Compare commits
1
Commits
next
..
477a8d07d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
477a8d07d3 |
+4
-13
@@ -1,23 +1,14 @@
|
|||||||
# .git is sent so the build can derive the version it stamps into the binary
|
|
||||||
# (script/version). Its config, which can hold a remote URL carrying a
|
|
||||||
# credential and which `git describe` does not need, is left out of a
|
|
||||||
# directory context. A context sent as a tar is not filtered by this file, so
|
|
||||||
# it carries .git/config unless its sender leaves it out.
|
|
||||||
.git/config
|
|
||||||
|
|
||||||
# No tracked file may be listed here: git in the build would see it as
|
|
||||||
# deleted and mark the version -dirty.
|
|
||||||
#
|
|
||||||
# .ci-fingerprint is deliberately NOT excluded: it is the CI cache barrier
|
# .ci-fingerprint is deliberately NOT excluded: it is the CI cache barrier
|
||||||
# that keeps the check stages from replaying a cached pass. See the lint
|
# that keeps the check stages from replaying a cached pass. See the lint
|
||||||
# stage of the Dockerfile.
|
# stage of the Dockerfile.
|
||||||
|
.git/
|
||||||
bin/
|
bin/
|
||||||
# Extracted from 3p/ by `make assets` inside the build; a host copy is not
|
# Extracted from 3p/ by `make assets` inside the build; a host copy is not
|
||||||
# needed. The tarball in 3p/ must stay in the context.
|
# needed. The tarball in 3p/ must stay in the context.
|
||||||
static/js/alpine.min.js
|
static/js/alpine.min.js
|
||||||
# The js-deps stage installs ESLint and prettier; a host copy would overwrite
|
*.md
|
||||||
# them at the `COPY . .` of the stages built on it.
|
LICENSE
|
||||||
node_modules/
|
.editorconfig
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
*.db
|
*.db
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23
|
||||||
with:
|
with:
|
||||||
# The superseded-status step needs history to walk ancestors (it
|
# The fingerprint step below needs history to find the last commit
|
||||||
# aborts on a shallow clone).
|
# that touched the Docker build context, and the superseded-status
|
||||||
|
# step needs it to walk ancestors (it aborts on a shallow clone).
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Mark superseded run statuses
|
- name: Mark superseded run statuses
|
||||||
@@ -27,11 +28,16 @@ jobs:
|
|||||||
run: script/ci-mark-superseded
|
run: script/ci-mark-superseded
|
||||||
|
|
||||||
- name: Fingerprint the build context
|
- name: Fingerprint the build context
|
||||||
# Writes the hash of the commit being checked into the context, which
|
# `.dockerignore` keeps docs out of the build context, so a docs-only
|
||||||
# invalidates the `COPY . .` layer of every check stage: a commit
|
# commit legitimately replays the whole image from cache and stays
|
||||||
# that was never linted, format-checked, stylesheet-checked, tested
|
# cheap. Every other commit writes a new fingerprint into the context,
|
||||||
# and built cannot report success from cache.
|
# which invalidates the `COPY . .` layer of both check stages: a
|
||||||
run: git rev-parse HEAD > .ci-fingerprint
|
# commit that was never linted, formatted-checked, tested and built
|
||||||
|
# cannot report success from cache.
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
fp="$(git log -1 --format=%H -- . ':!*.md' ':!LICENSE' ':!.editorconfig')"
|
||||||
|
printf '%s\n' "${fp:-$GITHUB_SHA}" > .ci-fingerprint
|
||||||
|
|
||||||
- name: Build Docker image (runs the gofmt check, golangci-lint, the stylesheet check, ESLint, the Markdown check, make test, make build)
|
- name: Build Docker image (runs make check)
|
||||||
run: script/cibuild
|
run: script/cibuild
|
||||||
|
|||||||
@@ -15,9 +15,6 @@ bin/
|
|||||||
# Go vendor directory
|
# Go vendor directory
|
||||||
vendor/
|
vendor/
|
||||||
|
|
||||||
# ESLint, prettier and their dependencies, installed from yarn.lock
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# IDE specific files
|
# IDE specific files
|
||||||
.idea/
|
.idea/
|
||||||
*.swp
|
*.swp
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"tabWidth": 4,
|
|
||||||
"proseWrap": "always"
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
Binary file not shown.
Binary file not shown.
+19
-112
@@ -4,105 +4,29 @@
|
|||||||
# compile on Alpine musl (off64_t is a glibc type).
|
# compile on Alpine musl (off64_t is a glibc type).
|
||||||
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends make && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Copy go mod files first for better layer caching
|
# Copy go mod files first for better layer caching
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
# Copy source code. In CI the context also carries .ci-fingerprint, which
|
# Copy source code. In CI the context also carries .ci-fingerprint, whose
|
||||||
# holds the hash of the commit being checked (see
|
# value changes with every commit that touches the build context (see
|
||||||
# .gitea/workflows/check.yml). That invalidates this layer, so the checks
|
# .gitea/workflows/check.yml). That invalidates this layer, so the checks
|
||||||
# below cannot report success by replaying a cached pass. Do not add it to
|
# below cannot report success by replaying a cached pass. Do not add it to
|
||||||
# .dockerignore.
|
# .dockerignore.
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Run the Go formatting check and the linter. gofmt and golangci-lint are
|
# Run formatting check and linter. golangci-lint is invoked directly rather
|
||||||
# invoked directly rather than through `make fmt-check` and `make lint`: this
|
# than through `make lint`: this stage is already the pinned linter image, and
|
||||||
# stage is already the pinned linter image, and both scripts build docker
|
# script/lint is a wrapper that builds Dockerfile.lint, so calling it here
|
||||||
# stages, so calling them here would need a docker daemon inside the build.
|
# would need a docker daemon inside the build. Keep these steps in step with
|
||||||
# The Markdown half of `make fmt-check` is the markdown-check stage below.
|
# Dockerfile.lint, including --network=none (see its header for why).
|
||||||
# Keep the golangci-lint steps in step with Dockerfile.lint, including
|
RUN make fmt-check
|
||||||
# --network=none (see its header for why).
|
|
||||||
RUN if [ -n "$(gofmt -s -l .)" ]; then echo "gofmt needed on:"; gofmt -s -l .; exit 1; fi
|
|
||||||
RUN script/assets
|
|
||||||
RUN --network=none golangci-lint config verify --config .golangci.yml
|
RUN --network=none golangci-lint config verify --config .golangci.yml
|
||||||
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
|
RUN --network=none golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
# Stylesheet stages. static/css/tailwind.css is generated, by this pinned
|
|
||||||
# tailwindcss, from static/css/input.css and the files its @source lines
|
|
||||||
# name. `make css` (script/css) writes it out from the css-output stage.
|
|
||||||
# The css-check stage fails when the committed file differs from what is
|
|
||||||
# generated; `make check` runs it, and so does the build stage below.
|
|
||||||
#
|
|
||||||
# tailwindcss v4.2.1 standalone CLI, released 2026-02-23: one binary per
|
|
||||||
# architecture, each pinned by its sha256 from the release's sha256sums.txt.
|
|
||||||
# debian:bookworm-slim, 2026-10-02: the binary needs glibc.
|
|
||||||
FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 AS tailwind-amd64
|
|
||||||
ADD --checksum=sha256:39e8d4e24b3c83b0a6e69e100a972fbc75d5fef8dce47b3ddac3cf92dea81fe3 --chmod=755 \
|
|
||||||
https://github.com/tailwindlabs/tailwindcss/releases/download/v4.2.1/tailwindcss-linux-x64 /usr/local/bin/tailwindcss
|
|
||||||
|
|
||||||
FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 AS tailwind-arm64
|
|
||||||
ADD --checksum=sha256:d87e6486bb3f70b04ef1dcaacc4ee6548a5a15fbf521b31bc24d2c774f68a951 --chmod=755 \
|
|
||||||
https://github.com/tailwindlabs/tailwindcss/releases/download/v4.2.1/tailwindcss-linux-arm64 /usr/local/bin/tailwindcss
|
|
||||||
|
|
||||||
# TARGETARCH, set by docker, is the architecture being built for.
|
|
||||||
FROM tailwind-${TARGETARCH} AS css
|
|
||||||
WORKDIR /src
|
|
||||||
COPY . .
|
|
||||||
RUN tailwindcss -i static/css/input.css -o /out/tailwind.css --minify
|
|
||||||
|
|
||||||
FROM scratch AS css-output
|
|
||||||
COPY --from=css /out/tailwind.css /
|
|
||||||
|
|
||||||
# Both files are split after each "}", one rule per line, so that when they
|
|
||||||
# differ the diff shows the rules that differ.
|
|
||||||
FROM css AS css-check
|
|
||||||
RUN sed 's/}/}\n/g' static/css/tailwind.css > /tmp/committed.css \
|
|
||||||
&& sed 's/}/}\n/g' /out/tailwind.css > /tmp/generated.css \
|
|
||||||
&& diff -U0 /tmp/committed.css /tmp/generated.css || { \
|
|
||||||
echo "static/css/tailwind.css is not what make css generates; run make css" >&2; \
|
|
||||||
exit 1; \
|
|
||||||
}
|
|
||||||
|
|
||||||
# JavaScript lint stages: ESLint, at the version package.json and yarn.lock
|
|
||||||
# pin, checks static/js/ against eslint.config.mjs. js-deps installs it, and
|
|
||||||
# prettier for the Markdown stages below, and stays cached until package.json,
|
|
||||||
# yarn.lock or .yarnrc.yml changes. script/lint forces only js-lint to re-run,
|
|
||||||
# and the build stage below runs it too. COPY . . brings in the CI cache
|
|
||||||
# barrier described in the lint stage above.
|
|
||||||
#
|
|
||||||
# The image's own corepack runs the yarn that package.json's packageManager
|
|
||||||
# field names, yarn 4.18.1 (released 2026-09-24), and checks it against the
|
|
||||||
# hash there. The image also ships yarn 1, which `corepack enable yarn`
|
|
||||||
# replaces.
|
|
||||||
# node:24.21.0-alpine (LTS), 2026-09-18
|
|
||||||
FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS js-deps
|
|
||||||
WORKDIR /src
|
|
||||||
COPY package.json yarn.lock .yarnrc.yml ./
|
|
||||||
RUN corepack enable yarn && yarn install --immutable --mode=skip-build
|
|
||||||
|
|
||||||
FROM js-deps AS js-lint
|
|
||||||
COPY . .
|
|
||||||
RUN --network=none node_modules/.bin/eslint static/js
|
|
||||||
|
|
||||||
# Markdown stages: prettier, at the version package.json and yarn.lock pin,
|
|
||||||
# formats every Markdown file in the tree with the settings in .prettierrc.
|
|
||||||
# `make fmt` (script/fmt) writes the formatted files out from markdown-output.
|
|
||||||
# markdown-check fails on any file prettier would change; `make fmt-check`
|
|
||||||
# runs it, and so does the build stage below.
|
|
||||||
FROM js-deps AS markdown
|
|
||||||
COPY . .
|
|
||||||
RUN --network=none node_modules/.bin/prettier --write '**/*.md' \
|
|
||||||
&& mkdir /out \
|
|
||||||
&& find . -name '*.md' ! -path './node_modules/*' -exec cp -p --parents {} /out \;
|
|
||||||
|
|
||||||
FROM scratch AS markdown-output
|
|
||||||
COPY --from=markdown /out /
|
|
||||||
|
|
||||||
FROM js-deps AS markdown-check
|
|
||||||
COPY . .
|
|
||||||
RUN --network=none node_modules/.bin/prettier --check '**/*.md'
|
|
||||||
|
|
||||||
# Build stage
|
# Build stage
|
||||||
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
||||||
@@ -110,21 +34,12 @@ RUN --network=none node_modules/.bin/prettier --check '**/*.md'
|
|||||||
# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl.
|
# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl.
|
||||||
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder
|
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder
|
||||||
|
|
||||||
# Depend on the lint, stylesheet check, JavaScript lint and Markdown check
|
# Depend on lint stage passing
|
||||||
# stages passing
|
|
||||||
COPY --from=lint /src/go.sum /dev/null
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
COPY --from=css-check /out/tailwind.css /dev/null
|
|
||||||
COPY --from=js-lint /src/yarn.lock /dev/null
|
|
||||||
COPY --from=markdown-check /src/yarn.lock /dev/null
|
|
||||||
|
|
||||||
# jq is a runtime dependency of script/ci-mark-superseded, which the test
|
# jq is a runtime dependency of script/ci-mark-superseded, which the test
|
||||||
# suite executes. git is what script/version derives the version with.
|
# suite executes.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq git && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# A build context sent as a tar archive keeps its files' owners, and git
|
|
||||||
# refuses to read a checkout owned by another user. Trust this one
|
|
||||||
# whoever owns it.
|
|
||||||
RUN git config --system --add safe.directory /build
|
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
@@ -140,22 +55,14 @@ COPY . .
|
|||||||
# from its tarball in 3p/.
|
# from its tarball in 3p/.
|
||||||
RUN make test
|
RUN make test
|
||||||
|
|
||||||
# Version stamped into the binary: the VERSION build arg when one is
|
# Version stamped into the binary. .dockerignore excludes .git/, so
|
||||||
# given, otherwise what script/version derives from the .git the build
|
# nothing in this stage can derive it: script/docker resolves it on the
|
||||||
# context carries, so any `docker build .` of a clone stamps its commit.
|
# host and passes it in. The default is what a bare `docker build .`
|
||||||
# With neither, as from a source tarball, it is "unknown".
|
# with no --build-arg gets, and it names no tag the tree may not be at.
|
||||||
#
|
#
|
||||||
# Declared here, below the test step, so a changed version does not
|
# Declared here, below the test step, so a changed version does not
|
||||||
# invalidate its cached layer.
|
# invalidate its cached layer.
|
||||||
ARG VERSION
|
ARG VERSION=unknown
|
||||||
|
|
||||||
# A context that carries .git must not stamp "unknown": that means git is
|
|
||||||
# missing here or could not read the checkout, and the image could not be
|
|
||||||
# traced back to its commit.
|
|
||||||
RUN if [ -d .git ] && [ "$(make version VERSION="$VERSION")" = unknown ]; then \
|
|
||||||
echo "version is unknown although the build context carries .git" >&2; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
RUN make build VERSION="$VERSION"
|
RUN make build VERSION="$VERSION"
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
# Browser test image, built by script/test-browser (make test-browser). It
|
|
||||||
# runs the test in internal/server that loads the pages in a headless
|
|
||||||
# browser under the real Content-Security-Policy. That test is built only
|
|
||||||
# with the browser build tag, so make test leaves it out. Here the browser
|
|
||||||
# comes from a digest-pinned image, and if it is missing the test fails.
|
|
||||||
|
|
||||||
# golang:1.26.1-bookworm, 2026-03-17: the builder stage's image in Dockerfile.
|
|
||||||
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS build
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# The test binary embeds the templates and static files, so the browser
|
|
||||||
# stage needs nothing else. -p 4 keeps the compile's memory down, as in
|
|
||||||
# script/test.
|
|
||||||
RUN make assets && go test -c -p 4 -tags browser -o /browser.test ./internal/server
|
|
||||||
|
|
||||||
# chromedp/headless-shell:151.0.7922.109 (Debian trixie), 2026-08-11. The
|
|
||||||
# browser is on PATH as headless-shell, where the test's browser library
|
|
||||||
# looks for it.
|
|
||||||
FROM chromedp/headless-shell:151.0.7922.109@sha256:2d349b544a1ea6b5b5fd7c0fe99215ff662339c57407ee2e8c0a11af93516b04 AS browser
|
|
||||||
|
|
||||||
COPY --from=build /browser.test /browser.test
|
|
||||||
|
|
||||||
RUN /browser.test -test.v -test.timeout 90s -test.run '^TestAlpineRunsUnderTheSecurityPolicy$'
|
|
||||||
+1
-7
@@ -31,13 +31,7 @@ FROM deps AS lint
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# static/static.go embeds the Alpine.js file this extracts from 3p/; without
|
|
||||||
# it the static package does not compile and cannot be linted.
|
|
||||||
RUN script/assets
|
|
||||||
|
|
||||||
# `run` silently ignores config keys it does not recognize, so a typo would
|
# `run` silently ignores config keys it does not recognize, so a typo would
|
||||||
# disable a setting without a word. `config verify` is what catches that.
|
# disable a setting without a word. `config verify` is what catches that.
|
||||||
RUN --network=none golangci-lint config verify --config .golangci.yml
|
RUN --network=none golangci-lint config verify --config .golangci.yml
|
||||||
# --build-tags browser also lints the browser test, which is built only with
|
RUN --network=none golangci-lint run --config .golangci.yml ./...
|
||||||
# that tag (make test-browser).
|
|
||||||
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
.PHONY: bootstrap setup assets test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css css-check version
|
.PHONY: bootstrap setup assets test lint fmt fmt-check check build run dev deps docker clean hooks css version
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
.DEFAULT_GOAL := check
|
.DEFAULT_GOAL := check
|
||||||
|
|
||||||
# Version stamped into the binary. Derived from git by script/version;
|
# Version stamped into the binary. Derived from git by script/version;
|
||||||
# override it (`make build VERSION=v1.2.3`) to stamp a given value, which is
|
# override it (`make build VERSION=v1.2.3`) where git metadata is
|
||||||
# how the Dockerfile passes its build arg in.
|
# unavailable, which is how the Dockerfile passes its build arg in.
|
||||||
VERSION ?= $(shell script/version)
|
VERSION ?= $(shell script/version)
|
||||||
|
|
||||||
# An empty override (`make build VERSION=`, or the Dockerfile's `make build
|
# An empty override (`make build VERSION=`, or a `--build-arg VERSION=`
|
||||||
# VERSION="$VERSION"` when no VERSION build arg was given) means unset,
|
# landing on the Dockerfile's `make build VERSION="$VERSION"`) means unset,
|
||||||
# exactly as it does in script/version -- stamping "" would leave the binary
|
# exactly as it does in script/version -- stamping "" would leave the binary
|
||||||
# reporting no version and the footer back on its "dev" fallback. `override`
|
# reporting no version and the footer back on its "dev" fallback. `override`
|
||||||
# is required: a plain assignment loses to the command-line definition it
|
# is required: a plain assignment loses to the command-line definition it
|
||||||
@@ -33,9 +33,6 @@ assets:
|
|||||||
test:
|
test:
|
||||||
@script/test
|
@script/test
|
||||||
|
|
||||||
test-browser:
|
|
||||||
@script/test-browser
|
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
@script/lint
|
@script/lint
|
||||||
|
|
||||||
@@ -74,7 +71,4 @@ hooks:
|
|||||||
@script/install-precommit
|
@script/install-precommit
|
||||||
|
|
||||||
css:
|
css:
|
||||||
@script/css
|
tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
|
||||||
|
|
||||||
css-check:
|
|
||||||
@script/css-check
|
|
||||||
|
|||||||
@@ -2,367 +2,409 @@
|
|||||||
|
|
||||||
One issue per unit of work, one branch and one PR per issue:
|
One issue per unit of work, one branch and one PR per issue:
|
||||||
|
|
||||||
- ensure a tracked issue exists with a definition of done
|
* ensure a tracked issue exists with a definition of done
|
||||||
- branch from `next` (never from `main`)
|
* branch from `next` (never from `main`)
|
||||||
- do the work; open a PR based on `next` (never on `main`)
|
* do the work; open a PR based on `next` (never on `main`)
|
||||||
- pass an independent review, then the manager squash-merges into `next`
|
* pass an independent review, then the manager squash-merges into `next`
|
||||||
- push; nothing stays local-only
|
* push; nothing stays local-only
|
||||||
|
|
||||||
`next` is the branch for the next milestone and must stay green and mergeable to
|
`next` is the branch for the next milestone and must stay green and
|
||||||
`main` without notice. One `next` -> `main` PR accumulates the milestone;
|
mergeable to `main` without notice. One `next` -> `main` PR accumulates
|
||||||
releases are cut from `main` separately.
|
the milestone; releases are cut from `main` separately.
|
||||||
|
|
||||||
Issue branches do NOT touch this file — the manager maintains it on `next`.
|
Issue branches do NOT touch this file — the manager maintains it on
|
||||||
Every branch editing `TODO.md` conflicts with every other (#112).
|
`next`. Every branch editing `TODO.md` conflicts with every other
|
||||||
|
(#112).
|
||||||
|
|
||||||
# Status
|
# Status
|
||||||
|
|
||||||
The milestone (https://git.eeqj.de/sneak/webhooker/milestone/9) is the
|
The milestone (https://git.eeqj.de/sneak/webhooker/milestone/9) is the
|
||||||
authoritative list, and the only place to read a count or a state of play from.
|
authoritative list, and the only place to read a count or a state of
|
||||||
This file records where the project is, not what is in flight: a sentence whose
|
play from. This file records where the project is, not what is in
|
||||||
truth depends on a branch being unmerged is wrong the moment it merges, and this
|
flight: a sentence whose truth depends on a branch being unmerged is
|
||||||
file has been wrong that way before.
|
wrong the moment it merges, and this file has been wrong that way
|
||||||
|
before.
|
||||||
|
|
||||||
The durability defect that held the tag has landed
|
The durability defect that held the tag has landed
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/256, commit `8d64259`). Every SQLite
|
(https://git.eeqj.de/sneak/webhooker/issues/256, commit `8d64259`).
|
||||||
handle opens with WAL journaling and a busy timeout, a bookkeeping write that
|
Every SQLite handle opens with WAL journaling and a busy timeout, a
|
||||||
fails leaves its delivery in a recoverable state rather than a lying one, and
|
bookkeeping write that fails leaves its delivery in a recoverable
|
||||||
recovery skips a delivery that already has a successful result row. Final
|
state rather than a lying one, and recovery skips a delivery that
|
||||||
pre-tag verification exercised it and confirmed it holds. Whatever the milestone
|
already has a successful result row. Final pre-tag verification
|
||||||
still shows open is what remains before `v1.0.0`.
|
exercised it and confirmed it holds. Whatever the milestone still
|
||||||
|
shows open is what remains before `v1.0.0`.
|
||||||
|
|
||||||
Delivery is at-least-once by design, not by accident: a send whose result row
|
Delivery is at-least-once by design, not by accident: a send whose
|
||||||
does not land is attempted again, so a receiver can see a duplicate. That is
|
result row does not land is attempted again, so a receiver can see a
|
||||||
deliberate — the alternative is a silent lost delivery — and the README says so
|
duplicate. That is deliberate — the alternative is a silent lost
|
||||||
under Rationale. It is not a defect to re-file.
|
delivery — and the README says so under Rationale. It is not a defect
|
||||||
|
to re-file.
|
||||||
|
|
||||||
|
One caveat on reading a green check: a docs-only commit deliberately
|
||||||
|
replays from the layer cache
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/119), so a green status on
|
||||||
|
such a commit evidences a replay rather than an executed run. A code
|
||||||
|
commit invalidates the `COPY` layer and genuinely executes.
|
||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Clear the rest of the open 1.0.0 milestone
|
Clear the rest of the open 1.0.0 milestone
|
||||||
(https://git.eeqj.de/sneak/webhooker/milestone/9) and tag `v1.0.0`. Merging
|
(https://git.eeqj.de/sneak/webhooker/milestone/9) and tag `v1.0.0`.
|
||||||
`next` into `main` is a separate act from tagging and waits on neither of those:
|
Merging `next` into `main` is a separate act from tagging and waits on
|
||||||
`next` is kept mergeable at all times, which is the point of the branch.
|
neither of those: `next` is kept mergeable at all times, which is the
|
||||||
|
point of the branch.
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-08-24 Bind the plaintext HTTP listener deliberately, via `BIND_ADDRESS`
|
- 2026-08-24 Bind the plaintext HTTP listener deliberately, via
|
||||||
defaulting to `127.0.0.1`, and document the reverse-proxy deployment. A
|
`BIND_ADDRESS` defaulting to `127.0.0.1`, and document the
|
||||||
hostname, an empty value or a value carrying a port is a startup error, and
|
reverse-proxy deployment. A hostname, an empty value or a value
|
||||||
the `Dockerfile` sets `0.0.0.0` because a loopback bind inside a container is
|
carrying a port is a startup error, and the `Dockerfile` sets
|
||||||
unreachable (https://git.eeqj.de/sneak/webhooker/issues/268). The same commit
|
`0.0.0.0` because a loopback bind inside a container is unreachable
|
||||||
removed the shutdown race: `httpServer` is built in the constructor rather
|
(https://git.eeqj.de/sneak/webhooker/issues/268). The same commit
|
||||||
than assigned from the serving goroutine, which orders the write before every
|
removed the shutdown race: `httpServer` is built in the constructor
|
||||||
fx hook and rules out the nil dereference a SIGTERM arriving first would have
|
rather than assigned from the serving goroutine, which orders the
|
||||||
caused, and `sentryEnabled` is an `atomic.Bool`
|
write before every fx hook and rules out the nil dereference a
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/226)
|
SIGTERM arriving first would have caused, and `sentryEnabled` is an
|
||||||
- 2026-08-24 Remove inbound request signature verification. The entrypoint UUID
|
`atomic.Bool` (https://git.eeqj.de/sneak/webhooker/issues/226)
|
||||||
is the authentication secret, so the per-entrypoint shared secret, the
|
- 2026-08-24 Remove inbound request signature verification. The
|
||||||
`internal/signature` package, the receiver check, the model fields and the
|
entrypoint UUID is the authentication secret, so the per-entrypoint
|
||||||
forms are all gone. This reverses the feature that landed earlier in the same
|
shared secret, the `internal/signature` package, the receiver check,
|
||||||
milestone (https://git.eeqj.de/sneak/webhooker/issues/67,
|
the model fields and the forms are all gone. This reverses the
|
||||||
|
feature that landed earlier in the same milestone
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/67,
|
||||||
https://git.eeqj.de/sneak/webhooker/issues/279)
|
https://git.eeqj.de/sneak/webhooker/issues/279)
|
||||||
- 2026-08-24 Stamp the build version into the binary and render it in the UI
|
- 2026-08-24 Stamp the build version into the binary and render it in
|
||||||
footer. `script/version` is the single source — `$VERSION`, else
|
the UI footer. `script/version` is the single source — `$VERSION`,
|
||||||
`git describe --tags --always --dirty`, else `unknown` — so a `make build`
|
else `git describe --tags --always --dirty`, else `unknown` — so a
|
||||||
binary and a `make docker` image from one checkout report the same thing, and
|
`make build` binary and a `make docker` image from one checkout
|
||||||
nothing in it varies between two builds of the same commit, which the release
|
report the same thing, and nothing in it varies between two builds
|
||||||
gate's byte-identical assertion would catch
|
of the same commit, which the release gate's byte-identical
|
||||||
|
assertion would catch
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/253)
|
(https://git.eeqj.de/sneak/webhooker/issues/253)
|
||||||
- 2026-08-24 Derive cookie `Secure` and CSRF strictness from the request
|
- 2026-08-24 Derive cookie `Secure` and CSRF strictness from the
|
||||||
transport rather than from `WEBHOOKER_ENVIRONMENT`. Behind a real TLS proxy
|
request transport rather than from `WEBHOOKER_ENVIRONMENT`. Behind a
|
||||||
with the environment left at its `dev` default, the session cookie silently
|
real TLS proxy with the environment left at its `dev` default, the
|
||||||
lost `Secure` while the CSRF cookie on the same response kept it.
|
session cookie silently lost `Secure` while the CSRF cookie on the
|
||||||
`X-Forwarded-Proto` is now matched case-insensitively on its first
|
same response kept it. `X-Forwarded-Proto` is now matched
|
||||||
comma-separated element, so `HTTPS` and `https, http` no longer fall to the
|
case-insensitively on its first comma-separated element, so `HTTPS`
|
||||||
relaxed CSRF path (https://git.eeqj.de/sneak/webhooker/issues/269)
|
and `https, http` no longer fall to the relaxed CSRF path
|
||||||
- 2026-08-24 Roll back a failed webhook deletion instead of committing it. A
|
(https://git.eeqj.de/sneak/webhooker/issues/269)
|
||||||
failing delete committed whatever had already succeeded, hard-deleted the
|
- 2026-08-24 Roll back a failed webhook deletion instead of committing
|
||||||
per-webhook event database anyway, and redirected as though it had worked —
|
it. A failing delete committed whatever had already succeeded,
|
||||||
orphaned config plus permanently destroyed history, reported as success. All
|
hard-deleted the per-webhook event database anyway, and redirected as
|
||||||
three delete positions now roll back with the event database intact
|
though it had worked — orphaned config plus permanently destroyed
|
||||||
|
history, reported as success. All three delete positions now roll
|
||||||
|
back with the event database intact
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/262)
|
(https://git.eeqj.de/sneak/webhooker/issues/262)
|
||||||
- 2026-08-24 Name a deleted target on its historical deliveries, marked
|
- 2026-08-24 Name a deleted target on its historical deliveries, marked
|
||||||
`(deleted)`, rather than leaving the event log unable to say where a delivery
|
`(deleted)`, rather than leaving the event log unable to say where a
|
||||||
went. A deleted target's credentials stay masked exactly as a live one's, and
|
delivery went. A deleted target's credentials stay masked exactly as
|
||||||
it cannot become deliverable again through the receiver, resubmit, replay, the
|
a live one's, and it cannot become deliverable again through the
|
||||||
edit form or the toggle (https://git.eeqj.de/sneak/webhooker/issues/211)
|
receiver, resubmit, replay, the edit form or the toggle
|
||||||
- 2026-08-24 Bound both request-controlled `/metrics` label dimensions, so the
|
(https://git.eeqj.de/sneak/webhooker/issues/211)
|
||||||
unauthenticated receiver is no longer a memory-exhaustion vector: `handler`
|
- 2026-08-24 Bound both request-controlled `/metrics` label dimensions,
|
||||||
carries the chi route pattern, and `method` folds anything chi cannot route
|
so the unauthenticated receiver is no longer a memory-exhaustion
|
||||||
onto a single `(unmatched)` sentinel. Both were reproduced before the fix —
|
vector: `handler` carries the chi route pattern, and `method` folds
|
||||||
300 random method tokens took the series count from 106 to 7,631, and path
|
anything chi cannot route onto a single `(unmatched)` sentinel. Both
|
||||||
flooding reached 62,532 — and a label audit across a live scrape found no
|
were reproduced before the fix — 300 random method tokens took the
|
||||||
third unbounded dimension (https://git.eeqj.de/sneak/webhooker/issues/254,
|
series count from 106 to 7,631, and path flooding reached 62,532 —
|
||||||
|
and a label audit across a live scrape found no third unbounded
|
||||||
|
dimension (https://git.eeqj.de/sneak/webhooker/issues/254,
|
||||||
https://git.eeqj.de/sneak/webhooker/issues/261)
|
https://git.eeqj.de/sneak/webhooker/issues/261)
|
||||||
- 2026-08-24 Validate `max_retries` on both target forms. `abc`, `2.7` and `-5`
|
- 2026-08-24 Validate `max_retries` on both target forms. `abc`, `2.7`
|
||||||
silently became 0 — fire-and-forget — including on the edit path, where it
|
and `-5` silently became 0 — fire-and-forget — including on the edit
|
||||||
destroyed a working value, and `999999999` stored verbatim. The ceiling of 20
|
path, where it destroyed a working value, and `999999999` stored
|
||||||
is the `max` both templates already declared
|
verbatim. The ceiling of 20 is the `max` both templates already
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/221)
|
declared (https://git.eeqj.de/sneak/webhooker/issues/221)
|
||||||
- 2026-08-24 Resubmit a stored event as a new undelivered event, so a backend
|
- 2026-08-24 Resubmit a stored event as a new undelivered event, so a
|
||||||
under development can be tested against real captured traffic. Per-delivery
|
backend under development can be tested against real captured
|
||||||
replay cannot serve that: it re-sends one finished delivery to its own
|
traffic. Per-delivery replay cannot serve that: it re-sends one
|
||||||
original target, and a target created for a dev backend has no prior delivery
|
finished delivery to its own original target, and a target created
|
||||||
to replay. Resubmit re-injects the stored event at the top of the receiver
|
for a dev backend has no prior delivery to replay. Resubmit
|
||||||
path and fans it out to whatever targets are active now
|
re-injects the stored event at the top of the receiver path and fans
|
||||||
|
it out to whatever targets are active now
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/250)
|
(https://git.eeqj.de/sneak/webhooker/issues/250)
|
||||||
- 2026-08-20 Take an exclusive lock on `DATA_DIR` at startup, so two instances
|
- 2026-08-20 Take an exclusive lock on `DATA_DIR` at startup, so two
|
||||||
on one directory cannot both deliver
|
instances on one directory cannot both deliver
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/201)
|
(https://git.eeqj.de/sneak/webhooker/issues/201)
|
||||||
- 2026-08-20 Shut down the app when the HTTP listener fails. The `OnStart` hook
|
- 2026-08-20 Shut down the app when the HTTP listener fails. The
|
||||||
returned as soon as the serving goroutine was spawned, so a failed listen left
|
`OnStart` hook returned as soon as the serving goroutine was
|
||||||
fx reporting RUNNING and a live process with nothing bound — invisible to
|
spawned, so a failed listen left fx reporting RUNNING and a live
|
||||||
systemd and Docker restart policies
|
process with nothing bound — invisible to systemd and Docker restart
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/200)
|
policies (https://git.eeqj.de/sneak/webhooker/issues/200)
|
||||||
- 2026-08-20 Stop target credentials leaking into the per-webhook event
|
- 2026-08-20 Stop target credentials leaking into the per-webhook event
|
||||||
databases (https://git.eeqj.de/sneak/webhooker/issues/206), log SQL with
|
databases (https://git.eeqj.de/sneak/webhooker/issues/206), log SQL
|
||||||
placeholders rather than bound values
|
with placeholders rather than bound values
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/207), and fail loudly on half-set
|
(https://git.eeqj.de/sneak/webhooker/issues/207), and fail loudly on
|
||||||
metrics auth credentials (https://git.eeqj.de/sneak/webhooker/issues/205)
|
half-set metrics auth credentials
|
||||||
- 2026-08-20 Read queue depths with `Find`, not `Scan`. `Scan` swaps GORM's own
|
(https://git.eeqj.de/sneak/webhooker/issues/205)
|
||||||
trace recorder in for the logging adapter, and that recorder does not
|
- 2026-08-20 Read queue depths with `Find`, not `Scan`. `Scan` swaps
|
||||||
implement `gorm.ParamsFilter`, so those statements logged their bound values
|
GORM's own trace recorder in for the logging adapter, and that
|
||||||
interpolated and bypassed the suppression above. The two units gated green
|
recorder does not implement `gorm.ParamsFilter`, so those statements
|
||||||
against a `next` that lacked the other, and `next` went red when both landed
|
logged their bound values interpolated and bypassed the suppression
|
||||||
|
above. The two units gated green against a `next` that lacked the
|
||||||
|
other, and `next` went red when both landed
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/234)
|
(https://git.eeqj.de/sneak/webhooker/issues/234)
|
||||||
- 2026-08-20 Render per-attempt delivery detail in the event log
|
- 2026-08-20 Render per-attempt delivery detail in the event log
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/202) and add replay of a
|
(https://git.eeqj.de/sneak/webhooker/issues/202) and add replay of a
|
||||||
terminally failed delivery (https://git.eeqj.de/sneak/webhooker/issues/203)
|
terminally failed delivery
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/203)
|
||||||
- 2026-08-20 Expose delivery metrics on `/metrics`
|
- 2026-08-20 Expose delivery metrics on `/metrics`
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/209) and document the backup,
|
(https://git.eeqj.de/sneak/webhooker/issues/209) and document the
|
||||||
restore and upgrade procedures
|
backup, restore and upgrade procedures
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/210)
|
(https://git.eeqj.de/sneak/webhooker/issues/210)
|
||||||
- 2026-08-20 Add a `webhooker resetpw` subcommand and a bootstrap banner. The
|
- 2026-08-20 Add a `webhooker resetpw` subcommand and a bootstrap
|
||||||
admin bootstrap password was printed once among roughly 45 fx lines, and under
|
banner. The admin bootstrap password was printed once among roughly
|
||||||
`docker run -d` went to container logs subject to rotation; there was no reset
|
45 fx lines, and under `docker run -d` went to container logs subject
|
||||||
path at all, so recovery meant hand-deleting the users row, documented
|
to rotation; there was no reset path at all, so recovery meant
|
||||||
nowhere. The password is read from stdin or generated, never from argv where
|
hand-deleting the users row, documented nowhere. The password is read
|
||||||
`/proc` would publish it (https://git.eeqj.de/sneak/webhooker/issues/208)
|
from stdin or generated, never from argv where `/proc` would publish
|
||||||
- 2026-08-20 Add `ALLOWED_EGRESS_CIDRS`, an allowlist-only escape hatch for the
|
it (https://git.eeqj.de/sneak/webhooker/issues/208)
|
||||||
SSRF guard, so a self-hosted proxy can forward into the operator's own
|
- 2026-08-20 Add `ALLOWED_EGRESS_CIDRS`, an allowlist-only escape hatch
|
||||||
network. The guard's always-blocked set cannot be reopened by configuration
|
for the SSRF guard, so a self-hosted proxy can forward into the
|
||||||
|
operator's own network. The guard's always-blocked set cannot be
|
||||||
|
reopened by configuration
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/204)
|
(https://git.eeqj.de/sneak/webhooker/issues/204)
|
||||||
- 2026-08-20 Harden operator-set target headers, which were carried unsafely
|
- 2026-08-20 Harden operator-set target headers, which were carried
|
||||||
across a redirect (https://git.eeqj.de/sneak/webhooker/issues/233)
|
unsafely across a redirect
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/233)
|
||||||
- 2026-08-20 Add a target edit form with headers and timeout fields
|
- 2026-08-20 Add a target edit form with headers and timeout fields
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/127)
|
(https://git.eeqj.de/sneak/webhooker/issues/127)
|
||||||
- 2026-08-18 Raise `script/test`'s per-package timeout from 30s to 90s, matching
|
- 2026-08-18 Raise `script/test`'s per-package timeout from 30s to 90s,
|
||||||
the org-wide backstop. `go test` applies `-timeout` per package, and
|
matching the org-wide backstop. `go test` applies `-timeout` per
|
||||||
`internal/handlers` had grown past the old budget: a cache-defeated build
|
package, and `internal/handlers` had grown past the old budget: a
|
||||||
failed outright at `GOMAXPROCS=4`, and every run under deliberate host load
|
cache-defeated build failed outright at `GOMAXPROCS=4`, and every run
|
||||||
breached 30s. The measurement table lives in the script (#194)
|
under deliberate host load breached 30s. The measurement table lives
|
||||||
- 2026-08-18 Re-sync `REPO_POLICIES.md` from `prompts`. The local copy was stale
|
in the script (#194)
|
||||||
and still mandated a 20s test target with a 30s timeout, which the org
|
- 2026-08-18 Re-sync `REPO_POLICIES.md` from `prompts`. The local copy
|
||||||
replaced with a 60s cap and a 90s backstop. A synced copy is not a source;
|
was stale and still mandated a 20s test target with a 30s timeout,
|
||||||
reading it as one nearly produced a PR against `prompts` proposing a change
|
which the org replaced with a 60s cap and a 90s backstop. A synced
|
||||||
already merged there (#196)
|
copy is not a source; reading it as one nearly produced a PR against
|
||||||
- 2026-08-18 Report handler panics through the logger and answer 500. chi
|
`prompts` proposing a change already merged there (#196)
|
||||||
v1.5.5's `Recoverer` scans for a `panic(0x` frame the runtime no longer emits,
|
- 2026-08-18 Report handler panics through the logger and answer 500.
|
||||||
then indexes `pkg[-1:]`, so it panicked inside its own stack printer before
|
chi v1.5.5's `Recoverer` scans for a `panic(0x` frame the runtime no
|
||||||
writing a byte: the recovery never ran, the client got a dropped connection
|
longer emits, then indexes `pkg[-1:]`, so it panicked inside its own
|
||||||
instead of a 500, and the original panic was lost. A local middleware replaces
|
stack printer before writing a byte: the recovery never ran, the
|
||||||
it, bounded by `MaxPanicLogLineBytes` (#187)
|
client got a dropped connection instead of a 500, and the original
|
||||||
- 2026-08-18 Route GORM's logger through `slog` and bound it. Every `gorm.Open`
|
panic was lost. A local middleware replaces it, bounded by
|
||||||
left `logger.Default` in place at `Warn` with `IgnoreRecordNotFoundError`
|
`MaxPanicLogLineBytes` (#187)
|
||||||
false, so **every record-not-found printed the fully interpolated SQL to
|
- 2026-08-18 Route GORM's logger through `slog` and bound it. Every
|
||||||
stdout** — including the client-chosen path on `/webhook/{uuid}` and the
|
`gorm.Open` left `logger.Default` in place at `Warn` with
|
||||||
submitted username on the login form, at no level the operator set and outside
|
`IgnoreRecordNotFoundError` false, so **every record-not-found
|
||||||
`internal/logger` entirely. Three call sites, not the two the issue named
|
printed the fully interpolated SQL to stdout** — including the
|
||||||
(#178)
|
client-chosen path on `/webhook/{uuid}` and the submitted username on
|
||||||
- 2026-08-18 Bound every `slog` line against client-chosen text. Eight sites
|
the login form, at no level the operator set and outside
|
||||||
reachable unauthenticated, found by reading every `slog` call in the tree
|
`internal/logger` entirely. Three call sites, not the two the issue
|
||||||
rather than only the one reported; the budget moved to a shared
|
named (#178)
|
||||||
`internal/logfield` so no second truncation exists. `DEBUG` being off by
|
- 2026-08-18 Bound every `slog` line against client-chosen text. Eight
|
||||||
default is not a bound and is not treated as one (#176)
|
sites reachable unauthenticated, found by reading every `slog` call in
|
||||||
- 2026-08-18 Stop a slow host turning a login-guard test into a segfault. A
|
the tree rather than only the one reported; the budget moved to a
|
||||||
non-fatal `assert` on an acquire result was dereferenced on the next line, so
|
shared `internal/logfield` so no second truncation exists. `DEBUG`
|
||||||
one timing miss killed the whole `internal/middleware` binary and reddened CI
|
being off by default is not a bound and is not treated as one (#176)
|
||||||
for unrelated PRs. The fix also removed a real production race — `acquire`
|
- 2026-08-18 Stop a slow host turning a login-guard test into a
|
||||||
could shed a request with a slot standing free, because Go picks uniformly
|
segfault. A non-fatal `assert` on an acquire result was dereferenced
|
||||||
among ready `select` cases (#186)
|
on the next line, so one timing miss killed the whole
|
||||||
- 2026-08-18 Send the chi route pattern to Sentry rather than the concrete path.
|
`internal/middleware` binary and reddened CI for unrelated PRs. The
|
||||||
The receiver's path carries the entrypoint capability token, so every Sentry
|
fix also removed a real production race — `acquire` could shed a
|
||||||
event from `/webhook/{uuid}` shipped a live credential to a third party.
|
request with a slot standing free, because Go picks uniformly among
|
||||||
Request `Data`, `QueryString`, `Cookies` and `Env` are dropped and headers
|
ready `select` cases (#186)
|
||||||
reduced to an allowlist (#179)
|
- 2026-08-18 Send the chi route pattern to Sentry rather than the
|
||||||
- 2026-08-18 Read form fields from the POST body only. `r.FormValue` merges the
|
concrete path. The receiver's path carries the entrypoint capability
|
||||||
query string, so a login could be driven by URL parameters — putting the
|
token, so every Sentry event from `/webhook/{uuid}` shipped a live
|
||||||
password somewhere that lands in access logs, proxy logs and browser history
|
credential to a third party. Request `Data`, `QueryString`, `Cookies`
|
||||||
(#160)
|
and `Env` are dropped and headers reduced to an allowlist (#179)
|
||||||
- 2026-08-18 Verify login credentials before spending rate-limit budget, so a
|
- 2026-08-18 Read form fields from the POST body only. `r.FormValue`
|
||||||
flood of wrong passwords cannot lock out the account it is guessing at. The
|
merges the query string, so a login could be driven by URL parameters
|
||||||
manager took this decision rather than stall the queue; it is flagged on the
|
— putting the password somewhere that lands in access logs, proxy
|
||||||
issue for reversal (#150)
|
logs and browser history (#160)
|
||||||
- 2026-08-18 Run all linting in Docker via `Dockerfile.lint`. Host lint was
|
- 2026-08-18 Verify login credentials before spending rate-limit
|
||||||
wrong in both directions from version skew and shared caches. `script/lint`
|
budget, so a flood of wrong passwords cannot lock out the account it
|
||||||
asserts the summary line, because `--no-cache-filter` silently ignores a stage
|
is guessing at. The manager took this decision rather than stall the
|
||||||
name it does not match — the flag that makes the gate meaningful fails open
|
queue; it is flagged on the issue for reversal (#150)
|
||||||
(#109)
|
- 2026-08-18 Run all linting in Docker via `Dockerfile.lint`. Host lint
|
||||||
- 2026-08-18 Serve an event's full stored body over HTTP. The list query
|
was wrong in both directions from version skew and shared caches.
|
||||||
truncates for rendering, and that truncated value was the only way to read a
|
`script/lint` asserts the summary line, because `--no-cache-filter`
|
||||||
body, so the full payload was unreachable (#157)
|
silently ignores a stage name it does not match — the flag that makes
|
||||||
|
the gate meaningful fails open (#109)
|
||||||
|
- 2026-08-18 Serve an event's full stored body over HTTP. The list
|
||||||
|
query truncates for rendering, and that truncated value was the only
|
||||||
|
way to read a body, so the full payload was unreachable (#157)
|
||||||
- 2026-08-18 Bound the access log line against client-chosen text.
|
- 2026-08-18 Bound the access log line against client-chosen text.
|
||||||
`internal/logfield` budgets by _encoded_ bytes, not runes, so a handler's JSON
|
`internal/logfield` budgets by *encoded* bytes, not runes, so a
|
||||||
escaping cannot multiply a field past its allowance (#146)
|
handler's JSON escaping cannot multiply a field past its allowance
|
||||||
- 2026-08-18 Mark superseded CI commits `failure` rather than `skipped`. A
|
(#146)
|
||||||
skipped run rolls up green, so a commit that was never tested reported success
|
- 2026-08-18 Mark superseded CI commits `failure` rather than
|
||||||
(#152)
|
`skipped`. A skipped run rolls up green, so a commit that was never
|
||||||
- 2026-08-18 Set `fx.StopTimeout` inside the container stop grace, so shutdown
|
tested reported success (#152)
|
||||||
hooks are bounded by a deadline the orchestrator will actually honour rather
|
- 2026-08-18 Set `fx.StopTimeout` inside the container stop grace, so
|
||||||
than being killed mid-flush (#134)
|
shutdown hooks are bounded by a deadline the orchestrator will
|
||||||
- 2026-08-17 Bucket IPv6 rate-limit keys by `/64`. A single allocation hands out
|
actually honour rather than being killed mid-flush (#134)
|
||||||
2^64 addresses, so per-address keying let one client mint unlimited buckets.
|
- 2026-08-17 Bucket IPv6 rate-limit keys by `/64`. A single allocation
|
||||||
Manager decision, recorded on the issue (#125)
|
hands out 2^64 addresses, so per-address keying let one client mint
|
||||||
- 2026-08-17 Correct release-blocking README and startup-warning inaccuracies,
|
unlimited buckets. Manager decision, recorded on the issue (#125)
|
||||||
including claims about behaviour the code does not have (#151)
|
- 2026-08-17 Correct release-blocking README and startup-warning
|
||||||
|
inaccuracies, including claims about behaviour the code does not have
|
||||||
|
(#151)
|
||||||
- 2026-08-17 Fetch and verify Alpine.js at build time against
|
- 2026-08-17 Fetch and verify Alpine.js at build time against
|
||||||
`static/vendor.sha256` instead of committing the minified blob, so the
|
`static/vendor.sha256` instead of committing the minified blob, so
|
||||||
dependency is pinned by hash rather than by trust (#145)
|
the dependency is pinned by hash rather than by trust (#145)
|
||||||
- 2026-08-17 Bound the event log's rendered bodies in the query itself, so a
|
- 2026-08-17 Bound the event log's rendered bodies in the query itself,
|
||||||
large stored payload cannot be read into memory just to be truncated for
|
so a large stored payload cannot be read into memory just to be
|
||||||
display (#135)
|
truncated for display (#135)
|
||||||
- 2026-08-17 Mask the `http` target's destination URL in the UI: it can carry a
|
- 2026-08-17 Mask the `http` target's destination URL in the UI: it can
|
||||||
bearer credential in its path or query, and was rendered verbatim. Manager
|
carry a bearer credential in its path or query, and was rendered
|
||||||
decision to mask unconditionally (#115)
|
verbatim. Manager decision to mask unconditionally (#115)
|
||||||
- 2026-08-14 Bound shutdown hooks by their stop context, so a hook that hangs
|
- 2026-08-14 Bound shutdown hooks by their stop context, so a hook that
|
||||||
cannot hold the process past its grace period (#102)
|
hangs cannot hold the process past its grace period (#102)
|
||||||
- 2026-08-14 Render templates via a buffer rather than the `ResponseWriter`, so
|
- 2026-08-14 Render templates via a buffer rather than the
|
||||||
a template error part-way through cannot commit a 200 and then fail — the
|
`ResponseWriter`, so a template error part-way through cannot commit
|
||||||
response is written only once it is whole (#123)
|
a 200 and then fail — the response is written only once it is whole
|
||||||
- 2026-08-14 Align the session codec's max-age with the 7-day absolute cap. The
|
(#123)
|
||||||
codec accepted cookies the session layer considered expired, so the cap was
|
- 2026-08-14 Align the session codec's max-age with the 7-day absolute
|
||||||
enforced in one place and not the other (#108)
|
cap. The codec accepted cookies the session layer considered expired,
|
||||||
- 2026-08-12 Warn when `TRUSTED_PROXIES` is empty in production, where the safe
|
so the cap was enforced in one place and not the other (#108)
|
||||||
default silently discards forwarded headers and every client rate-limits as
|
- 2026-08-12 Warn when `TRUSTED_PROXIES` is empty in production, where
|
||||||
the proxy's address (#149)
|
the safe default silently discards forwarded headers and every client
|
||||||
- 2026-08-12 Bound the receiver rate limit per client IP across the whole
|
rate-limits as the proxy's address (#149)
|
||||||
`/webhook/*` route. The existing limiter keyed on the request path and
|
- 2026-08-12 Bound the receiver rate limit per client IP across the
|
||||||
`/webhook/{uuid}` matches any single segment, so a client that invented a
|
whole `/webhook/*` route. The existing limiter keyed on the request
|
||||||
fresh path per request minted a fresh bucket per request: the limit on the
|
path and `/webhook/{uuid}` matches any single segment, so a client
|
||||||
only unauthenticated endpoint bounded nothing in aggregate, and every request
|
that invented a fresh path per request minted a fresh bucket per
|
||||||
still cost an entrypoint lookup before it 404ed. An outer limiter keyed on the
|
request: the limit on the only unauthenticated endpoint bounded
|
||||||
client address alone now bounds that, chained in front of the unchanged
|
nothing in aggregate, and every request still cost an entrypoint
|
||||||
|
lookup before it 404ed. An outer limiter keyed on the client address
|
||||||
|
alone now bounds that, chained in front of the unchanged
|
||||||
per-entrypoint limiter (#139)
|
per-entrypoint limiter (#139)
|
||||||
- 2026-08-12 Correct release-blocking documentation inaccuracies: the README
|
- 2026-08-12 Correct release-blocking documentation inaccuracies: the
|
||||||
promised manual redelivery in the present tense in three places when nothing
|
README promised manual redelivery in the present tense in three
|
||||||
implements it (the same false claim also sat in the doc comment that was its
|
places when nothing implements it (the same false claim also sat in
|
||||||
source text), the env table omitted `RETENTION_SWEEP_INTERVAL`, and `TODO.md`
|
the doc comment that was its source text), the env table omitted
|
||||||
itself omitted five landed units (#141)
|
`RETENTION_SWEEP_INTERVAL`, and `TODO.md` itself omitted five landed
|
||||||
- 2026-08-12 Make the CI gate execute the checks it reports on. The workflow now
|
units (#141)
|
||||||
writes a build-context fingerprint before calling `script/cibuild`, so a code
|
- 2026-08-12 Make the CI gate execute the checks it reports on. The
|
||||||
commit invalidates the `COPY` layer of the lint and builder stages while a
|
workflow now writes a build-context fingerprint before calling
|
||||||
docs-only commit still replays from cache; a superseding run also rewrites the
|
`script/cibuild`, so a code commit invalidates the `COPY` layer of
|
||||||
`failure` status Gitea leaves on commits it cancelled and never tested.
|
the lint and builder stages while a docs-only commit still replays
|
||||||
Verified by pushing a deliberately broken test and watching CI go red (#119)
|
from cache; a superseding run also rewrites the `failure` status
|
||||||
- 2026-08-12 Require a positive `RETENTION_SWEEP_INTERVAL`: a non-positive value
|
Gitea leaves on commits it cancelled and never tested. Verified by
|
||||||
reached `time.NewTicker` in both the retention reaper and the archive sweeper,
|
pushing a deliberately broken test and watching CI go red (#119)
|
||||||
panicking two goroutines with no recover after startup had already reported
|
- 2026-08-12 Require a positive `RETENTION_SWEEP_INTERVAL`: a
|
||||||
success (#140)
|
non-positive value reached `time.NewTicker` in both the retention
|
||||||
- 2026-08-12 Bound the `X-Forwarded-For` scan's allocation to the hop cap: the
|
reaper and the archive sweeper, panicking two goroutines with no
|
||||||
reverse walk cuts entries with `strings.LastIndexByte` instead of joining and
|
recover after startup had already reported success (#140)
|
||||||
splitting, so a 1 MB header allocates 16 bytes rather than 1.6 MB per request
|
- 2026-08-12 Bound the `X-Forwarded-For` scan's allocation to the hop
|
||||||
on the unauthenticated receiver. Semantics proven unchanged by differential
|
cap: the reverse walk cuts entries with `strings.LastIndexByte`
|
||||||
testing against the previous implementation (#133)
|
instead of joining and splitting, so a 1 MB header allocates 16 bytes
|
||||||
|
rather than 1.6 MB per request on the unauthenticated receiver.
|
||||||
|
Semantics proven unchanged by differential testing against the
|
||||||
|
previous implementation (#133)
|
||||||
- 2026-08-12 Cap the `X-Forwarded-For` hop walk at 64 entries, so an
|
- 2026-08-12 Cap the `X-Forwarded-For` hop walk at 64 entries, so an
|
||||||
attacker-supplied chain cannot burn unbounded CPU in the rate-limit key
|
attacker-supplied chain cannot burn unbounded CPU in the rate-limit
|
||||||
function; running off the end falls back to the peer address (#124)
|
key function; running off the end falls back to the peer address
|
||||||
- 2026-08-12 Gate forwarded-header trust behind a `TRUSTED_PROXIES` CIDR list:
|
(#124)
|
||||||
all three rate limiters key on the connection's own address unless the direct
|
- 2026-08-12 Gate forwarded-header trust behind a `TRUSTED_PROXIES` CIDR
|
||||||
peer is a configured proxy, in which case `X-Forwarded-For` is walked right to
|
list: all three rate limiters key on the connection's own address
|
||||||
left for the first non-proxy hop. Default trusts nothing, and a
|
unless the direct peer is a configured proxy, in which case
|
||||||
set-but-unparseable value aborts startup. Before this, any client could mint a
|
`X-Forwarded-For` is walked right to left for the first non-proxy hop.
|
||||||
fresh bucket or drain another's by rotating a spoofed header (#88)
|
Default trusts nothing, and a set-but-unparseable value aborts
|
||||||
- 2026-08-11 Web UI cleanup: nav terminology unified on Webhooks, the Profile
|
startup. Before this, any client could mint a fresh bucket or drain
|
||||||
settings placeholder removed, a progressive-enhancement copy button for the
|
another's by rotating a spoofed header (#88)
|
||||||
entrypoint URL, and retention form copy that states the actual policy
|
- 2026-08-11 Web UI cleanup: nav terminology unified on Webhooks, the
|
||||||
(deletion by the reaper, 0 retains forever) (#57)
|
Profile settings placeholder removed, a progressive-enhancement copy
|
||||||
- 2026-08-11 Mask the webhook credential in delivery errors and logs: Go embeds
|
button for the entrypoint URL, and retention form copy that states the
|
||||||
the request URL in `*url.Error`, so every transport failure persisted the full
|
actual policy (deletion by the reaper, 0 retains forever) (#57)
|
||||||
Slack webhook URL into the per-webhook event database via
|
- 2026-08-11 Mask the webhook credential in delivery errors and logs:
|
||||||
`DeliveryResult.Error`, a field a future REST API would have served.
|
Go embeds the request URL in `*url.Error`, so every transport failure
|
||||||
`maskURLError` drops path, query and userinfo while preserving the wrapped
|
persisted the full Slack webhook URL into the per-webhook event
|
||||||
cause, so `errors.Is`/`As` and `Timeout()` still work and DNS, TLS and timeout
|
database via `DeliveryResult.Error`, a field a future REST API would
|
||||||
failures still read differently (#118)
|
have served. `maskURLError` drops path, query and userinfo while
|
||||||
|
preserving the wrapped cause, so `errors.Is`/`As` and `Timeout()`
|
||||||
|
still work and DNS, TLS and timeout failures still read differently
|
||||||
|
(#118)
|
||||||
- 2026-08-11 Rate-limit the public webhook receiver endpoint
|
- 2026-08-11 Rate-limit the public webhook receiver endpoint
|
||||||
(`RECEIVER_RATE_LIMIT`, default 120/min), keyed on client IP plus entrypoint
|
(`RECEIVER_RATE_LIMIT`, default 120/min), keyed on client IP plus
|
||||||
path so one entrypoint cannot exhaust another's budget; over-limit requests
|
entrypoint path so one entrypoint cannot exhaust another's budget;
|
||||||
get 429 with `Retry-After`. It was the one unauthenticated, internet-facing
|
over-limit requests get 429 with `Retry-After`. It was the one
|
||||||
endpoint with no limit at all (#64)
|
unauthenticated, internet-facing endpoint with no limit at all (#64)
|
||||||
- 2026-08-11 Enforce the body size limit before CSRF parses the form:
|
- 2026-08-11 Enforce the body size limit before CSRF parses the form:
|
||||||
`MaxBodySize` is now first in all four form-parsing route groups, so an
|
`MaxBodySize` is now first in all four form-parsing route groups, so
|
||||||
oversized request is rejected with 413 instead of being read in full by the
|
an oversized request is rejected with 413 instead of being read in
|
||||||
CSRF middleware before any cap applied (#90)
|
full by the CSRF middleware before any cap applied (#90)
|
||||||
- 2026-08-11 Mask target config on the source detail page, which rendered the
|
- 2026-08-11 Mask target config on the source detail page, which
|
||||||
stored blob verbatim and so exposed the Slack incoming-webhook URL — a bearer
|
rendered the stored blob verbatim and so exposed the Slack
|
||||||
credential that cannot be revoked per-holder. Config reaches the template only
|
incoming-webhook URL — a bearer credential that cannot be revoked
|
||||||
as a `TargetView` of labelled fields, and header values are rendered as a
|
per-holder. Config reaches the template only as a `TargetView` of
|
||||||
count (#113)
|
labelled fields, and header values are rendered as a count (#113)
|
||||||
- 2026-08-11 Allow `retention_days` of 0 to mean retain forever, via a sentinel
|
- 2026-08-11 Allow `retention_days` of 0 to mean retain forever, via a
|
||||||
written in `BeforeSave` so the GORM column default cannot win the race. Also
|
sentinel written in `BeforeSave` so the GORM column default cannot
|
||||||
bounds the reaper's cutoff arithmetic: day counts above 106751 overflowed
|
win the race. Also bounds the reaper's cutoff arithmetic: day counts
|
||||||
`time.Duration` and wrapped the cutoff into the future, where every row
|
above 106751 overflowed `time.Duration` and wrapped the cutoff into
|
||||||
matched and the sweep deleted everything (#79)
|
the future, where every row matched and the sweep deleted everything
|
||||||
|
(#79)
|
||||||
- 2026-08-09 Inactivity-based session timeout: sliding idle expiry
|
- 2026-08-09 Inactivity-based session timeout: sliding idle expiry
|
||||||
(`SESSION_IDLE_TIMEOUT`, default `24h`) refreshed on authenticated requests,
|
(`SESSION_IDLE_TIMEOUT`, default `24h`) refreshed on authenticated
|
||||||
with the 7-day absolute cap kept as an independent backstop that activity
|
requests, with the 7-day absolute cap kept as an independent
|
||||||
never extends (#66)
|
backstop that activity never extends (#66)
|
||||||
- 2026-08-09 Restart recovery and the 60s retry sweep terminally fail an
|
- 2026-08-09 Restart recovery and the 60s retry sweep terminally fail an
|
||||||
orphaned `retrying` delivery whose target type no longer supports retries,
|
orphaned `retrying` delivery whose target type no longer supports
|
||||||
recording a `DeliveryResult` with the reason instead of leaving the delivery
|
retries, recording a `DeliveryResult` with the reason instead of
|
||||||
stuck forever (#82)
|
leaving the delivery stuck forever (#82)
|
||||||
- 2026-08-09 Root the delivery engine's worker pool and the retention reaper's
|
- 2026-08-09 Root the delivery engine's worker pool and the retention
|
||||||
sweep loop at `context.Background()` rather than the fx `OnStart` hook context
|
reaper's sweep loop at `context.Background()` rather than the fx
|
||||||
(#97), which carries fx's 15s start timeout and killed both roughly fifteen
|
`OnStart` hook context (#97), which carries fx's 15s start timeout and
|
||||||
seconds after boot: the proxy silently stopped delivering webhooks entirely,
|
killed both roughly fifteen seconds after boot: the proxy silently
|
||||||
and the reaper never ran a single sweep under its default one-hour interval
|
stopped delivering webhooks entirely, and the reaper never ran a
|
||||||
- 2026-08-09 Archive writer lifecycle (#89): deleting a webhook (or its last
|
single sweep under its default one-hour interval
|
||||||
`database` target) evicts the cached archive writer and closes its handle
|
- 2026-08-09 Archive writer lifecycle (#89): deleting a webhook (or its
|
||||||
while deliberately leaving `archive-{webhookID}.db` on disk, and a new
|
last `database` target) evicts the cached archive writer and closes
|
||||||
`ArchiveSweeper` prunes idle archives on the existing
|
its handle while deliberately leaving `archive-{webhookID}.db` on
|
||||||
|
disk, and a new `ArchiveSweeper` prunes idle archives on the existing
|
||||||
`RETENTION_SWEEP_INTERVAL` without ever creating an archive file
|
`RETENTION_SWEEP_INTERVAL` without ever creating an archive file
|
||||||
- 2026-08-09 Configuration parsing fails loudly on set-but-unparseable
|
- 2026-08-09 Configuration parsing fails loudly on set-but-unparseable
|
||||||
environment values: `envInt` removed in favour of `envPositiveInt` plus a
|
environment values: `envInt` removed in favour of `envPositiveInt`
|
||||||
`PORT` range check, `envBool` now parses with `strconv.ParseBool`, and
|
plus a `PORT` range check, `envBool` now parses with
|
||||||
defaults apply only to unset variables (#80)
|
`strconv.ParseBool`, and defaults apply only to unset variables (#80)
|
||||||
- 2026-08-07 Automatic event retention cleanup based on `retention_days`,
|
- 2026-08-07 Automatic event retention cleanup based on
|
||||||
deleting expired events, deliveries, and delivery results from each
|
`retention_days`, deleting expired events, deliveries, and delivery
|
||||||
per-webhook event database (#63)
|
results from each per-webhook event database (#63)
|
||||||
- 2026-08-07 Update golangci-lint to v2.12.2 (Docker image digest in
|
- 2026-08-07 Update golangci-lint to v2.12.2 (Docker image digest in
|
||||||
`Dockerfile`, release-archive sha256 pins in `script/bootstrap`), adopt the
|
`Dockerfile`, release-archive sha256 pins in `script/bootstrap`),
|
||||||
canonical `.golangci.yml` (v2 `linters.settings` layout so
|
adopt the canonical `.golangci.yml` (v2 `linters.settings` layout so
|
||||||
`lll`/`funlen`/`cyclop`/`dupl` thresholds actually apply), and fix all newly
|
`lll`/`funlen`/`cyclop`/`dupl` thresholds actually apply), and fix
|
||||||
surfaced lint findings
|
all newly surfaced lint findings
|
||||||
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
|
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
|
||||||
shims, README Entrypoints section
|
Makefile shims, README Entrypoints section
|
||||||
- 2026-03-25 pin golangci-lint Docker image for linting (#55)
|
- 2026-03-25 pin golangci-lint Docker image for linting (#55)
|
||||||
- 2026-03-18 CSRF middleware detects TLS per-request, fixing login over plain
|
- 2026-03-18 CSRF middleware detects TLS per-request, fixing login over
|
||||||
HTTP and behind reverse proxies (#54)
|
plain HTTP and behind reverse proxies (#54)
|
||||||
- 2026-03-17 root path redirects based on auth state (#52)
|
- 2026-03-17 root path redirects based on auth state (#52)
|
||||||
- 2026-03-17 CSRF protection, SSRF prevention for HTTP delivery targets with DNS
|
- 2026-03-17 CSRF protection, SSRF prevention for HTTP delivery targets
|
||||||
rebinding defense, and per-IP login rate limiting (#42)
|
with DNS rebinding defense, and per-IP login rate limiting (#42)
|
||||||
- 2026-03-17 Slack target type for incoming webhook notifications (#47)
|
- 2026-03-17 Slack target type for incoming webhook notifications (#47)
|
||||||
- 2026-03-17 Dockerfile absolute paths and static linking (#49); absolute dev
|
- 2026-03-17 Dockerfile absolute paths and static linking (#49);
|
||||||
DATA_DIR default and clarified env docs (#46)
|
absolute dev DATA_DIR default and clarified env docs (#46)
|
||||||
- 2026-03-05 security headers middleware, session regeneration on login, request
|
- 2026-03-05 security headers middleware, session regeneration on
|
||||||
body size limits (#41)
|
login, request body size limits (#41)
|
||||||
- 2026-03-04 tests for delivery, middleware, and session packages (#32); removed
|
- 2026-03-04 tests for delivery, middleware, and session packages
|
||||||
the build-architecture global (#31)
|
(#32); removed the build-architecture global (#31)
|
||||||
- 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core delivery
|
- 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core
|
||||||
engine with bounded worker pool and circuit breaker, parallel fan-out,
|
delivery engine with bounded worker pool and circuit breaker,
|
||||||
per-webhook event databases, management UI (#16)
|
parallel fan-out, per-webhook event databases, management UI (#16)
|
||||||
- 2026-03-01 repo brought to REPO_POLICIES standards; TODO.md folded into README
|
- 2026-03-01 repo brought to REPO_POLICIES standards; TODO.md folded
|
||||||
(#6)
|
into README (#6)
|
||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
- Delivery status and retry management UI. Replay of a terminally failed
|
- Delivery status and retry management UI. Replay of a terminally
|
||||||
delivery and per-attempt detail already landed
|
failed delivery and per-attempt detail already landed
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/203,
|
(https://git.eeqj.de/sneak/webhooker/issues/203,
|
||||||
https://git.eeqj.de/sneak/webhooker/issues/202)
|
https://git.eeqj.de/sneak/webhooker/issues/202)
|
||||||
- Per-webhook rate limiting in the receiver handler (per-webhook config plus
|
- Per-webhook rate limiting in the receiver handler (per-webhook config
|
||||||
handler enforcement; global limits must not apply to receiver endpoints)
|
plus handler enforcement; global limits must not apply to receiver
|
||||||
- API key authentication for programmatic access (APIKey model exists; Bearer
|
endpoints)
|
||||||
token middleware does not)
|
- API key authentication for programmatic access (APIKey model exists;
|
||||||
|
Bearer token middleware does not)
|
||||||
- REST API v1
|
- REST API v1
|
||||||
- CRUD for webhooks, entrypoints, targets
|
- CRUD for webhooks, entrypoints, targets
|
||||||
- event viewing and filtering endpoints
|
- event viewing and filtering endpoints
|
||||||
@@ -370,9 +412,9 @@ Clear the rest of the open 1.0.0 milestone
|
|||||||
- OpenAPI specification
|
- OpenAPI specification
|
||||||
- Analytics dashboard: success rates, response times, volume
|
- Analytics dashboard: success rates, response times, volume
|
||||||
- A remember-me option at login
|
- A remember-me option at login
|
||||||
- Password reset flow for a forgotten password over the web. The authenticated
|
- Password reset flow for a forgotten password over the web. The
|
||||||
password _change_ flow already landed, and a lost password is recoverable from
|
authenticated password *change* flow already landed, and a lost
|
||||||
the console with `webhooker resetpw`
|
password is recoverable from the console with `webhooker resetpw`
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/208)
|
(https://git.eeqj.de/sneak/webhooker/issues/208)
|
||||||
- Later, nice to have
|
- Later, nice to have
|
||||||
- email delivery target type
|
- email delivery target type
|
||||||
|
|||||||
+12
-36
@@ -8,7 +8,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"go.uber.org/fx/fxevent"
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/datadir"
|
"sneak.berlin/go/webhooker/internal/datadir"
|
||||||
@@ -17,7 +16,6 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
"sneak.berlin/go/webhooker/internal/healthcheck"
|
"sneak.berlin/go/webhooker/internal/healthcheck"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
"sneak.berlin/go/webhooker/internal/metrics"
|
|
||||||
"sneak.berlin/go/webhooker/internal/middleware"
|
"sneak.berlin/go/webhooker/internal/middleware"
|
||||||
"sneak.berlin/go/webhooker/internal/resetpw"
|
"sneak.berlin/go/webhooker/internal/resetpw"
|
||||||
"sneak.berlin/go/webhooker/internal/server"
|
"sneak.berlin/go/webhooker/internal/server"
|
||||||
@@ -38,19 +36,17 @@ import (
|
|||||||
// hook that used the whole budget would exhaust it at that instant,
|
// hook that used the whole budget would exhaust it at that instant,
|
||||||
// and fx would skip every hook after the server — the delivery
|
// and fx would skip every hook after the server — the delivery
|
||||||
// engine, the healthcheck, the webhook DB manager and the database
|
// engine, the healthcheck, the webhook DB manager and the database
|
||||||
// close. That hook is the HTTP drain plus the Sentry flush that
|
// close. That hook is the 3s HTTP drain plus the Sentry flush that
|
||||||
// follows it in the same hook, and each is clamped to the stop
|
// follows it in the same hook, so the flush is clamped to the stop
|
||||||
// context's remaining time less server.TailHookReserve rather than
|
// context's remaining time less server.TailHookReserve rather than
|
||||||
// running for its own fixed 3s and 2s; the reserve is what the tail
|
// running for its own fixed 2s; the reserve is what the tail hooks
|
||||||
// hooks live on, and they are microsecond-scale in normal operation.
|
// live on, and they are microsecond-scale in normal operation.
|
||||||
// TestStopTimeout_LeavesHeadroomForTailHooks pins the arithmetic
|
// TestStopTimeout_LeavesHeadroomForTailHooks pins the arithmetic
|
||||||
// across every drain length and every amount of budget the hooks
|
// across every drain length.
|
||||||
// before the server may already have spent.
|
|
||||||
//
|
//
|
||||||
// This does not make the database close unconditional: the
|
// This does not make the database close unconditional: the
|
||||||
// ArchiveSweeper and RetentionReaper hooks run before the server.
|
// ArchiveSweeper and RetentionReaper hooks run before the server
|
||||||
// What they spend comes out of the drain first, but past 3s it comes
|
// and can still consume the whole budget on their own.
|
||||||
// out of the reserve, and they can consume the whole budget.
|
|
||||||
const stopTimeout = 5 * time.Second
|
const stopTimeout = 5 * time.Second
|
||||||
|
|
||||||
// exitUsage is the status for a command line this binary cannot make
|
// exitUsage is the status for a command line this binary cannot make
|
||||||
@@ -171,19 +167,6 @@ func run(stderr io.Writer) int {
|
|||||||
func newApp() *fx.App {
|
func newApp() *fx.App {
|
||||||
return fx.New(
|
return fx.New(
|
||||||
fx.StopTimeout(stopTimeout),
|
fx.StopTimeout(stopTimeout),
|
||||||
// fx's own events go through the service's logger, not fx's
|
|
||||||
// console logger on standard error. The exception is a failure
|
|
||||||
// before this logger is built, such as an invalid configuration
|
|
||||||
// value, which fx's console logger still prints there. fx holds
|
|
||||||
// its events back until this logger is built and then replays
|
|
||||||
// them, so it takes the configuration, which sets the level
|
|
||||||
// DEBUG=true asks for: without it the replay would run at INFO
|
|
||||||
// and drop every record of how the graph was built.
|
|
||||||
fx.WithLogger(
|
|
||||||
func(l *logger.Logger, _ *config.Config) fxevent.Logger {
|
|
||||||
return logger.NewFxLogger(l.Get())
|
|
||||||
},
|
|
||||||
),
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -194,10 +177,6 @@ func newApp() *fx.App {
|
|||||||
healthcheck.New,
|
healthcheck.New,
|
||||||
session.New,
|
session.New,
|
||||||
handlers.New,
|
handlers.New,
|
||||||
// The registry /metrics serves, and the delivery
|
|
||||||
// collectors registered on it.
|
|
||||||
metrics.NewRegistry,
|
|
||||||
metrics.New,
|
|
||||||
middleware.New,
|
middleware.New,
|
||||||
// The one SSRF guard both target-creation validation
|
// The one SSRF guard both target-creation validation
|
||||||
// and the delivery dialer consult, so they cannot
|
// and the delivery dialer consult, so they cannot
|
||||||
@@ -208,14 +187,11 @@ func newApp() *fx.App {
|
|||||||
// Wire *delivery.Engine as delivery.Notifier so the
|
// Wire *delivery.Engine as delivery.Notifier so the
|
||||||
// webhook handler can notify the engine of new deliveries.
|
// webhook handler can notify the engine of new deliveries.
|
||||||
func(e *delivery.Engine) delivery.Notifier { return e },
|
func(e *delivery.Engine) delivery.Notifier { return e },
|
||||||
// Wire *delivery.Engine as delivery.Archives so deleting
|
// Wire *delivery.Engine as delivery.WebhookEvictor so
|
||||||
// or renaming a webhook or target reaches its archive
|
// deleting a webhook releases its archive writer.
|
||||||
// files.
|
func(e *delivery.Engine) delivery.WebhookEvictor {
|
||||||
func(e *delivery.Engine) delivery.Archives { return e },
|
return e
|
||||||
// Wire *delivery.Engine as delivery.CircuitBreakers so
|
},
|
||||||
// the pages can show a target whose deliveries are
|
|
||||||
// paused.
|
|
||||||
func(e *delivery.Engine) delivery.CircuitBreakers { return e },
|
|
||||||
server.New,
|
server.New,
|
||||||
),
|
),
|
||||||
fx.Invoke(
|
fx.Invoke(
|
||||||
|
|||||||
+8
-128
@@ -2,19 +2,12 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
|
||||||
"sneak.berlin/go/webhooker/internal/datadir"
|
"sneak.berlin/go/webhooker/internal/datadir"
|
||||||
"sneak.berlin/go/webhooker/internal/resetpw"
|
"sneak.berlin/go/webhooker/internal/resetpw"
|
||||||
"sneak.berlin/go/webhooker/internal/server"
|
"sneak.berlin/go/webhooker/internal/server"
|
||||||
@@ -37,7 +30,6 @@ const dockerStopGrace = 10 * time.Second
|
|||||||
// fx.New applies options before it executes invokes, so the timeout
|
// fx.New applies options before it executes invokes, so the timeout
|
||||||
// is set whether or not the graph itself can be constructed here.
|
// is set whether or not the graph itself can be constructed here.
|
||||||
func TestNewApp_StopTimeout(t *testing.T) {
|
func TestNewApp_StopTimeout(t *testing.T) {
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
t.Setenv("DATA_DIR", t.TempDir())
|
t.Setenv("DATA_DIR", t.TempDir())
|
||||||
|
|
||||||
got := newApp().StopTimeout()
|
got := newApp().StopTimeout()
|
||||||
@@ -46,100 +38,6 @@ func TestNewApp_StopTimeout(t *testing.T) {
|
|||||||
require.Less(t, got, dockerStopGrace)
|
require.Less(t, got, dockerStopGrace)
|
||||||
}
|
}
|
||||||
|
|
||||||
// freePort returns a loopback TCP port that was free a moment ago, by
|
|
||||||
// taking one and releasing it.
|
|
||||||
func freePort(t *testing.T) int {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var listenCfg net.ListenConfig
|
|
||||||
|
|
||||||
l, err := listenCfg.Listen(t.Context(), "tcp", "127.0.0.1:0")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
addr, ok := l.Addr().(*net.TCPAddr)
|
|
||||||
require.True(t, ok, "listener is not TCP")
|
|
||||||
require.NoError(t, l.Close())
|
|
||||||
|
|
||||||
return addr.Port
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewApp_SendsFxEventsToTheLogger starts and stops the app main
|
|
||||||
// runs, with DEBUG=true, and reads back what reached the service's
|
|
||||||
// logger. fx's own events must arrive there as structured records:
|
|
||||||
// the start at INFO, and at DEBUG the records of how the graph was
|
|
||||||
// built.
|
|
||||||
//
|
|
||||||
// fx holds its events back until its logger is built and then replays
|
|
||||||
// them all at once, so the earliest of them arriving shows the replay
|
|
||||||
// ran at DEBUG: that globals.New was provided, which fx records before
|
|
||||||
// anything is built, and the run of logger.New, which happens before
|
|
||||||
// the configuration sets the level.
|
|
||||||
func TestNewApp_SendsFxEventsToTheLogger(t *testing.T) {
|
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
t.Setenv("DATA_DIR", t.TempDir())
|
|
||||||
t.Setenv("PORT", strconv.Itoa(freePort(t)))
|
|
||||||
t.Setenv("DEBUG", "true")
|
|
||||||
|
|
||||||
// internal/logger writes to whatever os.Stdout is when it builds
|
|
||||||
// its handler. A file is not a terminal, so that handler is the
|
|
||||||
// JSON one the service uses in production.
|
|
||||||
out, err := os.CreateTemp(t.TempDir(), "stdout")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
stdout := os.Stdout
|
|
||||||
os.Stdout = out
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
os.Stdout = stdout
|
|
||||||
_ = out.Close()
|
|
||||||
})
|
|
||||||
|
|
||||||
app := newApp()
|
|
||||||
require.NoError(t, app.Start(t.Context()))
|
|
||||||
require.NoError(t, app.Stop(t.Context()))
|
|
||||||
|
|
||||||
_, err = out.Seek(0, io.SeekStart)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
written, err := io.ReadAll(out)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
type record struct {
|
|
||||||
Level string `json:"level"`
|
|
||||||
Msg string `json:"msg"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Constructor string `json:"constructor"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var records []record
|
|
||||||
|
|
||||||
for line := range strings.Lines(string(written)) {
|
|
||||||
var r record
|
|
||||||
|
|
||||||
// The first-boot banner is plain text, not a record.
|
|
||||||
if json.Unmarshal([]byte(line), &r) == nil {
|
|
||||||
records = append(records, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pkg = "sneak.berlin/go/webhooker/internal/"
|
|
||||||
|
|
||||||
info := slog.LevelInfo.String()
|
|
||||||
debug := slog.LevelDebug.String()
|
|
||||||
|
|
||||||
assert.Contains(t, records, record{Level: info, Msg: "started"})
|
|
||||||
assert.Contains(t, records, record{
|
|
||||||
Level: debug, Msg: "provided", Constructor: pkg + "globals.New()",
|
|
||||||
})
|
|
||||||
assert.Contains(t, records, record{
|
|
||||||
Level: debug, Msg: "run", Name: pkg + "logger.New()",
|
|
||||||
})
|
|
||||||
assert.Contains(t, records, record{Level: debug, Msg: "invoking"})
|
|
||||||
assert.Contains(t, records, record{
|
|
||||||
Level: debug, Msg: "initialized custom fxevent.Logger",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRunRefusesLockedDataDir pins what an operator's second start
|
// TestRunRefusesLockedDataDir pins what an operator's second start
|
||||||
// does. The entry point must refuse before it builds the fx graph —
|
// does. The entry point must refuse before it builds the fx graph —
|
||||||
// nothing may open a database in a DATA_DIR another process holds —
|
// nothing may open a database in a DATA_DIR another process holds —
|
||||||
@@ -252,40 +150,22 @@ const tailHeadroom = 2 * time.Second
|
|||||||
// can produce, since a shorter drain leaves the flush more room and
|
// can produce, since a shorter drain leaves the flush more room and
|
||||||
// the worst case is not necessarily at either extreme.
|
// the worst case is not necessarily at either extreme.
|
||||||
//
|
//
|
||||||
// Nor does the hook start on a full budget: the ArchiveSweeper and
|
// Shrinking either budget, or unbounding the flush again, must fail
|
||||||
// RetentionReaper hooks run before it, and whatever they spent is
|
// here rather than silently recreating a hook that swallows the
|
||||||
// gone. The outer sweep walks every amount they can spend. Once they
|
// whole sequence.
|
||||||
// have eaten into the headroom themselves, the hook must spend
|
|
||||||
// nothing of what is left. A drain that starts on the full budget
|
|
||||||
// must still get all of ShutdownTimeout, so a smaller stopTimeout
|
|
||||||
// cannot silently shorten every drain.
|
|
||||||
//
|
|
||||||
// Shrinking either budget, or unbounding the drain or the flush
|
|
||||||
// again, must fail here rather than silently recreating a hook that
|
|
||||||
// swallows the whole sequence.
|
|
||||||
func TestStopTimeout_LeavesHeadroomForTailHooks(t *testing.T) {
|
func TestStopTimeout_LeavesHeadroomForTailHooks(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
require.Less(t, server.ShutdownTimeout, stopTimeout)
|
require.Less(t, server.ShutdownTimeout, stopTimeout)
|
||||||
require.Equal(
|
|
||||||
t, server.ShutdownTimeout, server.DrainBudget(stopTimeout),
|
|
||||||
"a drain that starts on the full stop budget is cut short",
|
|
||||||
)
|
|
||||||
|
|
||||||
const step = 10 * time.Millisecond
|
const step = 10 * time.Millisecond
|
||||||
|
|
||||||
for spent := time.Duration(0); spent <= stopTimeout; spent += step {
|
for drain := time.Duration(0); drain <= server.ShutdownTimeout; drain += step {
|
||||||
remaining := stopTimeout - spent
|
hook := drain + server.SentryFlushBudget(stopTimeout-drain)
|
||||||
longest := max(server.DrainBudget(remaining), 0)
|
|
||||||
|
|
||||||
for drain := time.Duration(0); drain <= longest; drain += step {
|
require.LessOrEqual(
|
||||||
hook := drain + server.SentryFlushBudget(remaining-drain)
|
t, hook+tailHeadroom, stopTimeout,
|
||||||
|
"a %s drain leaves the tail hooks short", drain,
|
||||||
require.GreaterOrEqual(
|
|
||||||
t, remaining-hook, min(remaining, tailHeadroom),
|
|
||||||
"a %s drain after %s of earlier hooks leaves "+
|
|
||||||
"the tail hooks short", drain, spent,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
// ESLint configuration for static/js/. script/lint and the image build run
|
|
||||||
// ESLint in the Dockerfile's js-lint stage, never on the host.
|
|
||||||
//
|
|
||||||
// The rules are the ones the JavaScript styleguide linked from
|
|
||||||
// REPO_POLICIES.md states that a linter can check: const for everything,
|
|
||||||
// let only for a variable that is reassigned, never var.
|
|
||||||
export default [
|
|
||||||
// Alpine.js, extracted from 3p/ by make assets; not ours to lint.
|
|
||||||
{ ignores: ["static/js/alpine.min.js"] },
|
|
||||||
{
|
|
||||||
// The pages load static/js/app.js as a classic script, not a module.
|
|
||||||
languageOptions: { sourceType: "script" },
|
|
||||||
rules: {
|
|
||||||
"no-var": "error",
|
|
||||||
"prefer-const": "error",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -4,8 +4,6 @@ go 1.26.1
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
||||||
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f
|
|
||||||
github.com/chromedp/chromedp v0.16.0
|
|
||||||
github.com/dustin/go-humanize v1.0.1
|
github.com/dustin/go-humanize v1.0.1
|
||||||
github.com/getsentry/sentry-go v0.25.0
|
github.com/getsentry/sentry-go v0.25.0
|
||||||
github.com/go-chi/chi v1.5.5
|
github.com/go-chi/chi v1.5.5
|
||||||
@@ -20,7 +18,7 @@ require (
|
|||||||
github.com/prometheus/client_model v0.5.0
|
github.com/prometheus/client_model v0.5.0
|
||||||
github.com/slok/go-http-metrics v0.11.0
|
github.com/slok/go-http-metrics v0.11.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
go.uber.org/fx v1.24.0
|
go.uber.org/fx v1.20.1
|
||||||
golang.org/x/crypto v0.38.0
|
golang.org/x/crypto v0.38.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/driver/sqlite v1.5.4
|
gorm.io/driver/sqlite v1.5.4
|
||||||
@@ -31,17 +29,13 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect
|
|
||||||
github.com/gobwas/httphead v0.1.0 // indirect
|
|
||||||
github.com/gobwas/pool v0.2.1 // indirect
|
|
||||||
github.com/gobwas/ws v1.4.0 // indirect
|
|
||||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.17 // indirect
|
github.com/mattn/go-sqlite3 v1.14.17 // indirect
|
||||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
|
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
|
||||||
@@ -50,12 +44,13 @@ require (
|
|||||||
github.com/prometheus/procfs v0.12.0 // indirect
|
github.com/prometheus/procfs v0.12.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||||
go.uber.org/dig v1.19.0 // indirect
|
go.uber.org/atomic v1.9.0 // indirect
|
||||||
go.uber.org/multierr v1.10.0 // indirect
|
go.uber.org/dig v1.17.0 // indirect
|
||||||
go.uber.org/zap v1.26.0 // indirect
|
go.uber.org/multierr v1.9.0 // indirect
|
||||||
|
go.uber.org/zap v1.23.0 // indirect
|
||||||
golang.org/x/mod v0.17.0 // indirect
|
golang.org/x/mod v0.17.0 // indirect
|
||||||
golang.org/x/sync v0.14.0 // indirect
|
golang.org/x/sync v0.14.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
golang.org/x/text v0.25.0 // indirect
|
golang.org/x/text v0.25.0 // indirect
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
||||||
google.golang.org/protobuf v1.31.0 // indirect
|
google.golang.org/protobuf v1.31.0 // indirect
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 h1:nMpu1t4amK3vJWBibQ5X/Nv0aXL+b69TQf2uK5PH7Go=
|
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 h1:nMpu1t4amK3vJWBibQ5X/Nv0aXL+b69TQf2uK5PH7Go=
|
||||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8/go.mod h1:3cARGAK9CfW3HoxCy1a0G4TKrdiKke8ftOMEOHyySYs=
|
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8/go.mod h1:3cARGAK9CfW3HoxCy1a0G4TKrdiKke8ftOMEOHyySYs=
|
||||||
|
github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
|
||||||
|
github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f h1:8PK9FM4bE0C8GMoWBW5lVsef3U7sPICjDg6JqngyYhk=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f/go.mod h1:3v4FIp5njIUyPDvqXsxEOxnB34lijG0up98/5kM1KaE=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
|
|
||||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
|
||||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
@@ -24,14 +23,6 @@ github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5
|
|||||||
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
||||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||||
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||||
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 h1:UADEEmDKgfXbtnGJZ97beY5XLo9ZechG1nlU4KnRrkE=
|
|
||||||
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
|
||||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
|
||||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
|
||||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
|
||||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
|
||||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
|
||||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
|
||||||
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
|
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
|
||||||
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
|
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
@@ -64,20 +55,17 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
|||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
|
||||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
|
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
|
||||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
|
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
|
||||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
|
||||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
|
||||||
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
||||||
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
|
github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
|
||||||
@@ -94,24 +82,28 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
|
|||||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
github.com/slok/go-http-metrics v0.11.0 h1:ABJUpekCZSkQT1wQrFvS4kGbhea/w6ndFJaWJeh3zL0=
|
github.com/slok/go-http-metrics v0.11.0 h1:ABJUpekCZSkQT1wQrFvS4kGbhea/w6ndFJaWJeh3zL0=
|
||||||
github.com/slok/go-http-metrics v0.11.0/go.mod h1:ZGKeYG1ET6TEJpQx18BqAJAvxw9jBAZXCHU7bWQqqAc=
|
github.com/slok/go-http-metrics v0.11.0/go.mod h1:ZGKeYG1ET6TEJpQx18BqAJAvxw9jBAZXCHU7bWQqqAc=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||||
go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
|
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||||
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
|
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||||
go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg=
|
go.uber.org/dig v1.17.0 h1:5Chju+tUvcC+N7N6EV08BJz41UZuO3BmHcN4A287ZLI=
|
||||||
go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo=
|
go.uber.org/dig v1.17.0/go.mod h1:rTxpf7l5I0eBTlE6/9RL+lDybC7WFwY2QH55ZSjy1mU=
|
||||||
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
|
go.uber.org/fx v1.20.1 h1:zVwVQGS8zYvhh9Xxcu4w1M6ESyeMzebzj2NbSayZ4Mk=
|
||||||
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
|
go.uber.org/fx v1.20.1/go.mod h1:iSYNbHf2y55acNCwCXKx7LbWb5WG1Bnue5RDXz1OREg=
|
||||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
|
||||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||||
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
|
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||||
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
|
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
||||||
|
go.uber.org/zap v1.23.0 h1:OjGQ5KQDEUawVHxNwQgPpiypGHOxo2mNZsOqTak4fFY=
|
||||||
|
go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY=
|
||||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||||
@@ -119,8 +111,8 @@ golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
|||||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||||
|
|||||||
+24
-26
@@ -80,7 +80,8 @@ const (
|
|||||||
// process over a Docker network or a private LAN connects from.
|
// process over a Docker network or a private LAN connects from.
|
||||||
defaultTrustedProxies = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
defaultTrustedProxies = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
||||||
|
|
||||||
// maxPort is the highest valid TCP port number.
|
// maxPort is the highest valid TCP port number. The lower
|
||||||
|
// bound (at least 1) is enforced by envPositiveInt.
|
||||||
maxPort = 65535
|
maxPort = 65535
|
||||||
|
|
||||||
// mappedV4Offset is the number of leading bits an IPv4-mapped
|
// mappedV4Offset is the number of leading bits an IPv4-mapped
|
||||||
@@ -104,7 +105,7 @@ var ErrInvalidEnvironment = errors.New("invalid environment")
|
|||||||
var ErrNonPositiveValue = errors.New("value must be positive")
|
var ErrNonPositiveValue = errors.New("value must be positive")
|
||||||
|
|
||||||
// ErrInvalidPort is returned when an environment variable holding a
|
// ErrInvalidPort is returned when an environment variable holding a
|
||||||
// TCP port number is set to a number outside 1 to 65535.
|
// TCP port number is set above the valid port range.
|
||||||
var ErrInvalidPort = errors.New("invalid port")
|
var ErrInvalidPort = errors.New("invalid port")
|
||||||
|
|
||||||
// ErrInvalidCIDR is returned when an environment variable holding a
|
// ErrInvalidCIDR is returned when an environment variable holding a
|
||||||
@@ -148,6 +149,7 @@ type ConfigParams struct {
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
DataDir string
|
DataDir string
|
||||||
Debug bool
|
Debug bool
|
||||||
|
MaintenanceMode bool
|
||||||
Environment string
|
Environment string
|
||||||
MetricsPassword string
|
MetricsPassword string
|
||||||
MetricsUsername string
|
MetricsUsername string
|
||||||
@@ -194,13 +196,12 @@ type Config struct {
|
|||||||
// otherwise refuse. The guard itself is always on: there is no
|
// otherwise refuse. The guard itself is always on: there is no
|
||||||
// setting that disables SSRF protection, and delivery's
|
// setting that disables SSRF protection, and delivery's
|
||||||
// alwaysBlockedNetworks stays blocked no matter what is listed
|
// alwaysBlockedNetworks stays blocked no matter what is listed
|
||||||
// here. That set is link-local, the unspecified addresses
|
// here. That set is link-local plus the cloud metadata
|
||||||
// 0.0.0.0 and ::, and the cloud metadata endpoints outside
|
// endpoints outside it that disclose credentials or user data
|
||||||
// link-local that disclose credentials or user data at a
|
// at a provider-fixed, non-public address; it is not
|
||||||
// provider-fixed, non-public address; it is not exhaustive of
|
// exhaustive of every cloud's metadata address. See
|
||||||
// every cloud's metadata address. See
|
// alwaysBlockedNetworks for the authoritative list and the
|
||||||
// alwaysBlockedNetworks for the authoritative list and why
|
// criterion it is built from.
|
||||||
// each entry is on it.
|
|
||||||
AllowedEgressCIDRs []netip.Prefix
|
AllowedEgressCIDRs []netip.Prefix
|
||||||
|
|
||||||
params *ConfigParams
|
params *ConfigParams
|
||||||
@@ -362,27 +363,17 @@ func envPositiveInt(
|
|||||||
// envPort returns the value of the named environment variable parsed
|
// envPort returns the value of the named environment variable parsed
|
||||||
// as a TCP port number. Returns defaultValue if not set. A set value
|
// as a TCP port number. Returns defaultValue if not set. A set value
|
||||||
// that is unparseable, below 1, or above maxPort is a hard error
|
// that is unparseable, below 1, or above maxPort is a hard error
|
||||||
// naming the key and the bad value; every out-of-range value wraps
|
// naming the key and the bad value.
|
||||||
// ErrInvalidPort, including one too large or too small for an int.
|
|
||||||
func envPort(key string, defaultValue int) (int, error) {
|
func envPort(key string, defaultValue int) (int, error) {
|
||||||
v := os.Getenv(key)
|
port, err := envPositiveInt(key, defaultValue)
|
||||||
if v == "" {
|
if err != nil {
|
||||||
return defaultValue, nil
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// strconv.ErrRange means a number too large or too small for an
|
if port > maxPort {
|
||||||
// int, which is outside the port range as well.
|
|
||||||
port, err := strconv.Atoi(v)
|
|
||||||
if err != nil && !errors.Is(err, strconv.ErrRange) {
|
|
||||||
return 0, fmt.Errorf(
|
return 0, fmt.Errorf(
|
||||||
"invalid integer for %s: %q: %w", key, v, err,
|
"%w: %s must be at most %d, got %d",
|
||||||
)
|
ErrInvalidPort, key, maxPort, port,
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil || port < 1 || port > maxPort {
|
|
||||||
return 0, fmt.Errorf(
|
|
||||||
"%w: %s must be from 1 to %d, got %q",
|
|
||||||
ErrInvalidPort, key, maxPort, v,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,6 +657,11 @@ func loadFromEnv() (*Config, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
maintenanceMode, err := envBool("MAINTENANCE_MODE", false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
retentionSweepInterval, err := envPositiveDuration(
|
retentionSweepInterval, err := envPositiveDuration(
|
||||||
"RETENTION_SWEEP_INTERVAL",
|
"RETENTION_SWEEP_INTERVAL",
|
||||||
defaultRetentionSweepInterval,
|
defaultRetentionSweepInterval,
|
||||||
@@ -715,6 +711,7 @@ func loadFromEnv() (*Config, error) {
|
|||||||
return &Config{
|
return &Config{
|
||||||
DataDir: DataDir(),
|
DataDir: DataDir(),
|
||||||
Debug: debug,
|
Debug: debug,
|
||||||
|
MaintenanceMode: maintenanceMode,
|
||||||
Environment: environment,
|
Environment: environment,
|
||||||
MetricsUsername: metricsUsername,
|
MetricsUsername: metricsUsername,
|
||||||
MetricsPassword: metricsPassword,
|
MetricsPassword: metricsPassword,
|
||||||
@@ -801,6 +798,7 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
|||||||
// host can reach the admin UI.
|
// host can reach the admin UI.
|
||||||
"bindAddress", s.BindAddress,
|
"bindAddress", s.BindAddress,
|
||||||
"debug", s.Debug,
|
"debug", s.Debug,
|
||||||
|
"maintenanceMode", s.MaintenanceMode,
|
||||||
"dataDir", s.DataDir,
|
"dataDir", s.DataDir,
|
||||||
"retentionSweepInterval", s.RetentionSweepInterval.String(),
|
"retentionSweepInterval", s.RetentionSweepInterval.String(),
|
||||||
// Logged because a perfectly valid non-positive value here
|
// Logged because a perfectly valid non-positive value here
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package config_test
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -70,12 +71,14 @@ func TestEnvironmentConfig(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
|
|
||||||
if tt.envValue != "" {
|
if tt.envValue != "" {
|
||||||
t.Setenv(
|
t.Setenv(
|
||||||
"WEBHOOKER_ENVIRONMENT", tt.envValue,
|
"WEBHOOKER_ENVIRONMENT", tt.envValue,
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, os.Unsetenv(
|
||||||
|
"WEBHOOKER_ENVIRONMENT",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
for k, v := range tt.envVars {
|
for k, v := range tt.envVars {
|
||||||
@@ -121,11 +124,6 @@ func testEnvironmentConfigSuccess(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
// fx's own log is discarded, not sent to t.Logf: a hook still
|
|
||||||
// running after a start or stop timeout would write there after
|
|
||||||
// the test has returned. The same holds for every fxtest.New
|
|
||||||
// below.
|
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -196,11 +194,14 @@ func TestRetentionSweepInterval(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||||
|
|
||||||
if tt.set {
|
if tt.set {
|
||||||
t.Setenv("RETENTION_SWEEP_INTERVAL", tt.value)
|
t.Setenv("RETENTION_SWEEP_INTERVAL", tt.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, os.Unsetenv(
|
||||||
|
"RETENTION_SWEEP_INTERVAL",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
if tt.expectError {
|
if tt.expectError {
|
||||||
@@ -271,7 +272,6 @@ func testRetentionSweepIntervalSuccess(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -335,11 +335,14 @@ func TestSessionIdleTimeout(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||||
|
|
||||||
if tt.set {
|
if tt.set {
|
||||||
t.Setenv("SESSION_IDLE_TIMEOUT", tt.value)
|
t.Setenv("SESSION_IDLE_TIMEOUT", tt.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, os.Unsetenv(
|
||||||
|
"SESSION_IDLE_TIMEOUT",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
if tt.expectError {
|
if tt.expectError {
|
||||||
@@ -361,7 +364,6 @@ func testSessionIdleTimeoutSuccess(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -388,17 +390,20 @@ func TestDefaultDataDir(t *testing.T) {
|
|||||||
t.Run("env="+name, func(t *testing.T) {
|
t.Run("env="+name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
|
|
||||||
if env != "" {
|
if env != "" {
|
||||||
t.Setenv("WEBHOOKER_ENVIRONMENT", env)
|
t.Setenv("WEBHOOKER_ENVIRONMENT", env)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, os.Unsetenv(
|
||||||
|
"WEBHOOKER_ENVIRONMENT",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
require.NoError(t, os.Unsetenv("DATA_DIR"))
|
||||||
|
|
||||||
var cfg *config.Config
|
var cfg *config.Config
|
||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -433,9 +438,9 @@ func TestDataDirHelper(t *testing.T) {
|
|||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
if set == "" {
|
||||||
|
require.NoError(t, os.Unsetenv("DATA_DIR"))
|
||||||
if set != "" {
|
} else {
|
||||||
t.Setenv("DATA_DIR", set)
|
t.Setenv("DATA_DIR", set)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,11 +503,14 @@ func TestReceiverRateLimit(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||||
|
|
||||||
if tt.set {
|
if tt.set {
|
||||||
t.Setenv("RECEIVER_RATE_LIMIT", tt.value)
|
t.Setenv("RECEIVER_RATE_LIMIT", tt.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, os.Unsetenv(
|
||||||
|
"RECEIVER_RATE_LIMIT",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
if tt.expectError {
|
if tt.expectError {
|
||||||
@@ -526,7 +534,6 @@ func testReceiverRateLimitSuccess(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -614,11 +621,12 @@ func TestTrustedProxies(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||||
|
|
||||||
if tt.set {
|
if tt.set {
|
||||||
t.Setenv("TRUSTED_PROXIES", tt.value)
|
t.Setenv("TRUSTED_PROXIES", tt.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, os.Unsetenv("TRUSTED_PROXIES"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if tt.expectError {
|
if tt.expectError {
|
||||||
@@ -642,7 +650,6 @@ func testTrustedProxiesSuccess(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -725,11 +732,14 @@ func TestAllowedEgressCIDRs(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||||
|
|
||||||
if tt.set {
|
if tt.set {
|
||||||
t.Setenv("ALLOWED_EGRESS_CIDRS", tt.value)
|
t.Setenv("ALLOWED_EGRESS_CIDRS", tt.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(
|
||||||
|
t, os.Unsetenv("ALLOWED_EGRESS_CIDRS"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if tt.expectError {
|
if tt.expectError {
|
||||||
@@ -753,7 +763,6 @@ func testAllowedEgressCIDRsSuccess(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -797,10 +806,13 @@ func TestEgressAllowlistWarning(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
t.Setenv("WEBHOOKER_ENVIRONMENT", config.EnvironmentDev)
|
t.Setenv("WEBHOOKER_ENVIRONMENT", config.EnvironmentDev)
|
||||||
|
|
||||||
if tt.allowed != "" {
|
if tt.allowed == "" {
|
||||||
|
require.NoError(
|
||||||
|
t, os.Unsetenv("ALLOWED_EGRESS_CIDRS"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
t.Setenv("ALLOWED_EGRESS_CIDRS", tt.allowed)
|
t.Setenv("ALLOWED_EGRESS_CIDRS", tt.allowed)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -933,14 +945,20 @@ func TestMetricsAuthConfig(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
|
|
||||||
if tt.username.set {
|
if tt.username.set {
|
||||||
t.Setenv("METRICS_USERNAME", tt.username.value)
|
t.Setenv("METRICS_USERNAME", tt.username.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(
|
||||||
|
t, os.Unsetenv("METRICS_USERNAME"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if tt.password.set {
|
if tt.password.set {
|
||||||
t.Setenv("METRICS_PASSWORD", tt.password.value)
|
t.Setenv("METRICS_PASSWORD", tt.password.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(
|
||||||
|
t, os.Unsetenv("METRICS_PASSWORD"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if tt.expectError {
|
if tt.expectError {
|
||||||
@@ -988,7 +1006,6 @@ func assertMetricsAuthAccepted(t *testing.T, expectAuth bool) {
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(globals.New, logger.New, config.New),
|
fx.Provide(globals.New, logger.New, config.New),
|
||||||
fx.Populate(&cfg),
|
fx.Populate(&cfg),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ const malformedDotEnv = "PORT 19615\n" +
|
|||||||
"this is not = valid ! syntax\n" +
|
"this is not = valid ! syntax\n" +
|
||||||
"\"unclosed\n"
|
"\"unclosed\n"
|
||||||
|
|
||||||
|
// unsetDotEnvKey makes dotEnvKey genuinely absent for the duration of
|
||||||
|
// the test and restores it afterwards. t.Setenv registers the restore;
|
||||||
|
// the Unsetenv that follows is what the test actually needs, because a
|
||||||
|
// variable set to the empty string is still present in os.Environ and
|
||||||
|
// godotenv would refuse to overwrite it.
|
||||||
|
func unsetDotEnvKey(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv(dotEnvKey, "placeholder")
|
||||||
|
require.NoError(t, os.Unsetenv(dotEnvKey))
|
||||||
|
}
|
||||||
|
|
||||||
// writeDotEnv writes contents to a .env file in a fresh temporary
|
// writeDotEnv writes contents to a .env file in a fresh temporary
|
||||||
// directory and returns its path.
|
// directory and returns its path.
|
||||||
func writeDotEnv(t *testing.T, contents string) string {
|
func writeDotEnv(t *testing.T, contents string) string {
|
||||||
@@ -39,9 +50,9 @@ func writeDotEnv(t *testing.T, contents string) string {
|
|||||||
// normally rather than be refused for a file it was never meant to
|
// normally rather than be refused for a file it was never meant to
|
||||||
// have.
|
// have.
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // ClearEnvForTest uses t.Setenv.
|
//nolint:paralleltest // unsetDotEnvKey uses t.Setenv.
|
||||||
func TestLoadDotEnv_MissingFileIsFine(t *testing.T) {
|
func TestLoadDotEnv_MissingFileIsFine(t *testing.T) {
|
||||||
config.ClearEnvForTest(t)
|
unsetDotEnvKey(t)
|
||||||
|
|
||||||
absent := filepath.Join(t.TempDir(), config.DotEnvPath)
|
absent := filepath.Join(t.TempDir(), config.DotEnvPath)
|
||||||
require.NoError(t, config.LoadDotEnvFileForTest(absent))
|
require.NoError(t, config.LoadDotEnvFileForTest(absent))
|
||||||
@@ -54,9 +65,9 @@ func TestLoadDotEnv_MissingFileIsFine(t *testing.T) {
|
|||||||
// reaches the environment, which is the whole reason the file is read
|
// reaches the environment, which is the whole reason the file is read
|
||||||
// at all.
|
// at all.
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // ClearEnvForTest uses t.Setenv.
|
//nolint:paralleltest // unsetDotEnvKey uses t.Setenv.
|
||||||
func TestLoadDotEnv_AppliesValues(t *testing.T) {
|
func TestLoadDotEnv_AppliesValues(t *testing.T) {
|
||||||
config.ClearEnvForTest(t)
|
unsetDotEnvKey(t)
|
||||||
|
|
||||||
path := writeDotEnv(t, "# a comment\n"+dotEnvKey+"=from-dot-env\n")
|
path := writeDotEnv(t, "# a comment\n"+dotEnvKey+"=from-dot-env\n")
|
||||||
|
|
||||||
@@ -82,9 +93,9 @@ func TestLoadDotEnv_RealEnvironmentWins(t *testing.T) {
|
|||||||
// reverts to its default; the process used to start that way with no
|
// reverts to its default; the process used to start that way with no
|
||||||
// log line naming the file at all.
|
// log line naming the file at all.
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // ClearEnvForTest uses t.Setenv.
|
//nolint:paralleltest // unsetDotEnvKey uses t.Setenv.
|
||||||
func TestLoadDotEnv_MalformedFileAborts(t *testing.T) {
|
func TestLoadDotEnv_MalformedFileAborts(t *testing.T) {
|
||||||
config.ClearEnvForTest(t)
|
unsetDotEnvKey(t)
|
||||||
|
|
||||||
path := writeDotEnv(
|
path := writeDotEnv(
|
||||||
t, malformedDotEnv+dotEnvKey+"=from-dot-env\n",
|
t, malformedDotEnv+dotEnvKey+"=from-dot-env\n",
|
||||||
@@ -132,7 +143,7 @@ func TestLoadDotEnv_UnreadableFileAborts(t *testing.T) {
|
|||||||
//
|
//
|
||||||
//nolint:paralleltest // t.Chdir moves the whole process.
|
//nolint:paralleltest // t.Chdir moves the whole process.
|
||||||
func TestLoadDotEnv_ReadsTheWorkingDirectory(t *testing.T) {
|
func TestLoadDotEnv_ReadsTheWorkingDirectory(t *testing.T) {
|
||||||
config.ClearEnvForTest(t)
|
unsetDotEnvKey(t)
|
||||||
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
require.NoError(t, os.WriteFile(
|
require.NoError(t, os.WriteFile(
|
||||||
|
|||||||
+96
-74
@@ -1,6 +1,7 @@
|
|||||||
package config_test
|
package config_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -19,6 +20,7 @@ const testEnvKey = "WEBHOOKER_TEST_VALUE"
|
|||||||
const (
|
const (
|
||||||
envKeyPort = "PORT"
|
envKeyPort = "PORT"
|
||||||
envKeyDebug = "DEBUG"
|
envKeyDebug = "DEBUG"
|
||||||
|
envKeyMaintenanceMode = "MAINTENANCE_MODE"
|
||||||
envKeyBindAddress = "BIND_ADDRESS"
|
envKeyBindAddress = "BIND_ADDRESS"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -120,10 +122,10 @@ func TestEnvBool(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
|
|
||||||
if tt.set {
|
if tt.set {
|
||||||
t.Setenv(testEnvKey, tt.value)
|
t.Setenv(testEnvKey, tt.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, os.Unsetenv(testEnvKey))
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := config.EnvBoolForTest(
|
got, err := config.EnvBoolForTest(
|
||||||
@@ -144,62 +146,17 @@ func TestEnvBool(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// envIntCase is one row of the envPositiveInt and envPort tables.
|
func TestEnvPositiveInt(t *testing.T) {
|
||||||
type envIntCase struct {
|
const defaultValue = 7
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
set bool
|
set bool
|
||||||
value string
|
value string
|
||||||
expectError bool
|
expectError bool
|
||||||
errIs error
|
errIs error
|
||||||
expected int
|
expected int
|
||||||
}
|
}{
|
||||||
|
|
||||||
// runEnvIntCases runs each row through parse, which is
|
|
||||||
// envPositiveInt or envPort, with testEnvKey set to the row's value
|
|
||||||
// or left unset.
|
|
||||||
func runEnvIntCases(
|
|
||||||
t *testing.T,
|
|
||||||
parse func(key string, defaultValue int) (int, error),
|
|
||||||
defaultValue int,
|
|
||||||
tests []envIntCase,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
|
||||||
// is incompatible with parallel subtests.
|
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
|
|
||||||
if tt.set {
|
|
||||||
t.Setenv(testEnvKey, tt.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := parse(testEnvKey, defaultValue)
|
|
||||||
|
|
||||||
if tt.expectError {
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), testEnvKey)
|
|
||||||
assert.Contains(t, err.Error(), tt.value)
|
|
||||||
|
|
||||||
if tt.errIs != nil {
|
|
||||||
require.ErrorIs(t, err, tt.errIs)
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, tt.expected, got)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:paralleltest // runEnvIntCases uses t.Setenv.
|
|
||||||
func TestEnvPositiveInt(t *testing.T) {
|
|
||||||
const defaultValue = 7
|
|
||||||
|
|
||||||
runEnvIntCases(t, config.EnvPositiveIntForTest, defaultValue, []envIntCase{
|
|
||||||
{
|
{
|
||||||
name: "unset returns the default integer",
|
name: "unset returns the default integer",
|
||||||
expected: defaultValue,
|
expected: defaultValue,
|
||||||
@@ -236,14 +193,51 @@ func TestEnvPositiveInt(t *testing.T) {
|
|||||||
expectError: true,
|
expectError: true,
|
||||||
errIs: config.ErrNonPositiveValue,
|
errIs: config.ErrNonPositiveValue,
|
||||||
},
|
},
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:paralleltest // runEnvIntCases uses t.Setenv.
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
|
// is incompatible with parallel subtests.
|
||||||
|
if tt.set {
|
||||||
|
t.Setenv(testEnvKey, tt.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, os.Unsetenv(testEnvKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := config.EnvPositiveIntForTest(
|
||||||
|
testEnvKey, defaultValue,
|
||||||
|
)
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), testEnvKey)
|
||||||
|
assert.Contains(t, err.Error(), tt.value)
|
||||||
|
|
||||||
|
if tt.errIs != nil {
|
||||||
|
require.ErrorIs(t, err, tt.errIs)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.expected, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnvPort(t *testing.T) {
|
func TestEnvPort(t *testing.T) {
|
||||||
const defaultValue = 8080
|
const defaultValue = 8080
|
||||||
|
|
||||||
runEnvIntCases(t, config.EnvPortForTest, defaultValue, []envIntCase{
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
set bool
|
||||||
|
value string
|
||||||
|
expectError bool
|
||||||
|
errIs error
|
||||||
|
expected int
|
||||||
|
}{
|
||||||
{
|
{
|
||||||
name: "unset returns the default port",
|
name: "unset returns the default port",
|
||||||
expected: defaultValue,
|
expected: defaultValue,
|
||||||
@@ -271,14 +265,7 @@ func TestEnvPort(t *testing.T) {
|
|||||||
set: true,
|
set: true,
|
||||||
value: "0",
|
value: "0",
|
||||||
expectError: true,
|
expectError: true,
|
||||||
errIs: config.ErrInvalidPort,
|
errIs: config.ErrNonPositiveValue,
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "negative is rejected",
|
|
||||||
set: true,
|
|
||||||
value: "-1",
|
|
||||||
expectError: true,
|
|
||||||
errIs: config.ErrInvalidPort,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "above the port range is rejected",
|
name: "above the port range is rejected",
|
||||||
@@ -287,15 +274,38 @@ func TestEnvPort(t *testing.T) {
|
|||||||
expectError: true,
|
expectError: true,
|
||||||
errIs: config.ErrInvalidPort,
|
errIs: config.ErrInvalidPort,
|
||||||
},
|
},
|
||||||
{
|
}
|
||||||
name: "too large for an int is rejected",
|
|
||||||
set: true,
|
for _, tt := range tests {
|
||||||
value: "99999999999999999999",
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
expectError: true,
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
errIs: config.ErrInvalidPort,
|
// is incompatible with parallel subtests.
|
||||||
},
|
if tt.set {
|
||||||
|
t.Setenv(testEnvKey, tt.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, os.Unsetenv(testEnvKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := config.EnvPortForTest(
|
||||||
|
testEnvKey, defaultValue,
|
||||||
|
)
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), testEnvKey)
|
||||||
|
|
||||||
|
if tt.errIs != nil {
|
||||||
|
require.ErrorIs(t, err, tt.errIs)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.expected, got)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestEnvBindAddress covers BIND_ADDRESS parsing.
|
// TestEnvBindAddress covers BIND_ADDRESS parsing.
|
||||||
//
|
//
|
||||||
@@ -310,10 +320,10 @@ func TestEnvBindAddress(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
|
|
||||||
if tt.set {
|
if tt.set {
|
||||||
t.Setenv(testEnvKey, tt.value)
|
t.Setenv(testEnvKey, tt.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, os.Unsetenv(testEnvKey))
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := config.EnvBindAddressForTest(
|
got, err := config.EnvBindAddressForTest(
|
||||||
@@ -476,7 +486,6 @@ func TestNewRejectsBadEnvValues(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||||
t.Setenv(tt.key, tt.value)
|
t.Setenv(tt.key, tt.value)
|
||||||
|
|
||||||
@@ -595,6 +604,12 @@ func flagEnvValueCases() []badEnvValueCase {
|
|||||||
value: "ture",
|
value: "ture",
|
||||||
expectError: true,
|
expectError: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "unparseable MAINTENANCE_MODE aborts startup",
|
||||||
|
key: envKeyMaintenanceMode,
|
||||||
|
value: "sometimes",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -638,15 +653,22 @@ func sentryEnvValueCases() []badEnvValueCase {
|
|||||||
// break the legitimate unset case: absent variables still get their
|
// break the legitimate unset case: absent variables still get their
|
||||||
// documented defaults.
|
// documented defaults.
|
||||||
func TestNewUsesDefaultsWhenUnset(t *testing.T) {
|
func TestNewUsesDefaultsWhenUnset(t *testing.T) {
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||||
|
|
||||||
|
for _, key := range []string{
|
||||||
|
envKeyPort, envKeyDebug, envKeyMaintenanceMode,
|
||||||
|
envKeyBindAddress, envKeySentryDSN,
|
||||||
|
} {
|
||||||
|
require.NoError(t, os.Unsetenv(key))
|
||||||
|
}
|
||||||
|
|
||||||
cfg, err := buildConfig(t)
|
cfg, err := buildConfig(t)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, cfg)
|
require.NotNil(t, cfg)
|
||||||
|
|
||||||
assert.Equal(t, 8080, cfg.Port)
|
assert.Equal(t, 8080, cfg.Port)
|
||||||
assert.False(t, cfg.Debug)
|
assert.False(t, cfg.Debug)
|
||||||
|
assert.False(t, cfg.MaintenanceMode)
|
||||||
|
|
||||||
// Loopback, not the wildcard: the default must not publish the
|
// Loopback, not the wildcard: the default must not publish the
|
||||||
// cleartext admin UI and the unauthenticated receiver on every
|
// cleartext admin UI and the unauthenticated receiver on every
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package config_test
|
package config_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -100,10 +101,10 @@ func TestEnvSentryDSN(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
|
|
||||||
if tt.set {
|
if tt.set {
|
||||||
t.Setenv(envKeySentryDSN, tt.value)
|
t.Setenv(envKeySentryDSN, tt.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, os.Unsetenv(envKeySentryDSN))
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := config.EnvSentryDSNForTest(envKeySentryDSN)
|
got, err := config.EnvSentryDSNForTest(envKeySentryDSN)
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ClearEnvForTest unsets every variable in the process environment
|
|
||||||
// for the rest of the test, so a test sees only the variables it sets
|
|
||||||
// itself, not whatever the developer's shell exports. When the test
|
|
||||||
// ends it leaves the environment exactly as it found it: each variable
|
|
||||||
// it unset is put back, and any variable added since is removed.
|
|
||||||
func ClearEnvForTest(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
present := make(map[string]bool)
|
|
||||||
|
|
||||||
for _, entry := range os.Environ() {
|
|
||||||
key, _, _ := strings.Cut(entry, "=")
|
|
||||||
present[key] = true
|
|
||||||
|
|
||||||
// t.Setenv registers the restore; the Unsetenv after it is
|
|
||||||
// what makes the key absent, since a key set to the empty
|
|
||||||
// string is still present, and godotenv will not overwrite a
|
|
||||||
// present key.
|
|
||||||
t.Setenv(key, "")
|
|
||||||
|
|
||||||
err := os.Unsetenv(key)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unsetting %s: %v", key, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A variable the test adds other than through t.Setenv, as loading
|
|
||||||
// a .env file does, has no restore of its own.
|
|
||||||
t.Cleanup(func() {
|
|
||||||
for _, entry := range os.Environ() {
|
|
||||||
key, _, _ := strings.Cut(entry, "=")
|
|
||||||
if present[key] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
err := os.Unsetenv(key)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("unsetting %s: %v", key, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package config_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestClearEnvForTest_RemovesAddedVariables pins that a variable set
|
|
||||||
// after the clear other than through t.Setenv, as a test's .env file
|
|
||||||
// sets one, is gone once the test ends, so it cannot reach the tests
|
|
||||||
// that run after it.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // ClearEnvForTest uses t.Setenv.
|
|
||||||
func TestClearEnvForTest_RemovesAddedVariables(t *testing.T) {
|
|
||||||
// The outer clear keeps a value of the key exported in the shell
|
|
||||||
// from making it a variable the inner clear has to put back.
|
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
|
|
||||||
t.Run("loads a .env file after the clear", func(t *testing.T) {
|
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
|
|
||||||
path := writeDotEnv(t, dotEnvKey+"=from-dot-env\n")
|
|
||||||
require.NoError(t, config.LoadDotEnvFileForTest(path))
|
|
||||||
require.Equal(t, "from-dot-env", os.Getenv(dotEnvKey))
|
|
||||||
})
|
|
||||||
|
|
||||||
_, present := os.LookupEnv(dotEnvKey)
|
|
||||||
assert.False(
|
|
||||||
t, present,
|
|
||||||
"a variable set after the clear must not outlive the test",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -120,26 +119,3 @@ func TestNewDatabase_IsLoggedWithItsPath(t *testing.T) {
|
|||||||
t, second, created, "an existing database is not new",
|
t, second, created, "an existing database is not new",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestZeroLengthDatabase_IsLoggedAsNew covers what
|
|
||||||
// https://git.eeqj.de/sneak/webhooker/issues/290 found: SQLite opens a
|
|
||||||
// zero-length file as an empty database, so a start on one is a first
|
|
||||||
// start, and it must say so exactly as a start with no file does.
|
|
||||||
func TestZeroLengthDatabase_IsLoggedAsNew(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, database.MainDBFileName)
|
|
||||||
require.NoError(t, os.WriteFile(path, nil, database.SQLiteFilePerm))
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
|
|
||||||
db, err := database.Open(dir, slog.New(slog.NewTextHandler(&out, nil)))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, db.Close())
|
|
||||||
|
|
||||||
assert.Contains(
|
|
||||||
t, out.String(),
|
|
||||||
`level=WARN msg="created a new, empty database" path=`+path,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -202,7 +203,8 @@ func (d *Database) connectTo(dataDir string) error {
|
|||||||
// Checked before opening, which creates the file. A DATA_DIR that
|
// Checked before opening, which creates the file. A DATA_DIR that
|
||||||
// is unexpectedly empty -- its volume not mounted, say -- looks
|
// is unexpectedly empty -- its volume not mounted, say -- looks
|
||||||
// exactly like a first start, so a new database is a warning.
|
// exactly like a first start, so a new database is a warning.
|
||||||
created := missingOrEmpty(dbPath)
|
_, statErr := os.Stat(dbPath)
|
||||||
|
created := errors.Is(statErr, fs.ErrNotExist)
|
||||||
|
|
||||||
// Opened through OpenSQLite so this handle carries the same WAL
|
// Opened through OpenSQLite so this handle carries the same WAL
|
||||||
// journaling, busy timeout, immediate-transaction locking, and pool
|
// journaling, busy timeout, immediate-transaction locking, and pool
|
||||||
@@ -211,15 +213,13 @@ func (d *Database) connectTo(dataDir string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
d.log.Error(
|
d.log.Error(
|
||||||
"failed to open database",
|
"failed to open database",
|
||||||
"path", dbPath,
|
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then use it with GORM. Its errors are SQLite's alone and name no
|
// Then use it with GORM
|
||||||
// file, so the path is added to them here.
|
|
||||||
db, err := gorm.Open(sqlite.Dialector{
|
db, err := gorm.Open(sqlite.Dialector{
|
||||||
Conn: sqlDB,
|
Conn: sqlDB,
|
||||||
}, &gorm.Config{
|
}, &gorm.Config{
|
||||||
@@ -229,11 +229,10 @@ func (d *Database) connectTo(dataDir string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
d.log.Error(
|
d.log.Error(
|
||||||
"failed to connect to database",
|
"failed to connect to database",
|
||||||
"path", dbPath,
|
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return fmt.Errorf("connecting to %s: %w", dbPath, err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
d.db = db
|
d.db = db
|
||||||
@@ -244,12 +243,8 @@ func (d *Database) connectTo(dataDir string) error {
|
|||||||
d.log.Info("connected to database", "path", dbPath)
|
d.log.Info("connected to database", "path", dbPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = d.migrate()
|
// Run migrations
|
||||||
if err != nil {
|
return d.migrate()
|
||||||
return fmt.Errorf("migrating %s: %w", dbPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) migrate() error {
|
func (d *Database) migrate() error {
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
package database_test
|
package database_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"go.uber.org/fx/fxtest"
|
"go.uber.org/fx/fxtest"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
@@ -106,22 +100,3 @@ func TestDatabaseConnection(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestOpen_UnreadableDatabaseIsNamed pins
|
|
||||||
// https://git.eeqj.de/sneak/webhooker/issues/459: when SQLite cannot
|
|
||||||
// read webhooker.db, the error that stops the server and `webhooker
|
|
||||||
// resetpw` names the file, not only SQLite's own message.
|
|
||||||
func TestOpen_UnreadableDatabaseIsNamed(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, database.MainDBFileName)
|
|
||||||
require.NoError(t, os.WriteFile(
|
|
||||||
path, bytes.Repeat([]byte("junk"), 1024), database.SQLiteFilePerm,
|
|
||||||
))
|
|
||||||
|
|
||||||
_, err := database.Open(dir, slog.New(slog.DiscardHandler))
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), path)
|
|
||||||
assert.Contains(t, err.Error(), "file is not a database")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -149,62 +149,6 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
|
|||||||
Delete(&database.Event{}), "sqlite_autoindex_events_1 (id=?)")
|
Delete(&database.Event{}), "sqlite_autoindex_events_1 (id=?)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEventLogFiltersUseTheStatusIndex does the same for the event log's
|
|
||||||
// Failed and Pending lists, of the newest events with a delivery in
|
|
||||||
// given statuses, and for their counts (eventsWithStatus and
|
|
||||||
// countEventsWithStatus in the handlers). The lists must also reach
|
|
||||||
// the events table only by ID: from the matching deliveries, then from
|
|
||||||
// the newest of those events.
|
|
||||||
func TestEventLogFiltersUseTheStatusIndex(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
mgr, lc := setupTestWebhookDBManager(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
require.NoError(t, lc.Start(ctx))
|
|
||||||
|
|
||||||
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
|
||||||
|
|
||||||
webhookID := uuid.New().String()
|
|
||||||
|
|
||||||
db, err := mgr.GetDB(webhookID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
dry := db.Session(&gorm.Session{DryRun: true})
|
|
||||||
byStatus := "idx_deliveries_status (status=? AND deleted_at=?)"
|
|
||||||
pending := []database.DeliveryStatus{
|
|
||||||
database.DeliveryStatusPending,
|
|
||||||
database.DeliveryStatusRetrying,
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
rows []struct{ ID string }
|
|
||||||
count int64
|
|
||||||
)
|
|
||||||
|
|
||||||
matching := dry.Model(&database.Delivery{}).
|
|
||||||
Distinct("event_id").Where("status IN ?", pending)
|
|
||||||
newest := dry.Table("(?) AS matching", matching).
|
|
||||||
Joins("CROSS JOIN events ON events.id = matching.event_id").
|
|
||||||
Where(
|
|
||||||
"events.webhook_id = ? AND events.deleted_at IS NULL",
|
|
||||||
webhookID,
|
|
||||||
).
|
|
||||||
Order("events.created_at DESC").Limit(50).
|
|
||||||
Select("events.id AS event_id")
|
|
||||||
|
|
||||||
// Each step of the plan is printed in braces, so these name the
|
|
||||||
// lookup that follows each scan.
|
|
||||||
byID := "{SEARCH events USING INDEX sqlite_autoindex_events_1 (id=?)}"
|
|
||||||
|
|
||||||
assertPlanUses(t, db, dry.Table("(?) AS newest", newest).
|
|
||||||
Joins("CROSS JOIN events ON events.id = newest.event_id").
|
|
||||||
Select("id").Order("created_at DESC").Limit(50).Find(&rows),
|
|
||||||
byStatus, "{SCAN matching} "+byID, "{SCAN newest} "+byID)
|
|
||||||
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
|
|
||||||
Distinct("event_id").Where("status IN ?", pending).Count(&count),
|
|
||||||
byStatus)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStatisticsQueriesUseTheirIndexes does the same for the webhook
|
// TestStatisticsQueriesUseTheirIndexes does the same for the webhook
|
||||||
// page's statistics (readEventStats in the handlers): deliveries in
|
// page's statistics (readEventStats in the handlers): deliveries in
|
||||||
// progress, each target's deliveries finished since a time, which must
|
// progress, each target's deliveries finished since a time, which must
|
||||||
@@ -255,77 +199,6 @@ func TestStatisticsQueriesUseTheirIndexes(t *testing.T) {
|
|||||||
"(deleted_at=? AND created_at>?)")
|
"(deleted_at=? AND created_at>?)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestResubmitCountUsesItsIndex does the same for the event log's count
|
|
||||||
// of the events resubmitted from each of a page's events (resubmitCounts
|
|
||||||
// in the handlers). It passes a full page of 25 ids: with an index on
|
|
||||||
// resubmitted_from_id alone, SQLite uses it for three ids and turns to
|
|
||||||
// the deleted_at index from five.
|
|
||||||
func TestResubmitCountUsesItsIndex(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
mgr, lc := setupTestWebhookDBManager(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
require.NoError(t, lc.Start(ctx))
|
|
||||||
|
|
||||||
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
|
||||||
|
|
||||||
db, err := mgr.GetDB(uuid.New().String())
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
dry := db.Session(&gorm.Session{DryRun: true})
|
|
||||||
|
|
||||||
page := make([]string, 25)
|
|
||||||
for i := range page {
|
|
||||||
page[i] = uuid.New().String()
|
|
||||||
}
|
|
||||||
|
|
||||||
var counts []struct{ Total int }
|
|
||||||
|
|
||||||
assertPlanUses(t, db, dry.Model(&database.Event{}).
|
|
||||||
Select("resubmitted_from_id, count(*) AS total").
|
|
||||||
Where("resubmitted_from_id IN ?", page).
|
|
||||||
Group("resubmitted_from_id").Find(&counts),
|
|
||||||
"idx_events_resubmitted_from_id "+
|
|
||||||
"(resubmitted_from_id=? AND deleted_at=?)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEntrypointEventsUseTheirIndex does the same for the webhook
|
|
||||||
// page's count, for each entrypoint, of the events that arrived on its
|
|
||||||
// URL since the retention cutoff (addEntrypointEvents in the
|
|
||||||
// handlers), which must come from the index alone. It passes 25
|
|
||||||
// entrypoints, as TestResubmitCountUsesItsIndex passes 25 events.
|
|
||||||
func TestEntrypointEventsUseTheirIndex(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
mgr, lc := setupTestWebhookDBManager(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
require.NoError(t, lc.Start(ctx))
|
|
||||||
|
|
||||||
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
|
||||||
|
|
||||||
db, err := mgr.GetDB(uuid.New().String())
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
dry := db.Session(&gorm.Session{DryRun: true})
|
|
||||||
|
|
||||||
entrypoints := make([]string, 25)
|
|
||||||
for i := range entrypoints {
|
|
||||||
entrypoints[i] = uuid.New().String()
|
|
||||||
}
|
|
||||||
|
|
||||||
var rows []struct{ Events int }
|
|
||||||
|
|
||||||
assertPlanUses(t, db, dry.Model(&database.Event{}).
|
|
||||||
Select("entrypoint_id, count(*) AS events").
|
|
||||||
Where("entrypoint_id IN ? AND resubmitted_from_id IS NULL",
|
|
||||||
entrypoints).
|
|
||||||
Where("created_at >= ?", time.Now()).
|
|
||||||
Group("entrypoint_id").Find(&rows),
|
|
||||||
"COVERING INDEX idx_events_entrypoint_id "+
|
|
||||||
"(entrypoint_id=? AND deleted_at=? AND "+
|
|
||||||
"resubmitted_from_id=? AND created_at>?)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertPlanUses asserts that SQLite's plan for a statement GORM built
|
// assertPlanUses asserts that SQLite's plan for a statement GORM built
|
||||||
// in a dry run, run with the same SQL and arguments GORM would send,
|
// in a dry run, run with the same SQL and arguments GORM would send,
|
||||||
// names each of the given indexes.
|
// names each of the given indexes.
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ type APIKey struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
||||||
|
|
||||||
// Relations. No model marshals the record it belongs to:
|
// Relations
|
||||||
// User.APIKeys leads back here, and the JSON could loop.
|
User User `json:"user,omitzero"`
|
||||||
User User `json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,14 +56,8 @@ type Delivery struct {
|
|||||||
// the index.
|
// the index.
|
||||||
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
|
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
|
||||||
|
|
||||||
// Replay is set on a delivery created by the event log's Replay
|
// Relations
|
||||||
// action, so the pages can tell it from the delivery it repeats.
|
Event Event `json:"event,omitzero"`
|
||||||
Replay bool `gorm:"not null;default:false" json:"replay"`
|
Target Target `json:"target,omitzero"`
|
||||||
|
|
||||||
// Relations. No model marshals the record it belongs to:
|
|
||||||
// Event.Deliveries and Target.Deliveries lead back here, and the
|
|
||||||
// JSON could loop.
|
|
||||||
Event Event `json:"-"`
|
|
||||||
Target Target `json:"-"`
|
|
||||||
DeliveryResults []DeliveryResult `json:"deliveryResults,omitempty"`
|
DeliveryResults []DeliveryResult `json:"deliveryResults,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ type DeliveryResult struct {
|
|||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
Duration int64 `json:"durationMs"` // Duration in milliseconds
|
Duration int64 `json:"durationMs"` // Duration in milliseconds
|
||||||
|
|
||||||
// Relations. No model marshals the record it belongs to:
|
// Relations
|
||||||
// Delivery.DeliveryResults leads back here, and the JSON could loop.
|
Delivery Delivery `json:"delivery,omitzero"`
|
||||||
Delivery Delivery `json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ type Entrypoint struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Active bool `gorm:"default:true" json:"active"`
|
Active bool `gorm:"default:true" json:"active"`
|
||||||
|
|
||||||
// Relations. No model marshals the record it belongs to:
|
// Relations
|
||||||
// Webhook.Entrypoints leads back here, and the JSON could loop.
|
Webhook Webhook `json:"webhook,omitzero"`
|
||||||
Webhook Webhook `json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,16 +19,11 @@ type Event struct {
|
|||||||
// narrows by a < only on the last column it uses. Its final delete
|
// narrows by a < only on the last column it uses. Its final delete
|
||||||
// has no deleted_at condition and uses the index on created_at
|
// has no deleted_at condition and uses the index on created_at
|
||||||
// alone. The other tables keep the unindexed BaseModel created_at.
|
// alone. The other tables keep the unindexed BaseModel created_at.
|
||||||
// DeletedAt is also the second column of the resubmitted_from_id
|
CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2" json:"createdAt"`
|
||||||
// index, for the reason DeliveryResult gives. The entrypoint_id
|
DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1" json:"deletedAt,omitzero"`
|
||||||
// index, for the webhook page's entrypoint list, has it second too,
|
|
||||||
// resubmitted_from_id third, and created_at last, which the list
|
|
||||||
// compares with a range.
|
|
||||||
CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2;index:idx_events_entrypoint_id,priority:4" json:"createdAt"`
|
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1;index:idx_events_resubmitted_from_id,priority:2;index:idx_events_entrypoint_id,priority:2" json:"deletedAt,omitzero"`
|
|
||||||
|
|
||||||
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
|
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
|
||||||
EntrypointID string `gorm:"type:uuid;not null;index:idx_events_entrypoint_id,priority:1" json:"entrypointId"`
|
EntrypointID string `gorm:"type:uuid;not null" json:"entrypointId"`
|
||||||
|
|
||||||
// Request data
|
// Request data
|
||||||
Method string `gorm:"not null" json:"method"`
|
Method string `gorm:"not null" json:"method"`
|
||||||
@@ -37,8 +32,8 @@ type Event struct {
|
|||||||
ContentType string `json:"contentType"`
|
ContentType string `json:"contentType"`
|
||||||
|
|
||||||
// BodyBytes is the size of Body in bytes, recorded when the event
|
// BodyBytes is the size of Body in bytes, recorded when the event
|
||||||
// is stored, so that the recent events list, which reads only the
|
// is stored so the recent events list can show it without reading
|
||||||
// start of each body, knows the whole body's size.
|
// the body.
|
||||||
BodyBytes int64 `gorm:"not null" json:"bodyBytes"`
|
BodyBytes int64 `gorm:"not null" json:"bodyBytes"`
|
||||||
|
|
||||||
// ResubmittedFromID names the event this one was copied from by
|
// ResubmittedFromID names the event this one was copied from by
|
||||||
@@ -47,11 +42,10 @@ type Event struct {
|
|||||||
// existed. It is not a foreign key: the source event can be
|
// existed. It is not a foreign key: the source event can be
|
||||||
// reaped by retention while its copies remain, and the id is
|
// reaped by retention while its copies remain, and the id is
|
||||||
// kept as the record of where the copy came from either way.
|
// kept as the record of where the copy came from either way.
|
||||||
ResubmittedFromID *string `gorm:"type:uuid;index:idx_events_resubmitted_from_id,priority:1;index:idx_events_entrypoint_id,priority:3" json:"resubmittedFromId,omitempty"`
|
ResubmittedFromID *string `gorm:"type:uuid;index" json:"resubmittedFromId,omitempty"`
|
||||||
|
|
||||||
// Relations. No model marshals the record it belongs to, so
|
// Relations
|
||||||
// Webhook and Entrypoint are left out of the JSON.
|
Webhook Webhook `json:"webhook,omitzero"`
|
||||||
Webhook Webhook `json:"-"`
|
Entrypoint Entrypoint `json:"entrypoint,omitzero"`
|
||||||
Entrypoint Entrypoint `json:"-"`
|
|
||||||
Deliveries []Delivery `json:"deliveries,omitempty"`
|
Deliveries []Delivery `json:"deliveries,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
package database_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestPreloadedModelsMarshalWithoutTheirParent pins that a child's
|
|
||||||
// reference to the record it belongs to is left out of the JSON, so a
|
|
||||||
// webhook and its targets cannot marshal each other in a loop, and that
|
|
||||||
// GORM still preloads that reference, since it ignores json tags.
|
|
||||||
func TestPreloadedModelsMarshalWithoutTheirParent(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db := startedTestDB(t)
|
|
||||||
|
|
||||||
stored := database.Webhook{
|
|
||||||
UserID: uuid.New().String(),
|
|
||||||
Name: testWebhookName,
|
|
||||||
Entrypoints: []database.Entrypoint{{Path: uuid.New().String()}},
|
|
||||||
Targets: []database.Target{{
|
|
||||||
Name: "log",
|
|
||||||
Type: database.TargetTypeLog,
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
require.NoError(t, db.Create(&stored).Error)
|
|
||||||
|
|
||||||
entrypointID := stored.Entrypoints[0].ID
|
|
||||||
targetID := stored.Targets[0].ID
|
|
||||||
|
|
||||||
var webhook database.Webhook
|
|
||||||
|
|
||||||
require.NoError(t, db.
|
|
||||||
Preload("Entrypoints.Webhook").
|
|
||||||
Preload("Targets.Webhook").
|
|
||||||
First(&webhook, "id = ?", stored.ID).Error)
|
|
||||||
|
|
||||||
require.Len(t, webhook.Entrypoints, 1)
|
|
||||||
require.Len(t, webhook.Targets, 1)
|
|
||||||
assert.Equal(t, stored.ID, webhook.Entrypoints[0].Webhook.ID)
|
|
||||||
assert.Equal(t, stored.ID, webhook.Targets[0].Webhook.ID)
|
|
||||||
|
|
||||||
encoded := marshalModel(t, webhook)
|
|
||||||
|
|
||||||
assert.Contains(t, encoded, entrypointID)
|
|
||||||
assert.Contains(t, encoded, targetID)
|
|
||||||
|
|
||||||
// Each child holds the parent's id as its webhookId, so the parent
|
|
||||||
// is looked for by its own id field.
|
|
||||||
parentIDField := `"id":"` + stored.ID + `"`
|
|
||||||
|
|
||||||
assert.NotContains(t, marshalModel(t, webhook.Entrypoints[0]), parentIDField)
|
|
||||||
assert.NotContains(t, marshalModel(t, webhook.Targets[0]), parentIDField)
|
|
||||||
|
|
||||||
var target database.Target
|
|
||||||
|
|
||||||
require.NoError(t, db.
|
|
||||||
Preload("Webhook").
|
|
||||||
First(&target, "id = ?", targetID).Error)
|
|
||||||
|
|
||||||
assert.Equal(t, stored.ID, target.Webhook.ID)
|
|
||||||
|
|
||||||
encoded = marshalModel(t, target)
|
|
||||||
|
|
||||||
assert.Contains(t, encoded, stored.ID)
|
|
||||||
assert.NotContains(t, encoded, parentIDField)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestModelsMarshalWithoutTheirParent covers the other references to a
|
|
||||||
// parent: each model is built with its parent set, and the parent's id
|
|
||||||
// must not appear in the JSON.
|
|
||||||
func TestModelsMarshalWithoutTheirParent(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
parent := database.BaseModel{ID: uuid.New().String()}
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
model any
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Webhook.User",
|
|
||||||
model: database.Webhook{User: database.User{BaseModel: parent}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "APIKey.User",
|
|
||||||
model: database.APIKey{User: database.User{BaseModel: parent}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Delivery.Event",
|
|
||||||
model: database.Delivery{Event: database.Event{BaseModel: parent}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Delivery.Target",
|
|
||||||
model: database.Delivery{Target: database.Target{BaseModel: parent}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "DeliveryResult.Delivery",
|
|
||||||
model: database.DeliveryResult{
|
|
||||||
Delivery: database.Delivery{BaseModel: parent},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Event.Webhook",
|
|
||||||
model: database.Event{Webhook: database.Webhook{BaseModel: parent}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Event.Entrypoint",
|
|
||||||
model: database.Event{
|
|
||||||
Entrypoint: database.Entrypoint{BaseModel: parent},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
assert.NotContains(t, marshalModel(t, tc.model), parent.ID)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -32,9 +32,9 @@ type Target struct {
|
|||||||
// For HTTP targets (max_retries=0 means fire-and-forget,
|
// For HTTP targets (max_retries=0 means fire-and-forget,
|
||||||
// >0 enables retries with backoff)
|
// >0 enables retries with backoff)
|
||||||
MaxRetries int `json:"maxRetries,omitempty"`
|
MaxRetries int `json:"maxRetries,omitempty"`
|
||||||
|
MaxQueueSize int `json:"maxQueueSize,omitempty"`
|
||||||
|
|
||||||
// Relations. No model marshals the record it belongs to:
|
// Relations
|
||||||
// Webhook.Targets leads back here, and the JSON could loop.
|
Webhook Webhook `json:"webhook,omitzero"`
|
||||||
Webhook Webhook `json:"-"`
|
|
||||||
Deliveries []Delivery `json:"deliveries,omitempty"`
|
Deliveries []Delivery `json:"deliveries,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,21 +52,6 @@ func (TargetTotals) TableName() string {
|
|||||||
return "target_totals"
|
return "target_totals"
|
||||||
}
|
}
|
||||||
|
|
||||||
// EntrypointTotals is one row per entrypoint, created by the first
|
|
||||||
// event that arrives on its URL: when the newest such event arrived,
|
|
||||||
// which retention leaves as it is. A resubmitted copy did not arrive
|
|
||||||
// on the URL and does not change it.
|
|
||||||
type EntrypointTotals struct {
|
|
||||||
EntrypointID string `gorm:"type:uuid;primaryKey"`
|
|
||||||
|
|
||||||
LastEventAt time.Time `gorm:"not null"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TableName names the table AddEntrypointTotals updates.
|
|
||||||
func (EntrypointTotals) TableName() string {
|
|
||||||
return "entrypoint_totals"
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddEventTotals adds each count in add to the webhook's event totals,
|
// AddEventTotals adds each count in add to the webhook's event totals,
|
||||||
// and records add.LastEventAt as when the newest event arrived if it is
|
// and records add.LastEventAt as when the newest event arrived if it is
|
||||||
// set. Call it on the transaction that writes or deletes the events it
|
// set. Call it on the transaction that writes or deletes the events it
|
||||||
@@ -112,25 +97,3 @@ func AddTargetTotals(tx *gorm.DB, add TargetTotals) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddEntrypointTotals records add.LastEventAt as when the newest event
|
|
||||||
// arrived on the URL of the entrypoint add.EntrypointID names, creating
|
|
||||||
// its row the first time. Call it on the transaction that stores the
|
|
||||||
// event.
|
|
||||||
func AddEntrypointTotals(tx *gorm.DB, add EntrypointTotals) error {
|
|
||||||
err := tx.Exec(
|
|
||||||
`INSERT INTO entrypoint_totals (entrypoint_id, last_event_at)
|
|
||||||
VALUES (?, ?)
|
|
||||||
ON CONFLICT (entrypoint_id) DO UPDATE SET
|
|
||||||
last_event_at = excluded.last_event_at`,
|
|
||||||
add.EntrypointID, add.LastEventAt,
|
|
||||||
).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"adding to totals of entrypoint %s: %w",
|
|
||||||
add.EntrypointID, err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -66,9 +66,8 @@ type Webhook struct {
|
|||||||
// must equal DefaultRetentionDays.
|
// must equal DefaultRetentionDays.
|
||||||
RetentionDays int `gorm:"default:30" json:"retentionDays"`
|
RetentionDays int `gorm:"default:30" json:"retentionDays"`
|
||||||
|
|
||||||
// Relations. No model marshals the record it belongs to:
|
// Relations
|
||||||
// User.Webhooks leads back here, and the JSON could loop.
|
User User `json:"user,omitzero"`
|
||||||
User User `json:"-"`
|
|
||||||
Entrypoints []Entrypoint `json:"entrypoints,omitempty"`
|
Entrypoints []Entrypoint `json:"entrypoints,omitempty"`
|
||||||
Targets []Target `json:"targets,omitempty"`
|
Targets []Target `json:"targets,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -111,13 +110,6 @@ func (w *Webhook) RetainsForever() bool {
|
|||||||
return retainsForever(w.RetentionDays)
|
return retainsForever(w.RetentionDays)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RetentionCutoff returns the time before which this webhook's events
|
|
||||||
// have expired, as the reaper computes it, and false when the webhook
|
|
||||||
// retains them forever.
|
|
||||||
func (w *Webhook) RetentionCutoff(now time.Time) (time.Time, bool) {
|
|
||||||
return retentionCutoff(now, w.RetentionDays)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RetentionLabel returns the webhook's retention policy as display
|
// RetentionLabel returns the webhook's retention policy as display
|
||||||
// text, so that no template has to know about the sentinel value.
|
// text, so that no template has to know about the sentinel value.
|
||||||
func (w *Webhook) RetentionLabel() string {
|
func (w *Webhook) RetentionLabel() string {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package database
|
|||||||
// Migrate runs database migrations for the main application database.
|
// Migrate runs database migrations for the main application database.
|
||||||
// Only configuration-tier models are stored in the main database.
|
// Only configuration-tier models are stored in the main database.
|
||||||
// Event-tier models (Event, Delivery, DeliveryResult, EventTotals,
|
// Event-tier models (Event, Delivery, DeliveryResult, EventTotals,
|
||||||
// TargetTotals, EntrypointTotals) live in
|
// TargetTotals) live in
|
||||||
// per-webhook dedicated databases managed by WebhookDBManager.
|
// per-webhook dedicated databases managed by WebhookDBManager.
|
||||||
func (d *Database) Migrate() error {
|
func (d *Database) Migrate() error {
|
||||||
return d.db.AutoMigrate(
|
return d.db.AutoMigrate(
|
||||||
|
|||||||
@@ -184,8 +184,18 @@ func (r *RetentionReaper) sweep(ctx context.Context) {
|
|||||||
|
|
||||||
wh := webhooks[i]
|
wh := webhooks[i]
|
||||||
|
|
||||||
// A missing database has nothing to reap. Restart recovery
|
// Skip retain-forever webhooks before building any query.
|
||||||
// reports a lost one (see WebhookDBManager.GetDB).
|
// RetainsForever covers both the RetentionForeverDays
|
||||||
|
// sentinel and the non-positive values that predate it: the
|
||||||
|
// sentinel is a positive number, so without this the reaper
|
||||||
|
// would compute a cutoff a thousand years in the past and
|
||||||
|
// issue a DELETE matching nothing on every single sweep.
|
||||||
|
if wh.RetainsForever() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing to reap if the per-webhook database has never
|
||||||
|
// been created.
|
||||||
if !r.dbManager.DBExists(wh.ID) {
|
if !r.dbManager.DBExists(wh.ID) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -202,13 +212,6 @@ func (r *RetentionReaper) reapWebhook(
|
|||||||
webhookID string,
|
webhookID string,
|
||||||
retentionDays int,
|
retentionDays int,
|
||||||
) {
|
) {
|
||||||
// A retain-forever webhook has no cutoff, so its database is not
|
|
||||||
// even opened.
|
|
||||||
cutoff, ok := retentionCutoff(time.Now(), retentionDays)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := r.dbManager.GetDB(webhookID)
|
db, err := r.dbManager.GetDB(webhookID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.log.Error(
|
r.log.Error(
|
||||||
@@ -220,6 +223,11 @@ func (r *RetentionReaper) reapWebhook(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cutoff, ok := retentionCutoff(time.Now(), retentionDays)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
deleted, err := reapExpired(ctx, db, cutoff)
|
deleted, err := reapExpired(ctx, db, cutoff)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.log.Error(
|
r.log.Error(
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
|
|||||||
t,
|
t,
|
||||||
overflowingRetentionDays,
|
overflowingRetentionDays,
|
||||||
database.RetentionForeverDays,
|
database.RetentionForeverDays,
|
||||||
"the test value must not be treated as retain-forever",
|
"the test value must not be rescued by the forever skip",
|
||||||
)
|
)
|
||||||
|
|
||||||
webhookID := createWebhook(
|
webhookID := createWebhook(
|
||||||
|
|||||||
@@ -182,29 +182,6 @@ func TestOpenSQLiteTightensFilesLeftWorldReadable(t *testing.T) {
|
|||||||
requireDatabaseSetOwnerOnly(t, path)
|
requireDatabaseSetOwnerOnly(t, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestOpenSQLiteRefusesADirectorySidecar covers a directory in place
|
|
||||||
// of -wal or -shm. Beside a -shm directory SQLite opens the database
|
|
||||||
// read-only without a word, and every write then fails naming no file,
|
|
||||||
// so the open must stop instead, naming the directory.
|
|
||||||
func TestOpenSQLiteRefusesADirectorySidecar(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, suffix := range []string{"-wal", "-shm"} {
|
|
||||||
t.Run(suffix, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), database.MainDBFileName)
|
|
||||||
require.NoError(t, os.Mkdir(path+suffix, 0o700))
|
|
||||||
|
|
||||||
_, err := database.OpenSQLite(
|
|
||||||
path, database.SQLiteModeCreate,
|
|
||||||
)
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), path+suffix)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestOpenSQLiteExistingModeDoesNotCreateTheFile guards the mechanism
|
// TestOpenSQLiteExistingModeDoesNotCreateTheFile guards the mechanism
|
||||||
// the fix uses: OpenSQLite now creates the database file itself, and
|
// the fix uses: OpenSQLite now creates the database file itself, and
|
||||||
// must not do so for a caller that asked for an existing database. An
|
// must not do so for a caller that asked for an existing database. An
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "modernc.org/sqlite" // Pure Go SQLite driver
|
_ "modernc.org/sqlite" // Pure Go SQLite driver
|
||||||
@@ -94,8 +93,7 @@ const (
|
|||||||
const SQLiteFilePerm fs.FileMode = 0o600
|
const SQLiteFilePerm fs.FileMode = 0o600
|
||||||
|
|
||||||
// reserveSQLiteFile puts path at SQLiteFilePerm before the driver ever
|
// reserveSQLiteFile puts path at SQLiteFilePerm before the driver ever
|
||||||
// touches it, and tightens any sidecar already on disk. A directory in
|
// touches it, and tightens any sidecar already on disk.
|
||||||
// place of any of them is an error naming it.
|
|
||||||
//
|
//
|
||||||
// The mode has to be settled here rather than by a chmod after opening,
|
// The mode has to be settled here rather than by a chmod after opening,
|
||||||
// because SQLite picks it: robust_open substitutes
|
// because SQLite picks it: robust_open substitutes
|
||||||
@@ -145,15 +143,7 @@ func reserveSQLiteFile(path string, create bool) error {
|
|||||||
for _, p := range append(
|
for _, p := range append(
|
||||||
[]string{path}, sqliteSidecarPaths(path)...,
|
[]string{path}, sqliteSidecarPaths(path)...,
|
||||||
) {
|
) {
|
||||||
// Chmod accepts a directory, and SQLite opens a database whose
|
err := os.Chmod(p, SQLiteFilePerm)
|
||||||
// -shm is one read-only, without a word: every write then
|
|
||||||
// fails naming no file.
|
|
||||||
info, err := os.Stat(p)
|
|
||||||
if err == nil && info.IsDir() {
|
|
||||||
return fmt.Errorf("securing %s: %w", p, syscall.EISDIR)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = os.Chmod(p, SQLiteFilePerm)
|
|
||||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
return fmt.Errorf("securing %s: %w", p, err)
|
return fmt.Errorf("securing %s: %w", p, err)
|
||||||
}
|
}
|
||||||
@@ -162,20 +152,6 @@ func reserveSQLiteFile(path string, create bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// missingOrEmpty reports whether opening path in SQLiteModeCreate
|
|
||||||
// would start a new, empty database: the file is not there, or it is
|
|
||||||
// zero-length, which SQLite opens as an empty database. A file left at
|
|
||||||
// zero length by an interrupted first start or a truncated copy holds
|
|
||||||
// as little as a missing one, and must be reported the same way.
|
|
||||||
func missingOrEmpty(path string) bool {
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return err == nil && info.Size() == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// sqliteSidecarPaths returns the files SQLite maintains beside a
|
// sqliteSidecarPaths returns the files SQLite maintains beside a
|
||||||
// database under WAL. They carry the same rows as the database itself,
|
// database under WAL. They carry the same rows as the database itself,
|
||||||
// so a fix that tightens only the main file has fixed nothing.
|
// so a fix that tightens only the main file has fixed nothing.
|
||||||
|
|||||||
@@ -102,13 +102,6 @@ func TestWebhookDBManager_TotalsSurviveReopen(t *testing.T) {
|
|||||||
// seedExpiredEvents stores count events created at the given time,
|
// seedExpiredEvents stores count events created at the given time,
|
||||||
// each with a delivered delivery to one target and a failed delivery
|
// each with a delivered delivery to one target and a failed delivery
|
||||||
// to the other, and one attempt for each delivery.
|
// to the other, and one attempt for each delivery.
|
||||||
//
|
|
||||||
// It and seedBareEvents insert 50 rows per statement, not more. The
|
|
||||||
// SQLite driver looks up each parameter's value by scanning the
|
|
||||||
// statement's arguments from the first until it reaches that
|
|
||||||
// parameter's, so the time to bind a statement grows with the square of
|
|
||||||
// its parameter count: at 500 rows, several thousand parameters, the
|
|
||||||
// seeding took most of these tests' time under -race.
|
|
||||||
func seedExpiredEvents(
|
func seedExpiredEvents(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
db *gorm.DB,
|
db *gorm.DB,
|
||||||
@@ -145,8 +138,8 @@ func seedExpiredEvents(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(t, db.CreateInBatches(events, 50).Error)
|
require.NoError(t, db.CreateInBatches(events, 500).Error)
|
||||||
require.NoError(t, db.CreateInBatches(deliveries, 50).Error)
|
require.NoError(t, db.CreateInBatches(deliveries, 500).Error)
|
||||||
|
|
||||||
results := make([]database.DeliveryResult, len(deliveries))
|
results := make([]database.DeliveryResult, len(deliveries))
|
||||||
for i := range deliveries {
|
for i := range deliveries {
|
||||||
@@ -155,7 +148,7 @@ func seedExpiredEvents(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(t, db.CreateInBatches(results, 50).Error)
|
require.NoError(t, db.CreateInBatches(results, 500).Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedBareEvents stores count events created at the given time, with
|
// seedBareEvents stores count events created at the given time, with
|
||||||
@@ -179,7 +172,7 @@ func seedBareEvents(
|
|||||||
events[i].CreatedAt = createdAt
|
events[i].CreatedAt = createdAt
|
||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(t, db.CreateInBatches(events, 50).Error)
|
require.NoError(t, db.CreateInBatches(events, 500).Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRetentionReaper_PrunesMoreThanOneBatch verifies that a prune
|
// TestRetentionReaper_PrunesMoreThanOneBatch verifies that a prune
|
||||||
|
|||||||
@@ -33,23 +33,10 @@ var errInvalidCachedDBType = errors.New(
|
|||||||
"invalid cached database type",
|
"invalid cached database type",
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrEventDBNotRemoved is in DeleteDB's error when the event
|
|
||||||
// database file itself could not be removed: it is still on disk.
|
|
||||||
var ErrEventDBNotRemoved = errors.New(
|
|
||||||
"event database file not removed",
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrSidecarNotRemoved is in DeleteDB's error when the event
|
|
||||||
// database file was removed, so its events are gone, but its -wal
|
|
||||||
// or -shm sidecar could not be.
|
|
||||||
var ErrSidecarNotRemoved = errors.New(
|
|
||||||
"event database file removed, but a -wal or -shm sidecar was not",
|
|
||||||
)
|
|
||||||
|
|
||||||
// WebhookDBManager manages per-webhook SQLite database files
|
// WebhookDBManager manages per-webhook SQLite database files
|
||||||
// for event storage. Each webhook gets its own dedicated
|
// for event storage. Each webhook gets its own dedicated
|
||||||
// database containing Events, Deliveries, DeliveryResults and the
|
// database containing Events, Deliveries, DeliveryResults and the
|
||||||
// running totals of them (EventTotals, TargetTotals, EntrypointTotals).
|
// running totals of them (EventTotals, TargetTotals).
|
||||||
// Database connections are opened lazily and cached.
|
// Database connections are opened lazily and cached.
|
||||||
type WebhookDBManager struct {
|
type WebhookDBManager struct {
|
||||||
dataDir string
|
dataDir string
|
||||||
@@ -98,37 +85,34 @@ func NewWebhookDBManager(
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDB returns the database connection for a webhook, opening it on
|
// GetDB returns the database connection for a webhook,
|
||||||
// first use.
|
// creating the database file lazily if it doesn't exist.
|
||||||
//
|
|
||||||
// The file is made by CreateDB when the webhook is created. One that is
|
|
||||||
// missing or zero-length here means the webhook's events and pending
|
|
||||||
// deliveries are gone: an empty database is created in its place so
|
|
||||||
// the webhook keeps receiving, and that is logged as a warning naming
|
|
||||||
// the file, as a new main database is.
|
|
||||||
func (m *WebhookDBManager) GetDB(
|
func (m *WebhookDBManager) GetDB(
|
||||||
webhookID string,
|
webhookID string,
|
||||||
) (*gorm.DB, error) {
|
) (*gorm.DB, error) {
|
||||||
return m.getDB(webhookID, false)
|
// Fast path: already open
|
||||||
|
if val, ok := m.dbs.Load(webhookID); ok {
|
||||||
|
return asGormDB(val, webhookID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDBIf is GetDB, done only when check reports true. check runs under
|
// Slow path: open the database under the lock, looking in the
|
||||||
// the lock DeleteDB holds while it removes the files, so a caller can
|
// cache again first. A caller that raced another one here then
|
||||||
// confirm the webhook still exists and open its database with no delete
|
// waits for its handle instead of opening a second one.
|
||||||
// in between. The handle is nil when check reports false. check must
|
|
||||||
// not call the manager.
|
|
||||||
func (m *WebhookDBManager) GetDBIf(
|
|
||||||
webhookID string, check func() (bool, error),
|
|
||||||
) (*gorm.DB, error) {
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
ok, err := check()
|
if val, ok := m.dbs.Load(webhookID); ok {
|
||||||
if err != nil || !ok {
|
return asGormDB(val, webhookID)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := m.openDB(webhookID)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return m.getDBLocked(webhookID, false)
|
m.dbs.Store(webhookID, db)
|
||||||
|
|
||||||
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// asGormDB returns a value read from the cache as the database
|
// asGormDB returns a value read from the cache as the database
|
||||||
@@ -146,12 +130,12 @@ func asGormDB(val any, webhookID string) (*gorm.DB, error) {
|
|||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateDB creates a new webhook's database file and runs
|
// CreateDB explicitly creates a new per-webhook database file
|
||||||
// migrations.
|
// and runs migrations.
|
||||||
func (m *WebhookDBManager) CreateDB(
|
func (m *WebhookDBManager) CreateDB(
|
||||||
webhookID string,
|
webhookID string,
|
||||||
) error {
|
) error {
|
||||||
_, err := m.getDB(webhookID, true)
|
_, err := m.GetDB(webhookID)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -167,10 +151,7 @@ func (m *WebhookDBManager) DBExists(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeleteDB closes the connection and deletes the database file
|
// DeleteDB closes the connection and deletes the database file
|
||||||
// for a webhook, with its -wal and -shm sidecars. The files are
|
// for a webhook. The file is permanently removed.
|
||||||
// permanently removed. Each file is tried even when another could
|
|
||||||
// not be removed, and the error wraps ErrEventDBNotRemoved or
|
|
||||||
// ErrSidecarNotRemoved to say which was left, naming each file.
|
|
||||||
func (m *WebhookDBManager) DeleteDB(
|
func (m *WebhookDBManager) DeleteDB(
|
||||||
webhookID string,
|
webhookID string,
|
||||||
) error {
|
) error {
|
||||||
@@ -189,23 +170,16 @@ func (m *WebhookDBManager) DeleteDB(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete the main DB file and WAL/SHM files
|
||||||
path := m.dbPath(webhookID)
|
path := m.dbPath(webhookID)
|
||||||
|
for _, suffix := range []string{"", "-wal", "-shm"} {
|
||||||
dbErr := removeFile(path)
|
err := os.Remove(path + suffix)
|
||||||
sidecarErr := errors.Join(
|
if err != nil && !os.IsNotExist(err) {
|
||||||
removeFile(path+"-wal"),
|
|
||||||
removeFile(path+"-shm"),
|
|
||||||
)
|
|
||||||
|
|
||||||
if dbErr != nil {
|
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"%w: %w",
|
"deleting webhook database file %s%s: %w",
|
||||||
ErrEventDBNotRemoved, errors.Join(dbErr, sidecarErr),
|
path, suffix, err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if sidecarErr != nil {
|
|
||||||
return fmt.Errorf("%w: %w", ErrSidecarNotRemoved, sidecarErr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
m.log.Info(
|
m.log.Info(
|
||||||
@@ -216,17 +190,6 @@ func (m *WebhookDBManager) DeleteDB(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeFile removes path. A file that is already gone counts as
|
|
||||||
// removed; the error from any other failure names the file.
|
|
||||||
func removeFile(path string) error {
|
|
||||||
err := os.Remove(path)
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// CloseAll closes all open per-webhook database connections.
|
// CloseAll closes all open per-webhook database connections.
|
||||||
// Called during application shutdown.
|
// Called during application shutdown.
|
||||||
func (m *WebhookDBManager) CloseAll() error {
|
func (m *WebhookDBManager) CloseAll() error {
|
||||||
@@ -269,54 +232,6 @@ func (m *WebhookDBManager) DBPath(
|
|||||||
return m.dbPath(webhookID)
|
return m.dbPath(webhookID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getDB is GetDB, and CreateDB when isNew is true: the webhook has just
|
|
||||||
// been created, so a missing file is expected rather than lost.
|
|
||||||
func (m *WebhookDBManager) getDB(
|
|
||||||
webhookID string, isNew bool,
|
|
||||||
) (*gorm.DB, error) {
|
|
||||||
// Fast path: already open
|
|
||||||
if val, ok := m.dbs.Load(webhookID); ok {
|
|
||||||
return asGormDB(val, webhookID)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
return m.getDBLocked(webhookID, isNew)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getDBLocked is getDB's slow path, run with m.mu held. It looks in the
|
|
||||||
// cache again first: a caller that raced another one to the lock then
|
|
||||||
// gets its handle instead of opening a second one.
|
|
||||||
func (m *WebhookDBManager) getDBLocked(
|
|
||||||
webhookID string, isNew bool,
|
|
||||||
) (*gorm.DB, error) {
|
|
||||||
if val, ok := m.dbs.Load(webhookID); ok {
|
|
||||||
return asGormDB(val, webhookID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Checked before opening, which creates the file. See GetDB.
|
|
||||||
path := m.dbPath(webhookID)
|
|
||||||
replaced := !isNew && missingOrEmpty(path)
|
|
||||||
|
|
||||||
db, err := m.openDB(webhookID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if replaced {
|
|
||||||
m.log.Warn(
|
|
||||||
"created a new, empty database",
|
|
||||||
"webhook_id", webhookID,
|
|
||||||
"path", path,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.dbs.Store(webhookID, db)
|
|
||||||
|
|
||||||
return db, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *WebhookDBManager) dbPath(
|
func (m *WebhookDBManager) dbPath(
|
||||||
webhookID string,
|
webhookID string,
|
||||||
) string {
|
) string {
|
||||||
@@ -381,7 +296,7 @@ func (m *WebhookDBManager) openDB(
|
|||||||
// Run migrations for event-tier models only
|
// Run migrations for event-tier models only
|
||||||
err = db.AutoMigrate(
|
err = db.AutoMigrate(
|
||||||
&Event{}, &Delivery{}, &DeliveryResult{},
|
&Event{}, &Delivery{}, &DeliveryResult{},
|
||||||
&EventTotals{}, &TargetTotals{}, &EntrypointTotals{},
|
&EventTotals{}, &TargetTotals{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = sqlDB.Close()
|
_ = sqlDB.Close()
|
||||||
|
|||||||
@@ -182,91 +182,17 @@ func TestWebhookDBManager_DeleteDB(t *testing.T) {
|
|||||||
}
|
}
|
||||||
require.NoError(t, db.Create(event).Error)
|
require.NoError(t, db.Create(event).Error)
|
||||||
|
|
||||||
// Under WAL, an open database that has been written to has both
|
|
||||||
// sidecars beside it.
|
|
||||||
dbPath := mgr.DBPath(webhookID)
|
|
||||||
require.FileExists(t, dbPath+"-wal")
|
|
||||||
require.FileExists(t, dbPath+"-shm")
|
|
||||||
|
|
||||||
// Delete the DB
|
// Delete the DB
|
||||||
require.NoError(t, mgr.DeleteDB(webhookID))
|
require.NoError(t, mgr.DeleteDB(webhookID))
|
||||||
|
|
||||||
// File should no longer exist
|
// File should no longer exist
|
||||||
assert.False(t, mgr.DBExists(webhookID))
|
assert.False(t, mgr.DBExists(webhookID))
|
||||||
|
|
||||||
// Verify the files are actually gone from disk
|
// Verify the file is actually gone from disk
|
||||||
assert.NoFileExists(t, dbPath)
|
|
||||||
assert.NoFileExists(t, dbPath+"-wal")
|
|
||||||
assert.NoFileExists(t, dbPath+"-shm")
|
|
||||||
}
|
|
||||||
|
|
||||||
// blockRemoval puts a non-empty directory at path, which os.Remove
|
|
||||||
// cannot remove whoever runs the test, root included.
|
|
||||||
func blockRemoval(t *testing.T, path string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(path, "keep"), 0o700))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebhookDBManager_DeleteDBKeepsDatabaseFile proves that when the
|
|
||||||
// event database file cannot be removed, the error says so, and both
|
|
||||||
// sidecars are still removed.
|
|
||||||
func TestWebhookDBManager_DeleteDBKeepsDatabaseFile(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
mgr, lc := setupTestWebhookDBManager(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
require.NoError(t, lc.Start(ctx))
|
|
||||||
|
|
||||||
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
|
||||||
|
|
||||||
webhookID := uuid.New().String()
|
|
||||||
dbPath := mgr.DBPath(webhookID)
|
dbPath := mgr.DBPath(webhookID)
|
||||||
|
|
||||||
blockRemoval(t, dbPath)
|
_, err = os.Stat(dbPath)
|
||||||
require.NoError(t, os.WriteFile(dbPath+"-wal", nil, 0o600))
|
assert.True(t, os.IsNotExist(err))
|
||||||
require.NoError(t, os.WriteFile(dbPath+"-shm", nil, 0o600))
|
|
||||||
|
|
||||||
err := mgr.DeleteDB(webhookID)
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, database.ErrEventDBNotRemoved)
|
|
||||||
require.NotErrorIs(t, err, database.ErrSidecarNotRemoved)
|
|
||||||
assert.Contains(t, err.Error(), dbPath)
|
|
||||||
assert.NoFileExists(t, dbPath+"-wal")
|
|
||||||
assert.NoFileExists(t, dbPath+"-shm")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebhookDBManager_DeleteDBKeepsSidecar proves that when the
|
|
||||||
// event database file is removed but a sidecar is not, the error
|
|
||||||
// says the database file is gone, and the other sidecar is still
|
|
||||||
// removed.
|
|
||||||
func TestWebhookDBManager_DeleteDBKeepsSidecar(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
mgr, lc := setupTestWebhookDBManager(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
require.NoError(t, lc.Start(ctx))
|
|
||||||
|
|
||||||
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
|
||||||
|
|
||||||
webhookID := uuid.New().String()
|
|
||||||
dbPath := mgr.DBPath(webhookID)
|
|
||||||
|
|
||||||
require.NoError(t, mgr.CreateDB(webhookID))
|
|
||||||
// Closing removes the sidecars, so the ones below are the only
|
|
||||||
// ones there.
|
|
||||||
require.NoError(t, mgr.CloseAll())
|
|
||||||
|
|
||||||
blockRemoval(t, dbPath+"-wal")
|
|
||||||
require.NoError(t, os.WriteFile(dbPath+"-shm", nil, 0o600))
|
|
||||||
|
|
||||||
err := mgr.DeleteDB(webhookID)
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, database.ErrSidecarNotRemoved)
|
|
||||||
require.NotErrorIs(t, err, database.ErrEventDBNotRemoved)
|
|
||||||
assert.Contains(t, err.Error(), dbPath+"-wal")
|
|
||||||
assert.NoFileExists(t, dbPath)
|
|
||||||
assert.NoFileExists(t, dbPath+"-shm")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWebhookDBManager_LazyCreation(t *testing.T) {
|
func TestWebhookDBManager_LazyCreation(t *testing.T) {
|
||||||
@@ -289,75 +215,6 @@ func TestWebhookDBManager_LazyCreation(t *testing.T) {
|
|||||||
assert.True(t, mgr.DBExists(webhookID))
|
assert.True(t, mgr.DBExists(webhookID))
|
||||||
}
|
}
|
||||||
|
|
||||||
// A webhook's database is made by CreateDB along with the webhook. One
|
|
||||||
// that GetDB finds missing or zero-length has lost the webhook's events
|
|
||||||
// and pending deliveries, so the empty database made in its place is
|
|
||||||
// logged as a warning naming the file
|
|
||||||
// (https://git.eeqj.de/sneak/webhooker/issues/290). CreateDB, and
|
|
||||||
// reopening a database that is there, log no such warning.
|
|
||||||
func TestWebhookDBManager_LostDatabaseIsLogged(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const created = `level=WARN msg="created a new, empty database"`
|
|
||||||
|
|
||||||
open := func(
|
|
||||||
t *testing.T, prepare func(*database.WebhookDBManager, string),
|
|
||||||
) (string, string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var logs bytes.Buffer
|
|
||||||
|
|
||||||
mgr := database.NewTestWebhookDBManagerWithLogger(
|
|
||||||
t.TempDir(),
|
|
||||||
slog.New(slog.NewTextHandler(&logs, nil)),
|
|
||||||
)
|
|
||||||
|
|
||||||
webhookID := uuid.New().String()
|
|
||||||
prepare(mgr, webhookID)
|
|
||||||
|
|
||||||
_, err := mgr.GetDB(webhookID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, mgr.CloseAll())
|
|
||||||
|
|
||||||
return logs.String(),
|
|
||||||
" webhook_id=" + webhookID + " path=" + mgr.DBPath(webhookID)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("missing", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
logs, fields := open(
|
|
||||||
t, func(*database.WebhookDBManager, string) {},
|
|
||||||
)
|
|
||||||
assert.Contains(t, logs, created+fields)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("zero-length", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
logs, fields := open(
|
|
||||||
t, func(mgr *database.WebhookDBManager, webhookID string) {
|
|
||||||
require.NoError(t, os.WriteFile(
|
|
||||||
mgr.DBPath(webhookID), nil, database.SQLiteFilePerm,
|
|
||||||
))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert.Contains(t, logs, created+fields)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("created with the webhook, then reopened", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
logs, _ := open(
|
|
||||||
t, func(mgr *database.WebhookDBManager, webhookID string) {
|
|
||||||
require.NoError(t, mgr.CreateDB(webhookID))
|
|
||||||
require.NoError(t, mgr.CloseAll())
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert.NotContains(t, logs, created)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhookDBManager_DeliveryWorkflow(t *testing.T) {
|
func TestWebhookDBManager_DeliveryWorkflow(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"gorm.io/gorm"
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/lifecycle"
|
"sneak.berlin/go/webhooker/internal/lifecycle"
|
||||||
@@ -26,14 +25,14 @@ type ArchiveSweeperParams struct {
|
|||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArchiveSweeper periodically prunes expired rows from the
|
// ArchiveSweeper periodically prunes expired rows from
|
||||||
// archive databases of database targets that carry a positive
|
// per-webhook archive databases whose database target carries a
|
||||||
// expiry.
|
// positive expiry.
|
||||||
//
|
//
|
||||||
// Without it, pruning happens only when an archive is
|
// Without it, pruning happens only when an archive is
|
||||||
// (re)opened, and archives are only ever reopened by writes: an
|
// (re)opened, and archives are only ever reopened by writes: an
|
||||||
// archive whose target has stopped receiving events would keep
|
// archive belonging to a webhook that has stopped receiving
|
||||||
// its expired rows forever. The sweep closes
|
// events would keep its expired rows forever. The sweep closes
|
||||||
// that gap without changing anything for archives whose expiry
|
// that gap without changing anything for archives whose expiry
|
||||||
// is unset or "never".
|
// is unset or "never".
|
||||||
//
|
//
|
||||||
@@ -45,12 +44,7 @@ type ArchiveSweeper struct {
|
|||||||
eng *Engine
|
eng *Engine
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
|
|
||||||
// cancel needs no lock: fx calls the stop hook only after the
|
|
||||||
// start hook has returned, so stop never reads it while start
|
|
||||||
// is still setting it.
|
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
|
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,25 +155,17 @@ func (s *ArchiveSweeper) run(ctx context.Context) {
|
|||||||
// soft-deleted along with it, so GORM's default scope already
|
// soft-deleted along with it, so GORM's default scope already
|
||||||
// excludes them.
|
// excludes them.
|
||||||
//
|
//
|
||||||
// A failure for one target is logged and the sweep continues,
|
// A failure for one webhook is logged and the sweep continues,
|
||||||
// matching how the write path already treats a prune error as
|
// matching how the write path already treats a prune error as
|
||||||
// non-fatal.
|
// non-fatal.
|
||||||
func (s *ArchiveSweeper) sweep(ctx context.Context) {
|
func (s *ArchiveSweeper) sweep(ctx context.Context) {
|
||||||
var targets []database.Target
|
var targets []database.Target
|
||||||
|
|
||||||
err := s.db.DB().
|
err := s.db.DB().
|
||||||
WithContext(ctx).
|
|
||||||
Model(&database.Target{}).
|
Model(&database.Target{}).
|
||||||
Where("type = ?", database.TargetTypeDatabase).
|
Where("type = ?", database.TargetTypeDatabase).
|
||||||
Find(&targets).Error
|
Find(&targets).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// The app stopping as a sweep starts cancels the listing.
|
|
||||||
// Stopping is not a failure, so it must not produce an
|
|
||||||
// error line.
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.log.Error(
|
s.log.Error(
|
||||||
"archive sweep: failed to list database targets",
|
"archive sweep: failed to list database targets",
|
||||||
"error", err,
|
"error", err,
|
||||||
@@ -224,20 +210,19 @@ func (s *ArchiveSweeper) sweepTarget(target *database.Target) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.eng.dbTarget.sweepArchive(target.ID, expiry)
|
err = s.eng.dbTarget.sweepWebhook(target.WebhookID, expiry)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// A writer evicted, or a target row gone, underneath the sweep
|
// A writer evicted underneath the sweep means the operator
|
||||||
// means the operator deleted the target or its webhook while
|
// deleted the webhook (or its last database target) while the
|
||||||
// the sweep was walking the target list. That is an ordinary
|
// sweep was walking the target list. That is an ordinary
|
||||||
// interleaving, not a failure, so it must not produce an
|
// interleaving, not a failure, so it must not produce an
|
||||||
// error line.
|
// error line.
|
||||||
if errors.Is(err, errArchiveWriterEvicted) ||
|
if errors.Is(err, errArchiveWriterEvicted) {
|
||||||
errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
s.log.Debug(
|
s.log.Debug(
|
||||||
"archive sweep: target deleted mid-sweep",
|
"archive sweep: writer evicted mid-sweep",
|
||||||
"webhook_id", target.WebhookID,
|
"webhook_id", target.WebhookID,
|
||||||
"target_id", target.ID,
|
"target_id", target.ID,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
package delivery_test
|
package delivery_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -22,7 +20,6 @@ import (
|
|||||||
_ "modernc.org/sqlite" // Pure Go SQLite driver.
|
_ "modernc.org/sqlite" // Pure Go SQLite driver.
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -37,23 +34,18 @@ const (
|
|||||||
sweepConcurrentWrites = 20
|
sweepConcurrentWrites = 20
|
||||||
)
|
)
|
||||||
|
|
||||||
// archiveTestWebhookName is the name of every webhook
|
// sweeperEnv bundles the pieces an archive sweep test drives:
|
||||||
// seedDatabaseTarget creates. It is not safe in a file name as it
|
// a main configuration database holding webhooks and targets, a
|
||||||
// stands, so every archive test goes through archiveNamePart.
|
// delivery engine owning the archive writer registry, and the
|
||||||
const archiveTestWebhookName = "Sweep Test!"
|
// data directory the archive files live in.
|
||||||
|
type sweeperEnv struct {
|
||||||
// archiveEnv bundles the pieces an archive test drives: a main
|
|
||||||
// configuration database holding webhooks and targets, a delivery
|
|
||||||
// engine owning the archive writer registry, the archive sweeper,
|
|
||||||
// and the data directory the archive files live in.
|
|
||||||
type archiveEnv struct {
|
|
||||||
sweeper *delivery.ArchiveSweeper
|
sweeper *delivery.ArchiveSweeper
|
||||||
eng *delivery.Engine
|
eng *delivery.Engine
|
||||||
mainDB *database.Database
|
mainDB *database.Database
|
||||||
dataDir string
|
dataDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupArchiveTest(t *testing.T) *archiveEnv {
|
func setupSweeperTest(t *testing.T) *sweeperEnv {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
dataDir := t.TempDir()
|
dataDir := t.TempDir()
|
||||||
@@ -71,8 +63,7 @@ func setupArchiveTest(t *testing.T) *archiveEnv {
|
|||||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||||
|
|
||||||
gdb, err := gorm.Open(
|
gdb, err := gorm.Open(
|
||||||
sqlite.Dialector{Conn: sqlDB},
|
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
||||||
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -87,7 +78,7 @@ func setupArchiveTest(t *testing.T) *archiveEnv {
|
|||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
|
|
||||||
return &archiveEnv{
|
return &sweeperEnv{
|
||||||
sweeper: delivery.NewTestArchiveSweeper(
|
sweeper: delivery.NewTestArchiveSweeper(
|
||||||
mainDB, eng, log,
|
mainDB, eng, log,
|
||||||
),
|
),
|
||||||
@@ -97,27 +88,25 @@ func setupArchiveTest(t *testing.T) *archiveEnv {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// archivePath returns where the engine keeps a database target's
|
// archivePath returns where the engine keeps a webhook's
|
||||||
// archive file, for the names seedDatabaseTarget gave it.
|
// archive file.
|
||||||
func (env *archiveEnv) archivePath(tgt *database.Target) string {
|
func (env *sweeperEnv) archivePath(webhookID string) string {
|
||||||
return filepath.Join(
|
return filepath.Join(
|
||||||
env.dataDir,
|
env.dataDir, fmt.Sprintf("archive-%s.db", webhookID),
|
||||||
delivery.ArchiveFileName(
|
|
||||||
archiveTestWebhookName, tgt.Name, tgt.ID,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedDatabaseTarget creates a webhook with one database target
|
// seedDatabaseTarget creates a webhook with one database target
|
||||||
// carrying the given target config JSON, and returns the target.
|
// carrying the given target config JSON, and returns the
|
||||||
func (env *archiveEnv) seedDatabaseTarget(
|
// webhook id.
|
||||||
|
func (env *sweeperEnv) seedDatabaseTarget(
|
||||||
t *testing.T, configJSON string,
|
t *testing.T, configJSON string,
|
||||||
) *database.Target {
|
) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
wh := &database.Webhook{
|
wh := &database.Webhook{
|
||||||
UserID: uuid.New().String(),
|
UserID: uuid.New().String(),
|
||||||
Name: archiveTestWebhookName,
|
Name: "sweep-test",
|
||||||
}
|
}
|
||||||
require.NoError(
|
require.NoError(
|
||||||
t,
|
t,
|
||||||
@@ -126,19 +115,9 @@ func (env *archiveEnv) seedDatabaseTarget(
|
|||||||
Create(wh).Error,
|
Create(wh).Error,
|
||||||
)
|
)
|
||||||
|
|
||||||
return env.addDatabaseTarget(t, wh.ID, configJSON)
|
|
||||||
}
|
|
||||||
|
|
||||||
// addDatabaseTarget creates one more database target on an
|
|
||||||
// existing webhook and returns it.
|
|
||||||
func (env *archiveEnv) addDatabaseTarget(
|
|
||||||
t *testing.T, webhookID, configJSON string,
|
|
||||||
) *database.Target {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
tgt := &database.Target{
|
tgt := &database.Target{
|
||||||
WebhookID: webhookID,
|
WebhookID: wh.ID,
|
||||||
Name: "Archive",
|
Name: "archive",
|
||||||
Type: database.TargetTypeDatabase,
|
Type: database.TargetTypeDatabase,
|
||||||
Active: true,
|
Active: true,
|
||||||
Config: configJSON,
|
Config: configJSON,
|
||||||
@@ -150,30 +129,19 @@ func (env *archiveEnv) addDatabaseTarget(
|
|||||||
Create(tgt).Error,
|
Create(tgt).Error,
|
||||||
)
|
)
|
||||||
|
|
||||||
return tgt
|
return wh.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedArchiveRows creates the archive file for a target and
|
// seedArchiveRows creates the archive file for a webhook and
|
||||||
// inserts one row per supplied archived-at timestamp, returning
|
// inserts one row per supplied archived-at timestamp, returning
|
||||||
// the archive path. The handle is closed before returning, so
|
// the archive path. The handle is closed before returning, so
|
||||||
// the archive is idle exactly as it would be with no traffic.
|
// the archive is idle exactly as it would be with no traffic.
|
||||||
func (env *archiveEnv) seedArchiveRows(
|
func (env *sweeperEnv) seedArchiveRows(
|
||||||
t *testing.T, tgt *database.Target, archivedAt ...time.Time,
|
t *testing.T, webhookID string, archivedAt ...time.Time,
|
||||||
) string {
|
) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
path := env.archivePath(tgt)
|
path := env.archivePath(webhookID)
|
||||||
seedArchiveFile(t, path, tgt.WebhookID, archivedAt...)
|
|
||||||
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedArchiveFile creates the archive file at path and inserts one row
|
|
||||||
// per supplied archived-at timestamp, as seedArchiveRows does.
|
|
||||||
func seedArchiveFile(
|
|
||||||
t *testing.T, path, webhookID string, archivedAt ...time.Time,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
sqlDB, err := sql.Open(
|
sqlDB, err := sql.Open(
|
||||||
"sqlite", fmt.Sprintf("file:%s?mode=rwc", path),
|
"sqlite", fmt.Sprintf("file:%s?mode=rwc", path),
|
||||||
@@ -181,8 +149,7 @@ func seedArchiveFile(
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
gdb, err := gorm.Open(
|
gdb, err := gorm.Open(
|
||||||
sqlite.Dialector{Conn: sqlDB},
|
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
||||||
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -202,6 +169,8 @@ func seedArchiveFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(t, sqlDB.Close())
|
require.NoError(t, sqlDB.Close())
|
||||||
|
|
||||||
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
// archivedEventIDs returns the event ids currently stored in an
|
// archivedEventIDs returns the event ids currently stored in an
|
||||||
@@ -239,8 +208,7 @@ func countArchivedRows(path string) (int64, error) {
|
|||||||
defer func() { _ = sqlDB.Close() }()
|
defer func() { _ = sqlDB.Close() }()
|
||||||
|
|
||||||
gdb, err := gorm.Open(
|
gdb, err := gorm.Open(
|
||||||
sqlite.Dialector{Conn: sqlDB},
|
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
||||||
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -275,13 +243,13 @@ func TestArchiveSweeper_LoopOutlivesStartHookContext(
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
env := setupSweeperTest(t)
|
||||||
|
|
||||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
path := env.seedArchiveRows(
|
path := env.seedArchiveRows(
|
||||||
t, tgt,
|
t, webhookID,
|
||||||
now.Add(-48*time.Hour),
|
now.Add(-48*time.Hour),
|
||||||
now.Add(-time.Minute),
|
now.Add(-time.Minute),
|
||||||
)
|
)
|
||||||
@@ -319,60 +287,60 @@ func TestArchiveSweeper_LoopOutlivesStartHookContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestArchiveSweep_DoesNotResurrectEvictedWriter covers the
|
// TestArchiveSweep_DoesNotResurrectEvictedWriter covers the
|
||||||
// interleaving where a sweep tick has already listed a target
|
// interleaving where a sweep tick has already listed a webhook's
|
||||||
// when the target is deleted and its writer evicted. The sweep
|
// target when the webhook is deleted and its writer evicted. The
|
||||||
// must not put a writer back into the registry: nothing would
|
// sweep must not put a writer back into the registry: nothing
|
||||||
// ever evict it again, which is precisely the leak this change
|
// would ever evict it again, which is precisely the leak this
|
||||||
// exists to close.
|
// change exists to close.
|
||||||
func TestArchiveSweep_DoesNotResurrectEvictedWriter(
|
func TestArchiveSweep_DoesNotResurrectEvictedWriter(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
env := setupSweeperTest(t)
|
||||||
|
|
||||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
env.seedArchiveRows(
|
env.seedArchiveRows(
|
||||||
t, tgt, time.Now().Add(-48*time.Hour),
|
t, webhookID, time.Now().Add(-48*time.Hour),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Prime the registry the way a delivery would, then evict as
|
// Prime the registry the way a delivery would, then evict as
|
||||||
// the deletion path does. The target row is deliberately left
|
// the deletion path does. The target row is deliberately left
|
||||||
// in place: this is the tick that listed the target before
|
// in place: this is the tick that listed the webhook before
|
||||||
// the deletion committed.
|
// the deletion committed.
|
||||||
_, err := env.eng.ExportEnsureArchiveWriter(tgt.ID)
|
_, err := env.eng.ExportEnsureArchiveWriter(webhookID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
env.eng.EvictTarget(tgt.ID)
|
env.eng.EvictWebhook(webhookID)
|
||||||
require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID))
|
require.False(t, env.eng.ExportHasArchiveWriter(webhookID))
|
||||||
|
|
||||||
env.sweeper.ExportSweep(context.Background())
|
env.sweeper.ExportSweep(context.Background())
|
||||||
|
|
||||||
assert.False(
|
assert.False(
|
||||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
t, env.eng.ExportHasArchiveWriter(webhookID),
|
||||||
"a sweep must never re-register a writer for a target "+
|
"a sweep must never re-register a writer for a webhook "+
|
||||||
"whose registry entry has already been released",
|
"whose registry entry has already been released",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestArchiveSweep_LeavesNoRegistryEntry states the same
|
// TestArchiveSweep_LeavesNoRegistryEntry states the same
|
||||||
// invariant in its general form: sweeping an archive whose
|
// invariant in its general form: sweeping an archive whose
|
||||||
// target has no cached writer must not leave one behind, so the
|
// webhook has no cached writer must not leave one behind, so the
|
||||||
// registry keeps holding only writers a delivery created and an
|
// registry keeps holding only writers a delivery created and an
|
||||||
// eviction can reach.
|
// eviction can reach.
|
||||||
func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) {
|
func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
env := setupSweeperTest(t)
|
||||||
|
|
||||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
path := env.seedArchiveRows(
|
path := env.seedArchiveRows(
|
||||||
t, tgt,
|
t, webhookID,
|
||||||
time.Now().Add(-48*time.Hour),
|
time.Now().Add(-48*time.Hour),
|
||||||
time.Now().Add(-time.Minute),
|
time.Now().Add(-time.Minute),
|
||||||
)
|
)
|
||||||
|
|
||||||
require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID))
|
require.False(t, env.eng.ExportHasArchiveWriter(webhookID))
|
||||||
|
|
||||||
env.sweeper.ExportSweep(context.Background())
|
env.sweeper.ExportSweep(context.Background())
|
||||||
|
|
||||||
@@ -381,7 +349,7 @@ func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) {
|
|||||||
"the sweep must still prune an idle archive",
|
"the sweep must still prune an idle archive",
|
||||||
)
|
)
|
||||||
assert.False(
|
assert.False(
|
||||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
t, env.eng.ExportHasArchiveWriter(webhookID),
|
||||||
"the sweep must release the registry entry it created",
|
"the sweep must release the registry entry it created",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -396,31 +364,34 @@ func TestArchiveSweep_KeepsWriterAdoptedByDelivery(
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
env := setupSweeperTest(t)
|
||||||
|
|
||||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
env.seedArchiveRows(
|
env.seedArchiveRows(
|
||||||
t, tgt, time.Now().Add(-48*time.Hour),
|
t, webhookID, time.Now().Add(-48*time.Hour),
|
||||||
)
|
)
|
||||||
|
|
||||||
webhookDB := testWebhookDB(t)
|
webhookDB := testWebhookDB(t)
|
||||||
event := seedEvent(t, webhookDB, `{"n":1}`)
|
event := seedEvent(t, webhookDB, `{"n":1}`)
|
||||||
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
|
event.WebhookID = webhookID
|
||||||
|
d := seedDatabaseTargetDelivery(
|
||||||
|
t, webhookDB, event, `{"expiry":"1h"}`,
|
||||||
|
)
|
||||||
|
|
||||||
env.sweeper.ExportSweep(context.Background())
|
env.sweeper.ExportSweep(context.Background())
|
||||||
require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID))
|
require.False(t, env.eng.ExportHasArchiveWriter(webhookID))
|
||||||
|
|
||||||
env.eng.ExportDeliverDatabase(webhookDB, d)
|
env.eng.ExportDeliverDatabase(webhookDB, d)
|
||||||
|
|
||||||
assert.True(
|
assert.True(
|
||||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
t, env.eng.ExportHasArchiveWriter(webhookID),
|
||||||
"a delivery's writer must stay registered",
|
"a delivery's writer must stay registered",
|
||||||
)
|
)
|
||||||
|
|
||||||
env.sweeper.ExportSweep(context.Background())
|
env.sweeper.ExportSweep(context.Background())
|
||||||
|
|
||||||
assert.True(
|
assert.True(
|
||||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
t, env.eng.ExportHasArchiveWriter(webhookID),
|
||||||
"a sweep must not drop a writer a delivery owns",
|
"a sweep must not drop a writer a delivery owns",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -452,15 +423,15 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
env := setupSweeperTest(t)
|
||||||
|
|
||||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
env.seedArchiveRows(
|
env.seedArchiveRows(
|
||||||
t, tgt, time.Now().Add(-48*time.Hour),
|
t, webhookID, time.Now().Add(-48*time.Hour),
|
||||||
)
|
)
|
||||||
|
|
||||||
sweepWriter, created, err := env.eng.ExportSweepWriterFor(
|
sweepWriter, created, err := env.eng.ExportSweepWriterFor(
|
||||||
tgt.ID,
|
webhookID,
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.True(
|
require.True(
|
||||||
@@ -471,34 +442,37 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
|
|||||||
// The delivery lands mid-sweep and adopts the entry.
|
// The delivery lands mid-sweep and adopts the entry.
|
||||||
webhookDB := testWebhookDB(t)
|
webhookDB := testWebhookDB(t)
|
||||||
event := seedEvent(t, webhookDB, `{"n":1}`)
|
event := seedEvent(t, webhookDB, `{"n":1}`)
|
||||||
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
|
event.WebhookID = webhookID
|
||||||
|
d := seedDatabaseTargetDelivery(
|
||||||
|
t, webhookDB, event, `{"expiry":"1h"}`,
|
||||||
|
)
|
||||||
|
|
||||||
env.eng.ExportDeliverDatabase(webhookDB, d)
|
env.eng.ExportDeliverDatabase(webhookDB, d)
|
||||||
|
|
||||||
adopted := env.eng.ExportArchiveWriterFor(tgt.ID)
|
adopted := env.eng.ExportArchiveWriterFor(webhookID)
|
||||||
require.NotNil(t, adopted)
|
require.NotNil(t, adopted)
|
||||||
require.True(
|
require.True(
|
||||||
t, sweepWriter.Same(adopted),
|
t, sweepWriter.Same(adopted),
|
||||||
"the delivery must have adopted the sweep's writer",
|
"the delivery must have adopted the sweep's writer",
|
||||||
)
|
)
|
||||||
require.True(
|
require.True(
|
||||||
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
|
t, env.eng.ExportArchiveHandleOpen(webhookID),
|
||||||
"the delivery leaves the archive handle open",
|
"the delivery leaves the archive handle open",
|
||||||
)
|
)
|
||||||
|
|
||||||
// The sweep finishes.
|
// The sweep finishes.
|
||||||
env.eng.ExportReleaseSweepWriter(tgt.ID, sweepWriter)
|
env.eng.ExportReleaseSweepWriter(webhookID, sweepWriter)
|
||||||
|
|
||||||
require.True(
|
require.True(
|
||||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
t, env.eng.ExportHasArchiveWriter(webhookID),
|
||||||
"a writer adopted by a delivery during a sweep must "+
|
"a writer adopted by a delivery during a sweep must "+
|
||||||
"stay registered, or its open handle is unreachable",
|
"stay registered, or its open handle is unreachable",
|
||||||
)
|
)
|
||||||
|
|
||||||
env.eng.EvictTarget(tgt.ID)
|
env.eng.EvictWebhook(webhookID)
|
||||||
|
|
||||||
assert.False(
|
assert.False(
|
||||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
t, env.eng.ExportHasArchiveWriter(webhookID),
|
||||||
"the adopted writer must still be evictable",
|
"the adopted writer must still be evictable",
|
||||||
)
|
)
|
||||||
assert.False(
|
assert.False(
|
||||||
@@ -507,34 +481,34 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestArchiveSweep_ContinuesAfterPerTargetFailure proves a
|
// TestArchiveSweep_ContinuesAfterPerWebhookFailure proves a
|
||||||
// failure for one target does not abort the sweep for the
|
// failure for one webhook does not abort the sweep for the
|
||||||
// others: an unparseable expiry and an unreadable archive both
|
// others: an unparseable expiry and an unreadable archive both
|
||||||
// have to be logged and stepped over.
|
// have to be logged and stepped over.
|
||||||
func TestArchiveSweep_ContinuesAfterPerTargetFailure(
|
func TestArchiveSweep_ContinuesAfterPerWebhookFailure(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
env := setupSweeperTest(t)
|
||||||
|
|
||||||
// Seeded first so the sweep reaches them before the healthy
|
// Seeded first so the sweep reaches them before the healthy
|
||||||
// target: targets come back in insertion order.
|
// webhook: targets come back in insertion order.
|
||||||
badConfig := env.seedDatabaseTarget(t, `{"expiry":"!!!"}`)
|
badConfigID := env.seedDatabaseTarget(t, `{"expiry":"!!!"}`)
|
||||||
env.seedArchiveRows(
|
env.seedArchiveRows(
|
||||||
t, badConfig, time.Now().Add(-48*time.Hour),
|
t, badConfigID, time.Now().Add(-48*time.Hour),
|
||||||
)
|
)
|
||||||
|
|
||||||
corrupt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
corruptID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
require.NoError(t, os.WriteFile(
|
require.NoError(t, os.WriteFile(
|
||||||
env.archivePath(corrupt),
|
env.archivePath(corruptID),
|
||||||
[]byte("this is not a sqlite database"),
|
[]byte("this is not a sqlite database"),
|
||||||
0o600,
|
0o600,
|
||||||
))
|
))
|
||||||
|
|
||||||
healthy := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
healthyID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
healthyPath := env.seedArchiveRows(
|
healthyPath := env.seedArchiveRows(
|
||||||
t, healthy,
|
t, healthyID,
|
||||||
time.Now().Add(-48*time.Hour),
|
time.Now().Add(-48*time.Hour),
|
||||||
time.Now().Add(-time.Minute),
|
time.Now().Add(-time.Minute),
|
||||||
)
|
)
|
||||||
@@ -544,14 +518,14 @@ func TestArchiveSweep_ContinuesAfterPerTargetFailure(
|
|||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, []string{sweepRowNew},
|
t, []string{sweepRowNew},
|
||||||
archivedEventIDs(t, healthyPath),
|
archivedEventIDs(t, healthyPath),
|
||||||
"a failure for an earlier target must not stop the "+
|
"a failure for an earlier webhook must not stop the "+
|
||||||
"sweep from pruning the ones after it",
|
"sweep from pruning the ones after it",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestArchiveSweep_OpenExistingDoesNotCreateFile pins the second
|
// TestArchiveSweep_OpenExistingDoesNotCreateFile pins the second
|
||||||
// of the two no-create guards. The first is the stat in
|
// of the two no-create guards. The first is the stat in
|
||||||
// sweepExpired; this one is the SQLite open mode, which is what
|
// sweepWebhook; this one is the SQLite open mode, which is what
|
||||||
// protects the window between that stat and the open. Flipping
|
// protects the window between that stat and the open. Flipping
|
||||||
// the sweep's mode to create-if-missing makes this fail.
|
// the sweep's mode to create-if-missing makes this fail.
|
||||||
func TestArchiveSweep_OpenExistingDoesNotCreateFile(
|
func TestArchiveSweep_OpenExistingDoesNotCreateFile(
|
||||||
@@ -587,13 +561,13 @@ func TestArchiveSweep_OpenExistingDoesNotCreateFile(
|
|||||||
func TestArchiveSweep_PrunesIdleArchive(t *testing.T) {
|
func TestArchiveSweep_PrunesIdleArchive(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
env := setupSweeperTest(t)
|
||||||
|
|
||||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
path := env.seedArchiveRows(
|
path := env.seedArchiveRows(
|
||||||
t, tgt,
|
t, webhookID,
|
||||||
now.Add(-48*time.Hour),
|
now.Add(-48*time.Hour),
|
||||||
now.Add(-time.Minute),
|
now.Add(-time.Minute),
|
||||||
)
|
)
|
||||||
@@ -626,11 +600,11 @@ func TestArchiveSweep_PrunesIdleArchive(t *testing.T) {
|
|||||||
func TestArchiveSweep_LeavesArchiveClosed(t *testing.T) {
|
func TestArchiveSweep_LeavesArchiveClosed(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
env := setupSweeperTest(t)
|
||||||
|
|
||||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
path := env.seedArchiveRows(
|
path := env.seedArchiveRows(
|
||||||
t, tgt, time.Now().Add(-48*time.Hour),
|
t, webhookID, time.Now().Add(-48*time.Hour),
|
||||||
)
|
)
|
||||||
|
|
||||||
w := delivery.NewExportArchiveWriter(
|
w := delivery.NewExportArchiveWriter(
|
||||||
@@ -666,94 +640,39 @@ func TestArchiveSweep_ClosesHandleOfRegisteredWriter(
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
env := setupSweeperTest(t)
|
||||||
|
|
||||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
env.seedArchiveRows(
|
env.seedArchiveRows(
|
||||||
t, tgt, time.Now().Add(-48*time.Hour),
|
t, webhookID, time.Now().Add(-48*time.Hour),
|
||||||
)
|
)
|
||||||
|
|
||||||
webhookDB := testWebhookDB(t)
|
webhookDB := testWebhookDB(t)
|
||||||
event := seedEvent(t, webhookDB, `{"n":1}`)
|
event := seedEvent(t, webhookDB, `{"n":1}`)
|
||||||
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
|
event.WebhookID = webhookID
|
||||||
|
d := seedDatabaseTargetDelivery(
|
||||||
|
t, webhookDB, event, `{"expiry":"1h"}`,
|
||||||
|
)
|
||||||
|
|
||||||
env.eng.ExportDeliverDatabase(webhookDB, d)
|
env.eng.ExportDeliverDatabase(webhookDB, d)
|
||||||
|
|
||||||
require.True(
|
require.True(
|
||||||
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
|
t, env.eng.ExportArchiveHandleOpen(webhookID),
|
||||||
"the delivery must leave the archive handle open",
|
"the delivery must leave the archive handle open",
|
||||||
)
|
)
|
||||||
|
|
||||||
env.sweeper.ExportSweep(context.Background())
|
env.sweeper.ExportSweep(context.Background())
|
||||||
|
|
||||||
require.True(
|
require.True(
|
||||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
t, env.eng.ExportHasArchiveWriter(webhookID),
|
||||||
"the delivery's registry entry must survive the sweep",
|
"the delivery's registry entry must survive the sweep",
|
||||||
)
|
)
|
||||||
assert.False(
|
assert.False(
|
||||||
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
|
t, env.eng.ExportArchiveHandleOpen(webhookID),
|
||||||
"the sweep must leave the archive closed",
|
"the sweep must leave the archive closed",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestArchiveSweep_ClosesHandleBeforeReopening proves the sweep
|
|
||||||
// closes the handle it finds open before it reopens the file.
|
|
||||||
// TestArchiveSweep_LeavesArchiveClosed cannot see this: without the
|
|
||||||
// close, the reopen replaces the handle without closing it, the
|
|
||||||
// sweep then closes only the new one, and one connection leaks per
|
|
||||||
// archive per sweep.
|
|
||||||
func TestArchiveSweep_ClosesHandleBeforeReopening(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "archive.db")
|
|
||||||
|
|
||||||
w := delivery.NewExportArchiveWriter(
|
|
||||||
path, archiveTestLogger(), 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
require.NoError(t, w.Open(time.Hour))
|
|
||||||
|
|
||||||
before, err := w.DB().DB()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.NoError(t, w.SweepExpired(time.Hour))
|
|
||||||
|
|
||||||
assert.Error(
|
|
||||||
t, before.PingContext(t.Context()),
|
|
||||||
"the handle open before the sweep must be closed by it",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchiveSweep_CancelledSweepLogsNoError proves a sweep whose
|
|
||||||
// context is already cancelled, as when the app stops just as a
|
|
||||||
// sweep starts, returns without an error line: stopping is not a
|
|
||||||
// failure.
|
|
||||||
func TestArchiveSweep_CancelledSweepLogsNoError(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
|
||||||
|
|
||||||
var errorLines bytes.Buffer
|
|
||||||
|
|
||||||
sweeper := delivery.NewTestArchiveSweeper(
|
|
||||||
env.mainDB, env.eng,
|
|
||||||
slog.New(slog.NewTextHandler(
|
|
||||||
&errorLines,
|
|
||||||
&slog.HandlerOptions{Level: slog.LevelError},
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
sweeper.ExportSweep(ctx)
|
|
||||||
|
|
||||||
assert.Empty(
|
|
||||||
t, errorLines.String(),
|
|
||||||
"a cancelled sweep must not log at error level",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchiveSweep_NeverExpiryUntouched proves the sweep is a
|
// TestArchiveSweep_NeverExpiryUntouched proves the sweep is a
|
||||||
// no-op for the default retention policy, so archives with no
|
// no-op for the default retention policy, so archives with no
|
||||||
// expiry (or the literal "never") behave exactly as before.
|
// expiry (or the literal "never") behave exactly as before.
|
||||||
@@ -765,11 +684,11 @@ func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) {
|
|||||||
`{"expiry":""}`,
|
`{"expiry":""}`,
|
||||||
"",
|
"",
|
||||||
} {
|
} {
|
||||||
env := setupArchiveTest(t)
|
env := setupSweeperTest(t)
|
||||||
|
|
||||||
tgt := env.seedDatabaseTarget(t, configJSON)
|
webhookID := env.seedDatabaseTarget(t, configJSON)
|
||||||
path := env.seedArchiveRows(
|
path := env.seedArchiveRows(
|
||||||
t, tgt,
|
t, webhookID,
|
||||||
time.Now().Add(-10000*time.Hour),
|
time.Now().Add(-10000*time.Hour),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -780,7 +699,7 @@ func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) {
|
|||||||
"config %q must keep rows forever", configJSON,
|
"config %q must keep rows forever", configJSON,
|
||||||
)
|
)
|
||||||
assert.False(
|
assert.False(
|
||||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
t, env.eng.ExportHasArchiveWriter(webhookID),
|
||||||
"config %q must leave no registry entry behind",
|
"config %q must leave no registry entry behind",
|
||||||
configJSON,
|
configJSON,
|
||||||
)
|
)
|
||||||
@@ -803,10 +722,10 @@ func TestArchiveSweep_NeverExpirySkipsBeforeOpening(
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
env := setupSweeperTest(t)
|
||||||
|
|
||||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"never"}`)
|
webhookID := env.seedDatabaseTarget(t, `{"expiry":"never"}`)
|
||||||
path := env.archivePath(tgt)
|
path := env.archivePath(webhookID)
|
||||||
|
|
||||||
seedUnmigratedArchive(t, path)
|
seedUnmigratedArchive(t, path)
|
||||||
require.False(t, archiveTableExists(t, path))
|
require.False(t, archiveTableExists(t, path))
|
||||||
@@ -849,16 +768,16 @@ func archiveTableExists(t *testing.T, path string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestArchiveSweep_DoesNotCreateArchiveFile proves the sweep
|
// TestArchiveSweep_DoesNotCreateArchiveFile proves the sweep
|
||||||
// never conjures an archive: a database target that has never
|
// never conjures an archive: a webhook with a database target
|
||||||
// received an event must still have no archive file (nor SQLite
|
// that has never received an event must still have no archive
|
||||||
// sidecar) after a sweep, and no registry entry either.
|
// file (nor SQLite sidecar) after a sweep.
|
||||||
func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) {
|
func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
env := setupSweeperTest(t)
|
||||||
|
|
||||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
path := env.archivePath(tgt)
|
path := env.archivePath(webhookID)
|
||||||
|
|
||||||
require.NoFileExists(t, path)
|
require.NoFileExists(t, path)
|
||||||
|
|
||||||
@@ -870,11 +789,6 @@ func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) {
|
|||||||
"the sweep must not create an archive file",
|
"the sweep must not create an archive file",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.False(
|
|
||||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
|
||||||
"the sweep must leave no registry entry behind",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestArchiveSweep_DoesNotCreateAfterWriterExists covers the
|
// TestArchiveSweep_DoesNotCreateAfterWriterExists covers the
|
||||||
@@ -886,11 +800,11 @@ func TestArchiveSweep_DoesNotCreateAfterWriterExists(
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
env := setupSweeperTest(t)
|
||||||
|
|
||||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
|
|
||||||
path, err := env.eng.ExportEnsureArchiveWriter(tgt.ID)
|
path, err := env.eng.ExportEnsureArchiveWriter(webhookID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoFileExists(t, path)
|
require.NoFileExists(t, path)
|
||||||
|
|
||||||
@@ -905,17 +819,17 @@ func TestArchiveSweep_DoesNotCreateAfterWriterExists(
|
|||||||
func TestArchiveSweep_SkipsDeletedWebhookTargets(t *testing.T) {
|
func TestArchiveSweep_SkipsDeletedWebhookTargets(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
env := setupSweeperTest(t)
|
||||||
|
|
||||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
path := env.seedArchiveRows(
|
path := env.seedArchiveRows(
|
||||||
t, tgt, time.Now().Add(-48*time.Hour),
|
t, webhookID, time.Now().Add(-48*time.Hour),
|
||||||
)
|
)
|
||||||
|
|
||||||
require.NoError(
|
require.NoError(
|
||||||
t,
|
t,
|
||||||
env.mainDB.DB().
|
env.mainDB.DB().
|
||||||
Where("webhook_id = ?", tgt.WebhookID).
|
Where("webhook_id = ?", webhookID).
|
||||||
Delete(&database.Target{}).Error,
|
Delete(&database.Target{}).Error,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -928,14 +842,14 @@ func TestArchiveSweep_SkipsDeletedWebhookTargets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestArchiveSweep_ConcurrentWrites proves the sweep serialises
|
// TestArchiveSweep_ConcurrentWrites proves the sweep serialises
|
||||||
// against writes through the target's writer mutex. Run under
|
// against writes through the per-webhook writer mutex. Run
|
||||||
// -race, an unsynchronised sweep would be caught here.
|
// under -race, an unsynchronised sweep would be caught here.
|
||||||
func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
|
func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
env := setupSweeperTest(t)
|
||||||
|
|
||||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
|
|
||||||
webhookDB := testWebhookDB(t)
|
webhookDB := testWebhookDB(t)
|
||||||
|
|
||||||
@@ -948,10 +862,13 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
|
|||||||
|
|
||||||
for range sweepConcurrentWrites {
|
for range sweepConcurrentWrites {
|
||||||
event := seedEvent(t, webhookDB, `{"n":1}`)
|
event := seedEvent(t, webhookDB, `{"n":1}`)
|
||||||
|
event.WebhookID = webhookID
|
||||||
|
|
||||||
deliveries = append(
|
deliveries = append(
|
||||||
deliveries,
|
deliveries,
|
||||||
seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
|
seedDatabaseTargetDelivery(
|
||||||
|
t, webhookDB, event, `{"expiry":"1h"}`,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -977,7 +894,7 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
|
|||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
assert.FileExists(t, env.archivePath(tgt))
|
assert.FileExists(t, env.archivePath(webhookID))
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestArchiveSweeper_StopsCleanly proves the background loop
|
// TestArchiveSweeper_StopsCleanly proves the background loop
|
||||||
@@ -985,11 +902,11 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
|
|||||||
func TestArchiveSweeper_StopsCleanly(t *testing.T) {
|
func TestArchiveSweeper_StopsCleanly(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
env := setupSweeperTest(t)
|
||||||
|
|
||||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
env.seedArchiveRows(
|
env.seedArchiveRows(
|
||||||
t, tgt, time.Now().Add(-48*time.Hour),
|
t, webhookID, time.Now().Add(-48*time.Hour),
|
||||||
)
|
)
|
||||||
|
|
||||||
env.sweeper.ExportSetInterval(time.Millisecond)
|
env.sweeper.ExportSetInterval(time.Millisecond)
|
||||||
@@ -1013,7 +930,7 @@ func TestArchiveSweeper_StopHookHonoursStopTimeout(
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
env := setupSweeperTest(t)
|
||||||
|
|
||||||
lc := &recordingLifecycle{}
|
lc := &recordingLifecycle{}
|
||||||
env.sweeper.ExportRegisterHooks(lc)
|
env.sweeper.ExportRegisterHooks(lc)
|
||||||
|
|||||||
@@ -102,20 +102,6 @@ func (cb *CircuitBreaker) CooldownRemaining() time.Duration {
|
|||||||
return remaining
|
return remaining
|
||||||
}
|
}
|
||||||
|
|
||||||
// StateAndCooldown returns the circuit state and, while the circuit is
|
|
||||||
// open, what is left of the cooldown, or zero once that has passed.
|
|
||||||
// Both are read under one lock, so they always agree.
|
|
||||||
func (cb *CircuitBreaker) StateAndCooldown() (CircuitState, time.Duration) {
|
|
||||||
cb.mu.Lock()
|
|
||||||
defer cb.mu.Unlock()
|
|
||||||
|
|
||||||
if cb.state != CircuitOpen {
|
|
||||||
return cb.state, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return cb.state, max(cb.cooldown-time.Since(cb.lastFailure), 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordSuccess records a successful delivery and resets
|
// RecordSuccess records a successful delivery and resets
|
||||||
// the circuit breaker to closed state.
|
// the circuit breaker to closed state.
|
||||||
func (cb *CircuitBreaker) RecordSuccess() {
|
func (cb *CircuitBreaker) RecordSuccess() {
|
||||||
|
|||||||
+32
-132
@@ -14,7 +14,6 @@ import (
|
|||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
|
||||||
"sneak.berlin/go/webhooker/internal/lifecycle"
|
"sneak.berlin/go/webhooker/internal/lifecycle"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
"sneak.berlin/go/webhooker/internal/metrics"
|
"sneak.berlin/go/webhooker/internal/metrics"
|
||||||
@@ -123,33 +122,21 @@ type Notifier interface {
|
|||||||
Notify(tasks []Task)
|
Notify(tasks []Task)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Archives is how the handlers keep the database targets' archive
|
// WebhookEvictor releases the delivery engine's per-webhook
|
||||||
// files in step with the configuration. Deleting a webhook or a
|
// state for a webhook that no longer needs it — currently the
|
||||||
// target releases the cached archive writers, whose open file
|
// cached archive writer of the database target, whose open
|
||||||
// handles would otherwise outlive them; renaming one renames the
|
// file handle would otherwise outlive the webhook.
|
||||||
// archive files, which are named for the webhook and the target
|
|
||||||
// (see ArchiveFileName).
|
|
||||||
//
|
//
|
||||||
// It is deliberately separate from Notifier: archiving lifecycle
|
// It is deliberately separate from Notifier and deliberately
|
||||||
// is not notification, and a small interface keeps the handlers
|
// one method wide: archiving lifecycle is not notification, and
|
||||||
// package free of any dependency on the engine's internals while
|
// a single-method interface keeps the handlers package free of
|
||||||
// staying trivially fakeable in tests.
|
// any dependency on the engine's internals while staying
|
||||||
|
// trivially fakeable in tests.
|
||||||
//
|
//
|
||||||
// Neither eviction deletes an archive file. Both are idempotent
|
// EvictWebhook never deletes an archive file. It is idempotent
|
||||||
// and are no-ops for a webhook or target with no engine state.
|
// and is a no-op for a webhook with no engine state.
|
||||||
type Archives interface {
|
type WebhookEvictor interface {
|
||||||
EvictWebhook(webhookID string)
|
EvictWebhook(webhookID string)
|
||||||
EvictTarget(targetID string)
|
|
||||||
Rename(targetID, webhookName, targetName string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// CircuitBreakers is how the handlers read a target's circuit
|
|
||||||
// breaker, so the webhook page and the event log can say that
|
|
||||||
// deliveries to the target are paused and until when. Like Archives,
|
|
||||||
// it keeps the handlers free of the engine's internals and is
|
|
||||||
// trivially faked in tests.
|
|
||||||
type CircuitBreakers interface {
|
|
||||||
StateAndCooldown(targetID string) (CircuitState, time.Duration)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// EngineParams are the fx dependencies for the delivery
|
// EngineParams are the fx dependencies for the delivery
|
||||||
@@ -159,10 +146,8 @@ type EngineParams struct {
|
|||||||
|
|
||||||
DB *database.Database
|
DB *database.Database
|
||||||
DBManager *database.WebhookDBManager
|
DBManager *database.WebhookDBManager
|
||||||
Globals *globals.Globals
|
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
SSRFGuard *Guard
|
SSRFGuard *Guard
|
||||||
Metrics *metrics.Set
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Engine processes queued deliveries in the background
|
// Engine processes queued deliveries in the background
|
||||||
@@ -182,27 +167,21 @@ type Engine struct {
|
|||||||
retryCh chan Task
|
retryCh chan Task
|
||||||
workers int
|
workers int
|
||||||
|
|
||||||
// version is the running build's version, the one the web UI
|
// mtr is the delivery metric set. Production wires the
|
||||||
// footer shows. userAgent puts it on every outbound request.
|
// process-wide one; a test can substitute a set registered on
|
||||||
version string
|
// a private registry so its assertions are not disturbed by
|
||||||
|
// deliveries other tests are making at the same time.
|
||||||
// mtr is the delivery metric set. Production wires the one
|
|
||||||
// registered on the registry /metrics serves; a test can
|
|
||||||
// substitute a set registered on a registry it holds, so it can
|
|
||||||
// gather what its own deliveries recorded.
|
|
||||||
mtr *metrics.Set
|
mtr *metrics.Set
|
||||||
|
|
||||||
// targets maps each target type to its implementation.
|
// targets maps each target type to its implementation.
|
||||||
targets map[database.TargetType]Target
|
targets map[database.TargetType]Target
|
||||||
|
|
||||||
// httpTarget and slackTarget are retained so StateAndCooldown
|
// httpTarget is retained so tests can reach the HTTP
|
||||||
// can read their circuit breakers, and so tests can reach the
|
// target's shared client and circuit breakers.
|
||||||
// HTTP target's shared client.
|
|
||||||
httpTarget *httpTarget
|
httpTarget *httpTarget
|
||||||
slackTarget *slackTarget
|
|
||||||
|
|
||||||
// dbTarget is retained so the engine can reach the archive
|
// dbTarget is retained so the engine can reach the archive
|
||||||
// writer registry for eviction, renames and the idle sweep.
|
// writer registry for webhook eviction and the idle sweep.
|
||||||
dbTarget *databaseTarget
|
dbTarget *databaseTarget
|
||||||
|
|
||||||
// inflight is the set of deliveries this engine currently owns.
|
// inflight is the set of deliveries this engine currently owns.
|
||||||
@@ -225,8 +204,7 @@ func New(
|
|||||||
deliveryCh: make(chan Task, deliveryChannelSize),
|
deliveryCh: make(chan Task, deliveryChannelSize),
|
||||||
retryCh: make(chan Task, retryChannelSize),
|
retryCh: make(chan Task, retryChannelSize),
|
||||||
workers: defaultWorkers,
|
workers: defaultWorkers,
|
||||||
version: params.Globals.Version,
|
mtr: metrics.Default(),
|
||||||
mtr: params.Metrics,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
e.initTargets(&http.Client{
|
e.initTargets(&http.Client{
|
||||||
@@ -271,67 +249,17 @@ func (e *Engine) Notify(tasks []Task) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EvictWebhook implements Archives. The cached archive writer of
|
// EvictWebhook implements WebhookEvictor. It releases the
|
||||||
// every database target of the webhook is dropped from the
|
// engine's per-webhook archiving state: the database target's
|
||||||
// registry and its file handle closed. The archive files
|
// cached archive writer is dropped from the registry and its
|
||||||
// themselves are left on disk — they are long-term storage the
|
// file handle closed. The archive file itself is left on disk
|
||||||
// operator owns.
|
// — it is long-term storage the operator owns.
|
||||||
func (e *Engine) EvictWebhook(webhookID string) {
|
func (e *Engine) EvictWebhook(webhookID string) {
|
||||||
if e.dbTarget == nil {
|
if e.dbTarget == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
e.dbTarget.evictWebhook(webhookID)
|
e.dbTarget.evict(webhookID)
|
||||||
}
|
|
||||||
|
|
||||||
// EvictTarget implements Archives. It is EvictWebhook for a single
|
|
||||||
// database target, and leaves the archive file on disk the same
|
|
||||||
// way.
|
|
||||||
func (e *Engine) EvictTarget(targetID string) {
|
|
||||||
if e.dbTarget == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
e.dbTarget.evict(targetID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rename implements Archives. It renames every one of a database
|
|
||||||
// target's archive files to ArchiveFileName(webhookName, targetName,
|
|
||||||
// targetID), each keeping the period in its name, under the lock the
|
|
||||||
// target's archive writes and the idle sweep take. It never replaces
|
|
||||||
// a file: if one already has a new name, the error is
|
|
||||||
// ErrArchiveNameTaken. The caller renames before it saves the new
|
|
||||||
// name: see databaseTarget.rename.
|
|
||||||
func (e *Engine) Rename(
|
|
||||||
targetID, webhookName, targetName string,
|
|
||||||
) error {
|
|
||||||
if e.dbTarget == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return e.dbTarget.rename(targetID, webhookName, targetName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// StateAndCooldown implements CircuitBreakers. It returns the state of
|
|
||||||
// the target's circuit breaker and, while the breaker is open, what is
|
|
||||||
// left of its cooldown; the cooldown is zero once that has passed and
|
|
||||||
// in any other state. A target with no breaker reads as closed with no
|
|
||||||
// cooldown, and reading never creates one.
|
|
||||||
func (e *Engine) StateAndCooldown(
|
|
||||||
targetID string,
|
|
||||||
) (CircuitState, time.Duration) {
|
|
||||||
for _, core := range []*httpCore{
|
|
||||||
e.httpTarget.httpCore, e.slackTarget.httpCore,
|
|
||||||
} {
|
|
||||||
val, ok := core.circuitBreakers.Load(targetID)
|
|
||||||
if ok {
|
|
||||||
cb, _ := val.(*CircuitBreaker)
|
|
||||||
|
|
||||||
return cb.StateAndCooldown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return CircuitClosed, 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScheduleRetry schedules a task to be re-enqueued onto the
|
// ScheduleRetry schedules a task to be re-enqueued onto the
|
||||||
@@ -372,13 +300,6 @@ func (e *Engine) ScheduleRetry(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// userAgent is the User-Agent header of every http and slack
|
|
||||||
// delivery request: the program name and the running build's
|
|
||||||
// version.
|
|
||||||
func (e *Engine) userAgent() string {
|
|
||||||
return "webhooker/" + e.version
|
|
||||||
}
|
|
||||||
|
|
||||||
// registerHooks wires the engine's start and stop into the fx
|
// registerHooks wires the engine's start and stop into the fx
|
||||||
// lifecycle. The start hook's context is deliberately ignored
|
// lifecycle. The start hook's context is deliberately ignored
|
||||||
// (see start for why the worker pool must not inherit it); the
|
// (see start for why the worker pool must not inherit it); the
|
||||||
@@ -445,8 +366,7 @@ func (e *Engine) start() {
|
|||||||
// Once the pool has drained it closes the archive writers, so a
|
// Once the pool has drained it closes the archive writers, so a
|
||||||
// clean stop leaves no archive -wal behind. Nothing else holds a
|
// clean stop leaves no archive -wal behind. Nothing else holds a
|
||||||
// writer for long by then: the archive sweeper stops before the
|
// writer for long by then: the archive sweeper stops before the
|
||||||
// engine, and deleting or renaming a webhook or target only closes
|
// engine, and deleting a webhook only closes one. If the pool did
|
||||||
// or moves one. If the pool did
|
|
||||||
// not drain in time, the writers are left open, as a kill would
|
// not drain in time, the writers are left open, as a kill would
|
||||||
// leave them. Closing them would wait for any write in progress,
|
// leave them. Closing them would wait for any write in progress,
|
||||||
// and a worker still running would then open new writers that
|
// and a worker still running would then open new writers that
|
||||||
@@ -733,9 +653,10 @@ func (e *Engine) recoverInFlight(ctx context.Context) {
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
// Opened even when its file is missing, so that a lost
|
if !e.dbManager.DBExists(webhookID) {
|
||||||
// database is reported at start, not when the webhook next
|
continue
|
||||||
// receives an event, which for a quiet webhook may be never.
|
}
|
||||||
|
|
||||||
e.recoverWebhookDeliveries(ctx, webhookID)
|
e.recoverWebhookDeliveries(ctx, webhookID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -743,24 +664,7 @@ func (e *Engine) recoverInFlight(ctx context.Context) {
|
|||||||
func (e *Engine) recoverWebhookDeliveries(
|
func (e *Engine) recoverWebhookDeliveries(
|
||||||
ctx context.Context, webhookID string,
|
ctx context.Context, webhookID string,
|
||||||
) {
|
) {
|
||||||
// The web interface is already serving, so the webhook may have
|
webhookDB, err := e.dbManager.GetDB(webhookID)
|
||||||
// been deleted since the list was read. Opening its database then
|
|
||||||
// would create the file again after the delete removed it.
|
|
||||||
stillExists := func() (bool, error) {
|
|
||||||
var count int64
|
|
||||||
|
|
||||||
err := e.database.DB().
|
|
||||||
Model(&database.Webhook{}).
|
|
||||||
Where("id = ?", webhookID).
|
|
||||||
Count(&count).Error
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("confirming webhook exists: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return count > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
webhookDB, err := e.dbManager.GetDBIf(webhookID, stillExists)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.log.Error(
|
e.log.Error(
|
||||||
"failed to get webhook database for recovery",
|
"failed to get webhook database for recovery",
|
||||||
@@ -771,10 +675,6 @@ func (e *Engine) recoverWebhookDeliveries(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if webhookDB == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
e.recoverPendingDeliveries(
|
e.recoverPendingDeliveries(
|
||||||
ctx, webhookDB, webhookID,
|
ctx, webhookDB, webhookID,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package delivery_test
|
package delivery_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -24,7 +23,6 @@ import (
|
|||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// iSetup holds common integration test dependencies.
|
// iSetup holds common integration test dependencies.
|
||||||
@@ -82,8 +80,7 @@ func iMainDB(t *testing.T) *gorm.DB {
|
|||||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||||
|
|
||||||
db, err := gorm.Open(
|
db, err := gorm.Open(
|
||||||
sqlite.Dialector{Conn: sqlDB},
|
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
||||||
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -358,14 +355,9 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) {
|
|||||||
|
|
||||||
s := newISetup(t)
|
s := newISetup(t)
|
||||||
|
|
||||||
var receivedBody string
|
|
||||||
|
|
||||||
ts := httptest.NewServer(
|
ts := httptest.NewServer(
|
||||||
http.HandlerFunc(
|
http.HandlerFunc(
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
body, _ := io.ReadAll(r.Body)
|
|
||||||
receivedBody = string(body)
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -405,8 +397,6 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) {
|
|||||||
context.TODO(), &task,
|
context.TODO(), &task,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Equal(t, event.Body, receivedBody)
|
|
||||||
|
|
||||||
iAssertStatus(t, s.WebhookDB, d.ID,
|
iAssertStatus(t, s.WebhookDB, d.ID,
|
||||||
database.DeliveryStatusDelivered,
|
database.DeliveryStatusDelivered,
|
||||||
)
|
)
|
||||||
@@ -453,14 +443,9 @@ func TestProcessRetryTask_LargeBody_FetchFromDB(
|
|||||||
|
|
||||||
s := newISetup(t)
|
s := newISetup(t)
|
||||||
|
|
||||||
var receivedBody string
|
|
||||||
|
|
||||||
ts := httptest.NewServer(
|
ts := httptest.NewServer(
|
||||||
http.HandlerFunc(
|
http.HandlerFunc(
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
body, _ := io.ReadAll(r.Body)
|
|
||||||
receivedBody = string(body)
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -497,8 +482,6 @@ func TestProcessRetryTask_LargeBody_FetchFromDB(
|
|||||||
context.TODO(), &task,
|
context.TODO(), &task,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Equal(t, largeBody, receivedBody)
|
|
||||||
|
|
||||||
iAssertStatus(t, s.WebhookDB, d.ID,
|
iAssertStatus(t, s.WebhookDB, d.ID,
|
||||||
database.DeliveryStatusDelivered,
|
database.DeliveryStatusDelivered,
|
||||||
)
|
)
|
||||||
@@ -1137,85 +1120,6 @@ func TestRecoverInFlight_WithPendingDeliveries(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRecoverInFlight_ReportsAMissingWebhookDatabase covers a webhook
|
|
||||||
// whose database file is gone, after a partial restore say. Restart
|
|
||||||
// recovery opens every webhook's database, so the empty one made in its
|
|
||||||
// place is reported at start, naming the file
|
|
||||||
// (https://git.eeqj.de/sneak/webhooker/issues/290).
|
|
||||||
func TestRecoverInFlight_ReportsAMissingWebhookDatabase(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
mainDB := iMainDB(t)
|
|
||||||
webhookID := uuid.New().String()
|
|
||||||
iCreateWebhook(t, mainDB, webhookID, "lost-database")
|
|
||||||
|
|
||||||
var logs bytes.Buffer
|
|
||||||
|
|
||||||
dbMgr := database.NewTestWebhookDBManagerWithLogger(
|
|
||||||
t.TempDir(), slog.New(slog.NewTextHandler(&logs, nil)),
|
|
||||||
)
|
|
||||||
t.Cleanup(func() { _ = dbMgr.CloseAll() })
|
|
||||||
|
|
||||||
engine := delivery.NewTestEngineWithDB(
|
|
||||||
database.NewTestDatabase(mainDB), dbMgr,
|
|
||||||
slog.New(slog.DiscardHandler),
|
|
||||||
&http.Client{Timeout: 5 * time.Second}, 1,
|
|
||||||
)
|
|
||||||
|
|
||||||
engine.ExportRecoverInFlight(context.Background())
|
|
||||||
|
|
||||||
assert.Contains(
|
|
||||||
t, logs.String(),
|
|
||||||
`level=WARN msg="created a new, empty database" webhook_id=`+
|
|
||||||
webhookID+" path="+dbMgr.DBPath(webhookID),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRecoverInFlight_SkipsAWebhookDeletedAfterTheListIsRead covers a
|
|
||||||
// webhook deleted from the web interface while restart recovery runs.
|
|
||||||
// Its database file is gone, and recovery must not create it again.
|
|
||||||
func TestRecoverInFlight_SkipsAWebhookDeletedAfterTheListIsRead(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
mainDB := iMainDB(t)
|
|
||||||
webhookID := uuid.New().String()
|
|
||||||
iCreateWebhook(t, mainDB, webhookID, "deleted-during-recovery")
|
|
||||||
|
|
||||||
// The first query to return is recovery's read of the list of
|
|
||||||
// webhooks. Deleting the webhook right after it puts the delete
|
|
||||||
// between that read and the opening of the webhook's database.
|
|
||||||
deleted := false
|
|
||||||
|
|
||||||
require.NoError(t, mainDB.Callback().Query().After("gorm:query").
|
|
||||||
Register("delete-after-list", func(*gorm.DB) {
|
|
||||||
if deleted {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
deleted = true
|
|
||||||
|
|
||||||
require.NoError(t, mainDB.Delete(
|
|
||||||
&database.Webhook{}, "id = ?", webhookID,
|
|
||||||
).Error)
|
|
||||||
}))
|
|
||||||
|
|
||||||
dbMgr := database.NewTestWebhookDBManager(t.TempDir())
|
|
||||||
t.Cleanup(func() { _ = dbMgr.CloseAll() })
|
|
||||||
|
|
||||||
engine := delivery.NewTestEngineWithDB(
|
|
||||||
database.NewTestDatabase(mainDB), dbMgr,
|
|
||||||
slog.New(slog.DiscardHandler),
|
|
||||||
&http.Client{Timeout: 5 * time.Second}, 1,
|
|
||||||
)
|
|
||||||
|
|
||||||
engine.ExportRecoverInFlight(context.Background())
|
|
||||||
|
|
||||||
require.True(t, deleted)
|
|
||||||
assert.False(t, dbMgr.DBExists(webhookID))
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- HTTP Config with custom headers ---
|
// --- HTTP Config with custom headers ---
|
||||||
|
|
||||||
func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
|
func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
|
||||||
@@ -1507,32 +1411,6 @@ func TestDeliverHTTP_InvalidConfig(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDeliverHTTP_InvalidConfigUnrecordedStaysPending: a delivery is
|
|
||||||
// failed for an invalid config only once the reason is recorded.
|
|
||||||
// Unrecorded, it stays pending, where the sweep finds it again.
|
|
||||||
func TestDeliverHTTP_InvalidConfigUnrecordedStaysPending(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db := testWebhookDB(t)
|
|
||||||
e := testEngine(t, 1)
|
|
||||||
|
|
||||||
event, del := iSeedEventAndDelivery(
|
|
||||||
t, db, `{"config":"invalid"}`, "",
|
|
||||||
)
|
|
||||||
|
|
||||||
task, d := iHTTPTaskAndDelivery(
|
|
||||||
event, del, "bad-config", `not-json`, 0, 1,
|
|
||||||
)
|
|
||||||
|
|
||||||
require.NoError(t, db.Exec("drop table delivery_results").Error)
|
|
||||||
|
|
||||||
e.ExportDeliverHTTP(context.TODO(), db, d, task)
|
|
||||||
|
|
||||||
iAssertStatus(t, db, del.ID,
|
|
||||||
database.DeliveryStatusPending,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Notify batching ---
|
// --- Notify batching ---
|
||||||
|
|
||||||
func TestNotify_MultipleTasks(t *testing.T) {
|
func TestNotify_MultipleTasks(t *testing.T) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package delivery_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -9,7 +10,6 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
)
|
)
|
||||||
@@ -272,35 +272,22 @@ func TestEngine_StopHookHonoursStopTimeout(t *testing.T) {
|
|||||||
requireStopHookExpires(t, lc.hooks[0], "delivery engine")
|
requireStopHookExpires(t, lc.hooks[0], "delivery engine")
|
||||||
}
|
}
|
||||||
|
|
||||||
// deliverToArchive gives the setup's webhook a database target,
|
// deliverToArchive runs one delivery to a database target through
|
||||||
// runs one delivery to it through the running engine, and returns
|
// the running engine and returns the webhook's archive file path.
|
||||||
// the target's ID and archive file path. The archive writer holds
|
// The archive writer holds the file open afterwards.
|
||||||
// the file open afterwards.
|
func deliverToArchive(t *testing.T, s iSetup) string {
|
||||||
func deliverToArchive(t *testing.T, s iSetup) (string, string) {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
iCreateWebhook(t, s.MainDB, s.WebhookID, "hook")
|
|
||||||
|
|
||||||
tgt := &database.Target{
|
|
||||||
WebhookID: s.WebhookID,
|
|
||||||
Name: "archive",
|
|
||||||
Type: database.TargetTypeDatabase,
|
|
||||||
}
|
|
||||||
require.NoError(
|
|
||||||
t, s.MainDB.Omit(clause.Associations).Create(tgt).Error,
|
|
||||||
)
|
|
||||||
|
|
||||||
deliveryID, task := seedLogTask(t, s)
|
deliveryID, task := seedLogTask(t, s)
|
||||||
task.TargetID = tgt.ID
|
|
||||||
task.TargetType = database.TargetTypeDatabase
|
task.TargetType = database.TargetTypeDatabase
|
||||||
|
|
||||||
s.Engine.Notify([]delivery.Task{task})
|
s.Engine.Notify([]delivery.Task{task})
|
||||||
|
|
||||||
iWaitForDelivered(t, s.WebhookDB, deliveryID)
|
iWaitForDelivered(t, s.WebhookDB, deliveryID)
|
||||||
|
|
||||||
return tgt.ID, filepath.Join(
|
return filepath.Join(
|
||||||
filepath.Dir(s.DBMgr.DBPath(s.WebhookID)),
|
filepath.Dir(s.DBMgr.DBPath(s.WebhookID)),
|
||||||
"archive-hook-archive-"+tgt.ID+".db",
|
fmt.Sprintf("archive-%s.db", s.WebhookID),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +304,7 @@ func TestEngine_StopHookClosesArchives(t *testing.T) {
|
|||||||
|
|
||||||
lc := startEngineViaHook(t, s.Engine)
|
lc := startEngineViaHook(t, s.Engine)
|
||||||
|
|
||||||
_, path := deliverToArchive(t, s)
|
path := deliverToArchive(t, s)
|
||||||
require.FileExists(
|
require.FileExists(
|
||||||
t, path+"-wal",
|
t, path+"-wal",
|
||||||
"an open archive should have a -wal for the stop to remove",
|
"an open archive should have a -wal for the stop to remove",
|
||||||
@@ -351,7 +338,7 @@ func TestEngine_StopHookTimeoutLeavesArchivesOpen(t *testing.T) {
|
|||||||
|
|
||||||
lc := startEngineViaHook(t, s.Engine)
|
lc := startEngineViaHook(t, s.Engine)
|
||||||
|
|
||||||
targetID, _ := deliverToArchive(t, s)
|
deliverToArchive(t, s)
|
||||||
|
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
|
|
||||||
@@ -365,7 +352,7 @@ func TestEngine_StopHookTimeoutLeavesArchivesOpen(t *testing.T) {
|
|||||||
requireStopHookExpires(t, lc.hooks[0], "delivery engine")
|
requireStopHookExpires(t, lc.hooks[0], "delivery engine")
|
||||||
|
|
||||||
require.True(
|
require.True(
|
||||||
t, s.Engine.ExportArchiveHandleOpen(targetID),
|
t, s.Engine.ExportArchiveHandleOpen(s.WebhookID),
|
||||||
"a stop that timed out must not close archive writers",
|
"a stop that timed out must not close archive writers",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -26,7 +25,6 @@ import (
|
|||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
|
||||||
"sneak.berlin/go/webhooker/internal/metrics"
|
"sneak.berlin/go/webhooker/internal/metrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,8 +49,7 @@ func testWebhookDB(t *testing.T) *gorm.DB {
|
|||||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||||
|
|
||||||
db, err := gorm.Open(
|
db, err := gorm.Open(
|
||||||
sqlite.Dialector{Conn: sqlDB},
|
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
||||||
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -354,15 +351,23 @@ func TestDeliverDatabase_ImmediateSuccess(
|
|||||||
|
|
||||||
db := testWebhookDB(t)
|
db := testWebhookDB(t)
|
||||||
|
|
||||||
// The database target archives for real, so the engine needs
|
// The database target archives for real now, so the engine
|
||||||
// the target in the main database and a data directory.
|
// needs a webhook DB manager to locate the data directory.
|
||||||
env := setupArchiveTest(t)
|
e := delivery.NewTestEngineWithDB(
|
||||||
tgt := env.seedDatabaseTarget(t, "")
|
nil,
|
||||||
|
database.NewTestWebhookDBManager(t.TempDir()),
|
||||||
|
slog.New(slog.NewTextHandler(
|
||||||
|
os.Stderr,
|
||||||
|
&slog.HandlerOptions{Level: slog.LevelDebug},
|
||||||
|
)),
|
||||||
|
&http.Client{Timeout: 5 * time.Second},
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
event := seedEvent(t, db, `{"db":"target"}`)
|
event := seedEvent(t, db, `{"db":"target"}`)
|
||||||
d := seedDatabaseTargetDelivery(t, db, event, tgt)
|
d := seedDatabaseTargetDelivery(t, db, event, "")
|
||||||
|
|
||||||
env.eng.ExportDeliverDatabase(db, d)
|
e.ExportDeliverDatabase(db, d)
|
||||||
|
|
||||||
var updated database.Delivery
|
var updated database.Delivery
|
||||||
|
|
||||||
@@ -1018,62 +1023,6 @@ func TestGetCircuitBreaker_CreatesOnDemand(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStateAndCooldown_ReadsHTTPAndSlackBreakers proves the engine
|
|
||||||
// reads the state of an http or a slack target's circuit breaker, with
|
|
||||||
// what is left of its cooldown while it is open, and no cooldown while
|
|
||||||
// it is half-open, once it closes, or for a target with no breaker.
|
|
||||||
func TestStateAndCooldown_ReadsHTTPAndSlackBreakers(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
e := testEngine(t, 1)
|
|
||||||
|
|
||||||
httpID := uuid.New().String()
|
|
||||||
slackID := uuid.New().String()
|
|
||||||
|
|
||||||
state, cooldown := e.StateAndCooldown(httpID)
|
|
||||||
assert.Equal(t, delivery.CircuitClosed, state, "no breaker")
|
|
||||||
assert.Zero(t, cooldown, "no breaker")
|
|
||||||
|
|
||||||
httpCB := delivery.NewTestCircuitBreaker(1, time.Hour)
|
|
||||||
e.ExportSetCircuitBreaker(httpID, httpCB)
|
|
||||||
|
|
||||||
slackCB := delivery.NewTestCircuitBreaker(1, time.Hour)
|
|
||||||
e.ExportSetSlackCircuitBreaker(slackID, slackCB)
|
|
||||||
|
|
||||||
httpCB.RecordFailure()
|
|
||||||
slackCB.RecordFailure()
|
|
||||||
|
|
||||||
for _, id := range []string{httpID, slackID} {
|
|
||||||
state, cooldown := e.StateAndCooldown(id)
|
|
||||||
assert.Equal(t, delivery.CircuitOpen, state)
|
|
||||||
assert.Greater(t, cooldown, 59*time.Minute)
|
|
||||||
assert.LessOrEqual(t, cooldown, time.Hour)
|
|
||||||
}
|
|
||||||
|
|
||||||
httpCB.RecordSuccess()
|
|
||||||
slackCB.RecordSuccess()
|
|
||||||
|
|
||||||
for _, id := range []string{httpID, slackID} {
|
|
||||||
state, cooldown := e.StateAndCooldown(id)
|
|
||||||
assert.Equal(t, delivery.CircuitClosed, state, "closed")
|
|
||||||
assert.Zero(t, cooldown, "closed")
|
|
||||||
}
|
|
||||||
|
|
||||||
// A breaker with no cooldown goes half-open on the first Allow
|
|
||||||
// after it trips, letting that one delivery through to test the
|
|
||||||
// target.
|
|
||||||
halfOpenID := uuid.New().String()
|
|
||||||
halfOpenCB := delivery.NewTestCircuitBreaker(1, 0)
|
|
||||||
e.ExportSetCircuitBreaker(halfOpenID, halfOpenCB)
|
|
||||||
|
|
||||||
halfOpenCB.RecordFailure()
|
|
||||||
require.True(t, halfOpenCB.Allow())
|
|
||||||
|
|
||||||
state, cooldown = e.StateAndCooldown(halfOpenID)
|
|
||||||
assert.Equal(t, delivery.CircuitHalfOpen, state)
|
|
||||||
assert.Zero(t, cooldown, "half-open")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseHTTPConfig_Valid(t *testing.T) {
|
func TestParseHTTPConfig_Valid(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1115,21 +1064,6 @@ func TestParseHTTPConfig_MissingURL(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseHTTPConfig_Undecodable(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
e := testEngine(t, 1)
|
|
||||||
|
|
||||||
_, err := e.ExportParseHTTPConfig(
|
|
||||||
`{"url":"https://example.com/hook","timeout":"soon"}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Error(t, err,
|
|
||||||
"config that does not decode should return error, "+
|
|
||||||
"even when the part that did names a URL",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScheduleRetry_SendsToRetryChannel(
|
func TestScheduleRetry_SendsToRetryChannel(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
@@ -1313,33 +1247,11 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
|
|||||||
testContentType,
|
testContentType,
|
||||||
receivedHeaders.Get("Content-Type"),
|
receivedHeaders.Get("Content-Type"),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
// A response that ends before the length it announced is an error, not
|
assert.Equal(t,
|
||||||
// a short body.
|
"webhooker/1.0",
|
||||||
func TestDoHTTPRequest_CutShortResponseIsAnError(t *testing.T) {
|
receivedHeaders.Get("User-Agent"),
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ts := httptest.NewServer(
|
|
||||||
http.HandlerFunc(
|
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.Header().Set("Content-Length", "100")
|
|
||||||
_, _ = w.Write([]byte("cut short"))
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
defer ts.Close()
|
|
||||||
|
|
||||||
e := testEngine(t, 1)
|
|
||||||
|
|
||||||
_, body, _, err := e.ExportDoHTTPRequest(
|
|
||||||
context.TODO(),
|
|
||||||
&delivery.HTTPTargetConfig{URL: ts.URL},
|
|
||||||
&database.Event{},
|
|
||||||
)
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, io.ErrUnexpectedEOF)
|
|
||||||
assert.Empty(t, body)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The event's stored inbound headers carry the same Content-Type the
|
// The event's stored inbound headers carry the same Content-Type the
|
||||||
@@ -1408,7 +1320,6 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) {
|
|||||||
ContentType: tc.event,
|
ContentType: tc.event,
|
||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
"webhooker/dev",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Equal(t,
|
assert.Equal(t,
|
||||||
@@ -1418,34 +1329,6 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stored inbound headers that do not decode forward nothing, not the
|
|
||||||
// part of them that happened to decode.
|
|
||||||
func TestApplyRequestHeaders_UndecodableInboundForwardsNothing(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(
|
|
||||||
context.Background(),
|
|
||||||
http.MethodPost,
|
|
||||||
"https://target.example.com/hook",
|
|
||||||
http.NoBody,
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
names := delivery.ExportApplyRequestHeaders(
|
|
||||||
req,
|
|
||||||
&database.Event{
|
|
||||||
Headers: `{"X-Custom":["value1"],"X-Broken":"not a list"}`,
|
|
||||||
},
|
|
||||||
&delivery.HTTPTargetConfig{},
|
|
||||||
"webhooker/dev",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Empty(t, names)
|
|
||||||
assert.Empty(t, req.Header.Get("X-Custom"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessDelivery_RoutesToCorrectHandler(
|
func TestProcessDelivery_RoutesToCorrectHandler(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
@@ -1453,27 +1336,32 @@ func TestProcessDelivery_RoutesToCorrectHandler(
|
|||||||
|
|
||||||
db := testWebhookDB(t)
|
db := testWebhookDB(t)
|
||||||
|
|
||||||
// The database target archives for real, so the engine needs
|
// The database target archives for real now, so the engine
|
||||||
// the target in the main database and a data directory.
|
// needs a webhook DB manager to locate the data directory.
|
||||||
env := setupArchiveTest(t)
|
e := delivery.NewTestEngineWithDB(
|
||||||
archive := env.seedDatabaseTarget(t, "")
|
nil,
|
||||||
|
database.NewTestWebhookDBManager(t.TempDir()),
|
||||||
|
slog.New(slog.NewTextHandler(
|
||||||
|
os.Stderr,
|
||||||
|
&slog.HandlerOptions{Level: slog.LevelDebug},
|
||||||
|
)),
|
||||||
|
&http.Client{Timeout: 5 * time.Second},
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
targetType database.TargetType
|
targetType database.TargetType
|
||||||
targetID string
|
|
||||||
wantStatus database.DeliveryStatus
|
wantStatus database.DeliveryStatus
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"database target",
|
"database target",
|
||||||
database.TargetTypeDatabase,
|
database.TargetTypeDatabase,
|
||||||
archive.ID,
|
|
||||||
database.DeliveryStatusDelivered,
|
database.DeliveryStatusDelivered,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"log target",
|
"log target",
|
||||||
database.TargetTypeLog,
|
database.TargetTypeLog,
|
||||||
uuid.New().String(),
|
|
||||||
database.DeliveryStatusDelivered,
|
database.DeliveryStatusDelivered,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1483,7 +1371,7 @@ func TestProcessDelivery_RoutesToCorrectHandler(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
runRoutingSubtest(
|
runRoutingSubtest(
|
||||||
t, db, env.eng, tt.targetType, tt.targetID,
|
t, db, e, tt.targetType,
|
||||||
tt.wantStatus,
|
tt.wantStatus,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -1495,7 +1383,6 @@ func runRoutingSubtest(
|
|||||||
db *gorm.DB,
|
db *gorm.DB,
|
||||||
e *delivery.Engine,
|
e *delivery.Engine,
|
||||||
targetType database.TargetType,
|
targetType database.TargetType,
|
||||||
targetID string,
|
|
||||||
wantStatus database.DeliveryStatus,
|
wantStatus database.DeliveryStatus,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -1503,7 +1390,8 @@ func runRoutingSubtest(
|
|||||||
event := seedEvent(t, db, `{"routing":"test"}`)
|
event := seedEvent(t, db, `{"routing":"test"}`)
|
||||||
|
|
||||||
dlv := seedDelivery(
|
dlv := seedDelivery(
|
||||||
t, db, event.ID, targetID,
|
t, db, event.ID,
|
||||||
|
uuid.New().String(),
|
||||||
database.DeliveryStatusPending,
|
database.DeliveryStatusPending,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
@@ -41,6 +40,11 @@ const (
|
|||||||
ExportPendingSweepMinAge = pendingSweepMinAge
|
ExportPendingSweepMinAge = pendingSweepMinAge
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ExportIsBlockedIP exposes isBlockedIP for testing.
|
||||||
|
func ExportIsBlockedIP(ip net.IP) bool {
|
||||||
|
return isBlockedIP(ip)
|
||||||
|
}
|
||||||
|
|
||||||
// NewTestGuard builds an SSRF Guard from an explicit egress
|
// NewTestGuard builds an SSRF Guard from an explicit egress
|
||||||
// allowlist, without going through config. Passing no prefixes
|
// allowlist, without going through config. Passing no prefixes
|
||||||
// yields the default guard, which blocks every private/reserved
|
// yields the default guard, which blocks every private/reserved
|
||||||
@@ -66,11 +70,6 @@ func ExportBlockedNetworks() []*net.IPNet {
|
|||||||
return blockedNetworks
|
return blockedNetworks
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportBlockedPublicNetworks exposes blockedPublicNetworks.
|
|
||||||
func ExportBlockedPublicNetworks() []*net.IPNet {
|
|
||||||
return blockedPublicNetworks
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExportIsForwardableHeader exposes isForwardableHeader.
|
// ExportIsForwardableHeader exposes isForwardableHeader.
|
||||||
func ExportIsForwardableHeader(name string) bool {
|
func ExportIsForwardableHeader(name string) bool {
|
||||||
return isForwardableHeader(name)
|
return isForwardableHeader(name)
|
||||||
@@ -83,9 +82,8 @@ func ExportApplyRequestHeaders(
|
|||||||
req *http.Request,
|
req *http.Request,
|
||||||
event *database.Event,
|
event *database.Event,
|
||||||
cfg *HTTPTargetConfig,
|
cfg *HTTPTargetConfig,
|
||||||
userAgent string,
|
|
||||||
) []string {
|
) []string {
|
||||||
return applyRequestHeaders(req, event, cfg, userAgent)
|
return applyRequestHeaders(req, event, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportTruncate exposes truncate for testing.
|
// ExportTruncate exposes truncate for testing.
|
||||||
@@ -212,14 +210,6 @@ func (e *Engine) ExportSetCircuitBreaker(
|
|||||||
e.httpTarget.circuitBreakers.Store(targetID, cb)
|
e.httpTarget.circuitBreakers.Store(targetID, cb)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportSetSlackCircuitBreaker is ExportSetCircuitBreaker for the
|
|
||||||
// slack target.
|
|
||||||
func (e *Engine) ExportSetSlackCircuitBreaker(
|
|
||||||
targetID string, cb *CircuitBreaker,
|
|
||||||
) {
|
|
||||||
e.slackTarget.circuitBreakers.Store(targetID, cb)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExportParseHTTPConfig exposes parseHTTPConfig.
|
// ExportParseHTTPConfig exposes parseHTTPConfig.
|
||||||
func (e *Engine) ExportParseHTTPConfig(
|
func (e *Engine) ExportParseHTTPConfig(
|
||||||
configJSON string,
|
configJSON string,
|
||||||
@@ -409,7 +399,7 @@ func NewTestEngine(
|
|||||||
deliveryCh: make(chan Task, deliveryChannelSize),
|
deliveryCh: make(chan Task, deliveryChannelSize),
|
||||||
retryCh: make(chan Task, retryChannelSize),
|
retryCh: make(chan Task, retryChannelSize),
|
||||||
workers: workers,
|
workers: workers,
|
||||||
mtr: metrics.New(prometheus.NewRegistry()),
|
mtr: metrics.Default(),
|
||||||
}
|
}
|
||||||
e.initTargets(client)
|
e.initTargets(client)
|
||||||
|
|
||||||
@@ -424,7 +414,7 @@ func NewTestEngineSmallRetry(
|
|||||||
e := &Engine{
|
e := &Engine{
|
||||||
log: log,
|
log: log,
|
||||||
retryCh: make(chan Task, 1),
|
retryCh: make(chan Task, 1),
|
||||||
mtr: metrics.New(prometheus.NewRegistry()),
|
mtr: metrics.Default(),
|
||||||
}
|
}
|
||||||
e.initTargets(nil)
|
e.initTargets(nil)
|
||||||
|
|
||||||
@@ -447,7 +437,7 @@ func NewTestEngineWithDB(
|
|||||||
deliveryCh: make(chan Task, deliveryChannelSize),
|
deliveryCh: make(chan Task, deliveryChannelSize),
|
||||||
retryCh: make(chan Task, retryChannelSize),
|
retryCh: make(chan Task, retryChannelSize),
|
||||||
workers: workers,
|
workers: workers,
|
||||||
mtr: metrics.New(prometheus.NewRegistry()),
|
mtr: metrics.Default(),
|
||||||
}
|
}
|
||||||
e.initTargets(client)
|
e.initTargets(client)
|
||||||
|
|
||||||
@@ -455,7 +445,8 @@ func NewTestEngineWithDB(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ExportSetMetrics substitutes the engine's metric set, so a test can
|
// ExportSetMetrics substitutes the engine's metric set, so a test can
|
||||||
// assert on collectors registered on a registry it holds.
|
// assert on collectors registered on a private registry instead of
|
||||||
|
// the process-wide ones every other test is also moving.
|
||||||
func (e *Engine) ExportSetMetrics(mtr *metrics.Set) {
|
func (e *Engine) ExportSetMetrics(mtr *metrics.Set) {
|
||||||
e.mtr = mtr
|
e.mtr = mtr
|
||||||
}
|
}
|
||||||
@@ -482,7 +473,7 @@ func NewTestCircuitBreaker(
|
|||||||
type ExportArchivedEvent = archivedEvent
|
type ExportArchivedEvent = archivedEvent
|
||||||
|
|
||||||
// ExportArchiveWriter wraps an archiveWriter so black-box tests
|
// ExportArchiveWriter wraps an archiveWriter so black-box tests
|
||||||
// can exercise the archive file mechanics.
|
// can exercise the per-webhook archive file mechanics.
|
||||||
type ExportArchiveWriter struct {
|
type ExportArchiveWriter struct {
|
||||||
w *archiveWriter
|
w *archiveWriter
|
||||||
}
|
}
|
||||||
@@ -501,38 +492,23 @@ func NewExportArchiveWriter(
|
|||||||
return &ExportArchiveWriter{w: w}
|
return &ExportArchiveWriter{w: w}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write archives a row through the writer, into the file named
|
// Write archives a row through the writer.
|
||||||
// without a period.
|
|
||||||
func (e *ExportArchiveWriter) Write(
|
func (e *ExportArchiveWriter) Write(
|
||||||
row ExportArchivedEvent, expiry time.Duration,
|
row ExportArchivedEvent, expiry time.Duration,
|
||||||
) error {
|
) error {
|
||||||
return e.w.write(row, expiry, "")
|
return e.w.write(row, expiry)
|
||||||
}
|
|
||||||
|
|
||||||
// WritePeriod archives a row through the writer, into the file for
|
|
||||||
// period.
|
|
||||||
func (e *ExportArchiveWriter) WritePeriod(
|
|
||||||
row ExportArchivedEvent, expiry time.Duration, period string,
|
|
||||||
) error {
|
|
||||||
return e.w.write(row, expiry, period)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open opens the archive file, pruning when expiry is positive.
|
// Open opens the archive file, pruning when expiry is positive.
|
||||||
func (e *ExportArchiveWriter) Open(expiry time.Duration) error {
|
func (e *ExportArchiveWriter) Open(expiry time.Duration) error {
|
||||||
return e.w.open(e.w.path, expiry)
|
return e.w.open(expiry)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reopen closes and reopens the archive file.
|
// Reopen closes and reopens the archive file.
|
||||||
func (e *ExportArchiveWriter) Reopen(
|
func (e *ExportArchiveWriter) Reopen(
|
||||||
expiry time.Duration,
|
expiry time.Duration,
|
||||||
) error {
|
) error {
|
||||||
return e.w.reopen(e.w.path, expiry)
|
return e.w.reopen(expiry)
|
||||||
}
|
|
||||||
|
|
||||||
// SetNow replaces the clock the writer measures its reopen
|
|
||||||
// debounce on.
|
|
||||||
func (e *ExportArchiveWriter) SetNow(now func() time.Time) {
|
|
||||||
e.w.now = now
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reopens reports how many times the file has been opened.
|
// Reopens reports how many times the file has been opened.
|
||||||
@@ -556,7 +532,7 @@ func (e *ExportArchiveWriter) Path() string {
|
|||||||
func (e *ExportArchiveWriter) OpenExisting(
|
func (e *ExportArchiveWriter) OpenExisting(
|
||||||
expiry time.Duration,
|
expiry time.Duration,
|
||||||
) error {
|
) error {
|
||||||
return e.w.openMode(e.w.path, archiveModeExisting, expiry)
|
return e.w.openMode(archiveModeExisting, expiry)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SweepExpired runs an idle sweep of the archive.
|
// SweepExpired runs an idle sweep of the archive.
|
||||||
@@ -572,12 +548,6 @@ func (e *ExportArchiveWriter) Evict() {
|
|||||||
e.w.evict()
|
e.w.evict()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename gives the archive file a new name in the same directory,
|
|
||||||
// as a rename of the webhook or target does.
|
|
||||||
func (e *ExportArchiveWriter) Rename(name string) error {
|
|
||||||
return e.w.rename(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleOpen reports whether the writer currently holds an open
|
// HandleOpen reports whether the writer currently holds an open
|
||||||
// archive handle.
|
// archive handle.
|
||||||
func (e *ExportArchiveWriter) HandleOpen() bool {
|
func (e *ExportArchiveWriter) HandleOpen() bool {
|
||||||
@@ -597,16 +567,16 @@ func (e *ExportArchiveWriter) Same(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ExportArchiveWriterFor returns the archive writer the registry
|
// ExportArchiveWriterFor returns the archive writer the registry
|
||||||
// currently caches for a database target, or nil when none is
|
// currently caches for a webhook, or nil when none is cached. It
|
||||||
// cached. It never creates one, so a test can hold a reference to
|
// never creates one, so a test can hold a reference to the very
|
||||||
// the very writer an eviction is about to detach.
|
// writer an eviction is about to detach.
|
||||||
func (e *Engine) ExportArchiveWriterFor(
|
func (e *Engine) ExportArchiveWriterFor(
|
||||||
targetID string,
|
webhookID string,
|
||||||
) *ExportArchiveWriter {
|
) *ExportArchiveWriter {
|
||||||
e.dbTarget.mu.Lock()
|
e.dbTarget.mu.Lock()
|
||||||
defer e.dbTarget.mu.Unlock()
|
defer e.dbTarget.mu.Unlock()
|
||||||
|
|
||||||
w, ok := e.dbTarget.writers[targetID]
|
w, ok := e.dbTarget.writers[webhookID]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -615,26 +585,26 @@ func (e *Engine) ExportArchiveWriterFor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ExportHasArchiveWriter reports whether the database target
|
// ExportHasArchiveWriter reports whether the database target
|
||||||
// type currently caches an archive writer for a target.
|
// currently caches an archive writer for a webhook.
|
||||||
func (e *Engine) ExportHasArchiveWriter(
|
func (e *Engine) ExportHasArchiveWriter(
|
||||||
targetID string,
|
webhookID string,
|
||||||
) bool {
|
) bool {
|
||||||
e.dbTarget.mu.Lock()
|
e.dbTarget.mu.Lock()
|
||||||
defer e.dbTarget.mu.Unlock()
|
defer e.dbTarget.mu.Unlock()
|
||||||
|
|
||||||
_, ok := e.dbTarget.writers[targetID]
|
_, ok := e.dbTarget.writers[webhookID]
|
||||||
|
|
||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportArchiveHandleOpen reports whether the cached archive
|
// ExportArchiveHandleOpen reports whether the cached archive
|
||||||
// writer for a target holds an open database handle. It
|
// writer for a webhook holds an open database handle. It
|
||||||
// returns false when no writer is cached.
|
// returns false when no writer is cached.
|
||||||
func (e *Engine) ExportArchiveHandleOpen(
|
func (e *Engine) ExportArchiveHandleOpen(
|
||||||
targetID string,
|
webhookID string,
|
||||||
) bool {
|
) bool {
|
||||||
e.dbTarget.mu.Lock()
|
e.dbTarget.mu.Lock()
|
||||||
w, ok := e.dbTarget.writers[targetID]
|
w, ok := e.dbTarget.writers[webhookID]
|
||||||
e.dbTarget.mu.Unlock()
|
e.dbTarget.mu.Unlock()
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -648,12 +618,12 @@ func (e *Engine) ExportArchiveHandleOpen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ExportEnsureArchiveWriter creates (if needed) and returns the
|
// ExportEnsureArchiveWriter creates (if needed) and returns the
|
||||||
// archive file path of the cached writer for a target, so a
|
// archive file path of the cached writer for a webhook, so a
|
||||||
// test can prime the registry the way a delivery would.
|
// test can prime the registry the way a delivery would.
|
||||||
func (e *Engine) ExportEnsureArchiveWriter(
|
func (e *Engine) ExportEnsureArchiveWriter(
|
||||||
targetID string,
|
webhookID string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
w, err := e.dbTarget.writerFor(targetID)
|
w, err := e.dbTarget.writerFor(webhookID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -661,14 +631,14 @@ func (e *Engine) ExportEnsureArchiveWriter(
|
|||||||
return w.path, nil
|
return w.path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportSweepWriterFor takes a target's registry writer exactly
|
// ExportSweepWriterFor takes a webhook's registry writer exactly
|
||||||
// as the idle sweep does, reporting whether the sweep had to
|
// as the idle sweep does, reporting whether the sweep had to
|
||||||
// create the entry. It lets a test drive the registry through the
|
// create the entry. It lets a test drive the registry through the
|
||||||
// sweep's own entry point instead of choreographing goroutines.
|
// sweep's own entry point instead of choreographing goroutines.
|
||||||
func (e *Engine) ExportSweepWriterFor(
|
func (e *Engine) ExportSweepWriterFor(
|
||||||
targetID string,
|
webhookID string,
|
||||||
) (*ExportArchiveWriter, bool, error) {
|
) (*ExportArchiveWriter, bool, error) {
|
||||||
w, created, err := e.dbTarget.sweepWriterFor(targetID)
|
w, created, err := e.dbTarget.sweepWriterFor(webhookID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
@@ -679,9 +649,9 @@ func (e *Engine) ExportSweepWriterFor(
|
|||||||
// ExportReleaseSweepWriter releases a sweep-created registry entry
|
// ExportReleaseSweepWriter releases a sweep-created registry entry
|
||||||
// exactly as a finished sweep does.
|
// exactly as a finished sweep does.
|
||||||
func (e *Engine) ExportReleaseSweepWriter(
|
func (e *Engine) ExportReleaseSweepWriter(
|
||||||
targetID string, w *ExportArchiveWriter,
|
webhookID string, w *ExportArchiveWriter,
|
||||||
) {
|
) {
|
||||||
e.dbTarget.releaseSweepWriter(targetID, w.w)
|
e.dbTarget.releaseSweepWriter(webhookID, w.w)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTestArchiveSweeper builds an ArchiveSweeper backed by the
|
// NewTestArchiveSweeper builds an ArchiveSweeper backed by the
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// mIsolate gives the setup's engine a metric set registered on a
|
// mIsolate gives the setup's engine a metric set registered on a
|
||||||
// registry this test holds, so its exact assertions can gather from
|
// private registry. The process-wide collectors are moved by every
|
||||||
// it.
|
// other delivery test running in parallel, so exact assertions are
|
||||||
|
// only possible against a registry this test owns.
|
||||||
func mIsolate(
|
func mIsolate(
|
||||||
t *testing.T, s iSetup,
|
t *testing.T, s iSetup,
|
||||||
) *prometheus.Registry {
|
) *prometheus.Registry {
|
||||||
|
|||||||
@@ -376,97 +376,3 @@ func TestFailedResultWriteLeavesDeliveryRecoverable(
|
|||||||
database.DeliveryStatusPending,
|
database.DeliveryStatusPending,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable is the same
|
|
||||||
// rule for a target with retries: whatever the receiver answered, the
|
|
||||||
// delivery stays pending and no retry is scheduled. The circuit breaker
|
|
||||||
// still learns the answer, because it describes the target's health,
|
|
||||||
// not the database's.
|
|
||||||
func TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// The "send succeeded" case starts with the breaker tripped open,
|
|
||||||
// so the delivery goes out as its probe and only a recorded
|
|
||||||
// success closes it again.
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
answer int
|
|
||||||
tripped bool
|
|
||||||
wantBreaker delivery.CircuitState
|
|
||||||
}{
|
|
||||||
{"send succeeded", http.StatusOK, true, delivery.CircuitClosed},
|
|
||||||
{"send failed", http.StatusBadGateway, false, delivery.CircuitOpen},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s := newISetup(t)
|
|
||||||
targetID := uuid.New().String()
|
|
||||||
|
|
||||||
ts := httptest.NewServer(http.HandlerFunc(
|
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(tc.answer)
|
|
||||||
},
|
|
||||||
))
|
|
||||||
defer ts.Close()
|
|
||||||
|
|
||||||
event := iSeedEvent(
|
|
||||||
t, s.WebhookDB, s.WebhookID, `{"unwritable":true}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
d := iSeedDelivery(
|
|
||||||
t, s.WebhookDB, event.ID, targetID,
|
|
||||||
database.DeliveryStatusPending,
|
|
||||||
)
|
|
||||||
|
|
||||||
require.NoError(
|
|
||||||
t,
|
|
||||||
s.WebhookDB.Exec("drop table delivery_results").Error,
|
|
||||||
)
|
|
||||||
|
|
||||||
// A single failure opens this breaker, and with no
|
|
||||||
// cooldown an open breaker lets the next delivery
|
|
||||||
// through as a probe.
|
|
||||||
cb := delivery.NewTestCircuitBreaker(1, 0)
|
|
||||||
if tc.tripped {
|
|
||||||
cb.RecordFailure()
|
|
||||||
}
|
|
||||||
|
|
||||||
s.Engine.ExportSetCircuitBreaker(targetID, cb)
|
|
||||||
|
|
||||||
full := &database.Delivery{
|
|
||||||
EventID: event.ID,
|
|
||||||
TargetID: targetID,
|
|
||||||
Status: database.DeliveryStatusPending,
|
|
||||||
Event: event,
|
|
||||||
Target: database.Target{
|
|
||||||
Name: "unwritable",
|
|
||||||
Type: database.TargetTypeHTTP,
|
|
||||||
Config: iHTTPConfig(ts.URL),
|
|
||||||
MaxRetries: 3,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
full.ID = d.ID
|
|
||||||
|
|
||||||
sched := &recordingScheduler{}
|
|
||||||
|
|
||||||
s.Engine.ExportDeliverHTTPWithScheduler(
|
|
||||||
context.Background(), s.WebhookDB, full,
|
|
||||||
&delivery.Task{
|
|
||||||
DeliveryID: d.ID,
|
|
||||||
TargetID: targetID,
|
|
||||||
AttemptNum: 1,
|
|
||||||
},
|
|
||||||
sched,
|
|
||||||
)
|
|
||||||
|
|
||||||
iAssertStatus(t, s.WebhookDB, d.ID, database.DeliveryStatusPending)
|
|
||||||
assert.Empty(t, sched.delays, "no retry may be scheduled")
|
|
||||||
assert.Equal(t, tc.wantBreaker, cb.State())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -375,7 +375,6 @@ func TestApplyRequestHeaders_ReportsOriginScopedNames(t *testing.T) {
|
|||||||
"Content-Type": testContentType,
|
"Content-Type": testContentType,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"webhooker/dev",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Equal(t,
|
assert.Equal(t,
|
||||||
|
|||||||
+38
-103
@@ -25,64 +25,43 @@ var (
|
|||||||
errNoIPs = errors.New(
|
errNoIPs = errors.New(
|
||||||
"hostname resolved to no IP addresses",
|
"hostname resolved to no IP addresses",
|
||||||
)
|
)
|
||||||
// ErrBlockedPrivateOrReservedIP reports an address in the
|
errBlockedIP = errors.New(
|
||||||
// default blocklist's private and reserved ranges,
|
"blocked private, reserved or cloud metadata address",
|
||||||
// blockedNetworks.
|
|
||||||
ErrBlockedPrivateOrReservedIP = errors.New(
|
|
||||||
"blocked private or reserved address",
|
|
||||||
)
|
|
||||||
// errBlockedPublicMetadata reports a public address on the
|
|
||||||
// default blocklist, one in blockedPublicNetworks.
|
|
||||||
errBlockedPublicMetadata = errors.New(
|
|
||||||
"blocked cloud metadata address",
|
|
||||||
)
|
)
|
||||||
errBlockedMetadata = errors.New(
|
errBlockedMetadata = errors.New(
|
||||||
"blocked link-local, cloud instance metadata or " +
|
"blocked link-local or cloud instance metadata " +
|
||||||
"unspecified address: ALLOWED_EGRESS_CIDRS cannot open it",
|
"address: ALLOWED_EGRESS_CIDRS cannot open it",
|
||||||
)
|
)
|
||||||
errInvalidScheme = errors.New(
|
errInvalidScheme = errors.New(
|
||||||
"only http and https are allowed",
|
"only http and https are allowed",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// blockedNetworks and blockedPublicNetworks together are the
|
// blockedNetworks is the default blocklist: the private and
|
||||||
// default blocklist: the private and reserved IP ranges, plus
|
// reserved IP ranges, plus the public cloud metadata addresses,
|
||||||
// the public cloud metadata addresses, that are blocked to
|
// that are blocked to prevent SSRF attacks. An operator can
|
||||||
// prevent SSRF attacks. An operator can permit specific blocks
|
// permit specific blocks out of this set with
|
||||||
// out of this set with ALLOWED_EGRESS_CIDRS; see Guard.
|
// ALLOWED_EGRESS_CIDRS; see Guard.
|
||||||
//
|
//
|
||||||
// blockedNetworks holds the private and reserved IP ranges.
|
// A public address belongs on the default blocklist only if it
|
||||||
|
// hands credentials, user data or bootstrap material to whatever
|
||||||
|
// can reach it, without the caller presenting anything. A
|
||||||
|
// provider's other public addresses are not refused, since
|
||||||
|
// reaching them can be legitimate and no list of them could be
|
||||||
|
// complete.
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // package-level network list is appropriate here
|
//nolint:gochecknoglobals // package-level network list is appropriate here
|
||||||
var blockedNetworks []*net.IPNet
|
var blockedNetworks []*net.IPNet
|
||||||
|
|
||||||
// blockedPublicNetworks holds the default blocklist's public
|
|
||||||
// addresses, kept apart from blockedNetworks so that they are
|
|
||||||
// refused as cloud metadata addresses, never as private or
|
|
||||||
// reserved ones.
|
|
||||||
//
|
|
||||||
// A public address belongs on the default blocklist only if it
|
|
||||||
// hands credentials, user data or bootstrap material to whatever
|
|
||||||
// can reach it, without the caller presenting anything; it goes
|
|
||||||
// in this list. A provider's other public addresses are not
|
|
||||||
// refused, since reaching them can be legitimate and no list of
|
|
||||||
// them could be complete.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals // package-level network list is appropriate here
|
|
||||||
var blockedPublicNetworks []*net.IPNet
|
|
||||||
|
|
||||||
// alwaysBlockedNetworks are the ranges no configuration can
|
// alwaysBlockedNetworks are the ranges no configuration can
|
||||||
// open, so a supplied CIDR that covers one still leaves it
|
// open: the link-local blocks and the cloud instance metadata
|
||||||
// blocked. An entry is here for one of two reasons: it is a
|
// endpoints that live outside them. Reaching one is credential
|
||||||
// metadata endpoint (the link-local blocks and the cloud
|
// or user-data theft rather than delivery to an internal
|
||||||
// instance metadata endpoints that live outside them), or it is
|
// service, so a supplied CIDR that covers such an address still
|
||||||
// an unspecified address. Reaching a metadata endpoint is
|
// leaves it blocked.
|
||||||
// credential or user-data theft rather than delivery to an
|
|
||||||
// internal service.
|
|
||||||
//
|
//
|
||||||
// Inclusion criterion for metadata endpoints — one belongs here
|
// Inclusion criterion — an address belongs here only if BOTH
|
||||||
// only if BOTH hold, and every metadata entry below satisfies
|
// hold, and every entry below satisfies both:
|
||||||
// both:
|
|
||||||
//
|
//
|
||||||
// 1. It is a fixed address assigned by the provider, or a
|
// 1. It is a fixed address assigned by the provider, or a
|
||||||
// range reserved by IANA — never one the operator chose.
|
// range reserved by IANA — never one the operator chose.
|
||||||
@@ -93,8 +72,8 @@ var blockedPublicNetworks []*net.IPNet
|
|||||||
// not cheaply rotated.
|
// not cheaply rotated.
|
||||||
//
|
//
|
||||||
// Both halves are load-bearing, so use them to refuse a
|
// Both halves are load-bearing, so use them to refuse a
|
||||||
// metadata candidate and say why. An endpoint disclosing only
|
// candidate and say why. An endpoint disclosing only the
|
||||||
// the operator's own inventory (instance id, region, disks, NICs)
|
// operator's own inventory (instance id, region, disks, NICs)
|
||||||
// fails (2): letting a delivery target reach the operator's own
|
// fails (2): letting a delivery target reach the operator's own
|
||||||
// infrastructure is the feature ALLOWED_EGRESS_CIDRS exists to
|
// infrastructure is the feature ALLOWED_EGRESS_CIDRS exists to
|
||||||
// provide. But (2) is not "IAM credentials only" either —
|
// provide. But (2) is not "IAM credentials only" either —
|
||||||
@@ -109,21 +88,12 @@ var blockedPublicNetworks []*net.IPNet
|
|||||||
// when it clears both halves. Nothing in this list can be
|
// when it clears both halves. Nothing in this list can be
|
||||||
// reopened, so putting a public address here leaves the operator
|
// reopened, so putting a public address here leaves the operator
|
||||||
// no escape hatch at all — the condition ALLOWED_EGRESS_CIDRS
|
// no escape hatch at all — the condition ALLOWED_EGRESS_CIDRS
|
||||||
// exists to remove. Default-block it in blockedPublicNetworks
|
// exists to remove. Default-block it in blockedNetworks instead,
|
||||||
// instead, which an allowlist can override.
|
// which an allowlist can override.
|
||||||
//
|
//
|
||||||
// This is a criterion, not an enumeration of every metadata
|
// This is a criterion, not an enumeration of every metadata
|
||||||
// address in existence.
|
// address in existence.
|
||||||
//
|
//
|
||||||
// The unspecified addresses 0.0.0.0 and :: are here for a
|
|
||||||
// separate reason: they disclose nothing, but no host can have
|
|
||||||
// either, and on Linux a connection to one reaches this host's
|
|
||||||
// own loopback. Listing them means an allowlist reaches loopback
|
|
||||||
// only through an entry that covers a loopback address
|
|
||||||
// (127.0.0.0/8, ::1/128, 0.0.0.0/0), never through one that
|
|
||||||
// covers only 0.0.0.0 or :: (0.0.0.0/8, for example). Nothing
|
|
||||||
// else lives at either address, so refusing them costs nothing.
|
|
||||||
//
|
|
||||||
// Every entry is either already in blockedNetworks — this list is
|
// Every entry is either already in blockedNetworks — this list is
|
||||||
// what makes it unconditional — or an alternate encoding of
|
// what makes it unconditional — or an alternate encoding of
|
||||||
// 169.254.169.254 that Contains does not match against
|
// 169.254.169.254 that Contains does not match against
|
||||||
@@ -143,49 +113,23 @@ var alwaysBlockedNetworks []*net.IPNet
|
|||||||
//nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup
|
//nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup
|
||||||
func init() {
|
func init() {
|
||||||
blockedNetworks = mustParseCIDRs([]string{
|
blockedNetworks = mustParseCIDRs([]string{
|
||||||
// IPv4 loopback.
|
|
||||||
"127.0.0.0/8",
|
"127.0.0.0/8",
|
||||||
// RFC 1918 private network.
|
|
||||||
"10.0.0.0/8",
|
"10.0.0.0/8",
|
||||||
// RFC 1918 private network.
|
|
||||||
"172.16.0.0/12",
|
"172.16.0.0/12",
|
||||||
// RFC 1918 private network.
|
|
||||||
"192.168.0.0/16",
|
"192.168.0.0/16",
|
||||||
// IPv4 link-local.
|
|
||||||
"169.254.0.0/16",
|
"169.254.0.0/16",
|
||||||
// "This network", holding the IPv4 unspecified address 0.0.0.0.
|
|
||||||
"0.0.0.0/8",
|
"0.0.0.0/8",
|
||||||
// Carrier-grade NAT shared address space.
|
|
||||||
"100.64.0.0/10",
|
"100.64.0.0/10",
|
||||||
// IETF protocol assignments.
|
|
||||||
"192.0.0.0/24",
|
"192.0.0.0/24",
|
||||||
// IPv4 documentation (TEST-NET-1).
|
|
||||||
"192.0.2.0/24",
|
"192.0.2.0/24",
|
||||||
// Benchmarking.
|
|
||||||
"198.18.0.0/15",
|
"198.18.0.0/15",
|
||||||
// IPv4 documentation (TEST-NET-2).
|
|
||||||
"198.51.100.0/24",
|
"198.51.100.0/24",
|
||||||
// IPv4 documentation (TEST-NET-3).
|
|
||||||
"203.0.113.0/24",
|
"203.0.113.0/24",
|
||||||
// IPv4 multicast.
|
|
||||||
"224.0.0.0/4",
|
"224.0.0.0/4",
|
||||||
// Reserved, including the broadcast address.
|
|
||||||
"240.0.0.0/4",
|
"240.0.0.0/4",
|
||||||
// IPv6 loopback.
|
|
||||||
"::1/128",
|
"::1/128",
|
||||||
// IPv6 unspecified address.
|
|
||||||
"::/128",
|
|
||||||
// IPv6 unique local addresses.
|
|
||||||
"fc00::/7",
|
"fc00::/7",
|
||||||
// IPv6 link-local.
|
|
||||||
"fe80::/10",
|
"fe80::/10",
|
||||||
// IPv6 multicast.
|
|
||||||
"ff00::/8",
|
|
||||||
// IPv6 documentation.
|
|
||||||
"2001:db8::/32",
|
|
||||||
})
|
|
||||||
|
|
||||||
blockedPublicNetworks = mustParseCIDRs([]string{
|
|
||||||
// Azure WireServer, a public address that serves VM credentials.
|
// Azure WireServer, a public address that serves VM credentials.
|
||||||
"168.63.129.16/32",
|
"168.63.129.16/32",
|
||||||
})
|
})
|
||||||
@@ -242,14 +186,6 @@ func init() {
|
|||||||
// allowlist from opening it.
|
// allowlist from opening it.
|
||||||
"192.0.0.192/32",
|
"192.0.0.192/32",
|
||||||
|
|
||||||
// The unspecified addresses, each of which reaches this
|
|
||||||
// host's loopback on Linux.
|
|
||||||
//
|
|
||||||
// IPv4 unspecified address, inside the blocked 0.0.0.0/8.
|
|
||||||
"0.0.0.0/32",
|
|
||||||
// IPv6 unspecified address.
|
|
||||||
"::/128",
|
|
||||||
|
|
||||||
// 169.254.169.254 as an IPv4-compatible IPv6 address.
|
// 169.254.169.254 as an IPv4-compatible IPv6 address.
|
||||||
"::a9fe:a9fe/128",
|
"::a9fe:a9fe/128",
|
||||||
// 169.254.169.254 behind the NAT64 well-known prefix.
|
// 169.254.169.254 behind the NAT64 well-known prefix.
|
||||||
@@ -289,6 +225,13 @@ func matchesAny(networks []*net.IPNet, ip net.IP) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isBlockedIP checks whether an IP address falls within
|
||||||
|
// the default blocklist, before any operator allowlist is
|
||||||
|
// considered.
|
||||||
|
func isBlockedIP(ip net.IP) bool {
|
||||||
|
return matchesAny(blockedNetworks, ip)
|
||||||
|
}
|
||||||
|
|
||||||
// Guard makes every SSRF decision in the process.
|
// Guard makes every SSRF decision in the process.
|
||||||
//
|
//
|
||||||
// It holds the operator's ALLOWED_EGRESS_CIDRS allowlist and
|
// It holds the operator's ALLOWED_EGRESS_CIDRS allowlist and
|
||||||
@@ -386,12 +329,10 @@ func (g *Guard) allows(ip net.IP) bool {
|
|||||||
// The order is the policy:
|
// The order is the policy:
|
||||||
//
|
//
|
||||||
// 1. alwaysBlockedNetworks is refused before the allowlist is
|
// 1. alwaysBlockedNetworks is refused before the allowlist is
|
||||||
// consulted, so no configured CIDR reaches link-local, a
|
// consulted, so no configured CIDR reaches link-local or a
|
||||||
// cloud metadata endpoint at a non-public address, or an
|
// cloud metadata endpoint at a non-public address.
|
||||||
// unspecified address.
|
|
||||||
// 2. The allowlist is consulted next, so a listed private
|
// 2. The allowlist is consulted next, so a listed private
|
||||||
// network, or a listed public address on the default
|
// network becomes reachable.
|
||||||
// blocklist, becomes reachable.
|
|
||||||
// 3. Everything else keeps the default blocklist's answer.
|
// 3. Everything else keeps the default blocklist's answer.
|
||||||
func (g *Guard) checkIP(ip net.IP) error {
|
func (g *Guard) checkIP(ip net.IP) error {
|
||||||
if matchesAny(alwaysBlockedNetworks, ip) {
|
if matchesAny(alwaysBlockedNetworks, ip) {
|
||||||
@@ -404,15 +345,9 @@ func (g *Guard) checkIP(ip net.IP) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if matchesAny(blockedNetworks, ip) {
|
if isBlockedIP(ip) {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"target IP %s: %w", ip, ErrBlockedPrivateOrReservedIP,
|
"target IP %s: %w", ip, errBlockedIP,
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if matchesAny(blockedPublicNetworks, ip) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"target IP %s: %w", ip, errBlockedPublicMetadata,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,6 @@ const (
|
|||||||
metadataIP = "169.254.169.254"
|
metadataIP = "169.254.169.254"
|
||||||
metadataURL = "http://" + metadataIP + "/latest/meta-data/"
|
metadataURL = "http://" + metadataIP + "/latest/meta-data/"
|
||||||
|
|
||||||
// linkLocalIPv4 is the IPv4 link-local block, which holds
|
|
||||||
// metadataIP.
|
|
||||||
linkLocalIPv4 = "169.254.0.0/16"
|
|
||||||
|
|
||||||
// loopbackHookURL is a target on this host: blocked by
|
// loopbackHookURL is a target on this host: blocked by
|
||||||
// default, reachable only once an operator allowlists
|
// default, reachable only once an operator allowlists
|
||||||
// loopback.
|
// loopback.
|
||||||
@@ -168,13 +164,12 @@ func TestGuardAllowlist_UnlistedPrivateStillRefused(t *testing.T) {
|
|||||||
|
|
||||||
// TestGuardAllowlist_MetadataAlwaysRefused is the load-bearing
|
// TestGuardAllowlist_MetadataAlwaysRefused is the load-bearing
|
||||||
// case: cloud instance metadata endpoints are credential theft
|
// case: cloud instance metadata endpoints are credential theft
|
||||||
// rather than delivery to an internal service, and the
|
// rather than delivery to an internal service, so no allowlist
|
||||||
// unspecified addresses 0.0.0.0 and :: reach this host's loopback
|
// reaches one. Every guard below names a CIDR that covers its
|
||||||
// on Linux, so no allowlist reaches any of them. Every guard
|
// target — including 0.0.0.0/0, ::/0, and the ordinary ULA and
|
||||||
// below names a CIDR that covers its target — including
|
// CGNAT blocks an operator would really list — and the address
|
||||||
// 0.0.0.0/0, ::/0, and the ordinary ULA and CGNAT blocks an
|
// must stay refused anyway, on both the validation and the
|
||||||
// operator would really list — and the address must stay
|
// delivery path.
|
||||||
// refused anyway, on both the validation and the delivery path.
|
|
||||||
func TestGuardAllowlist_MetadataAlwaysRefused(t *testing.T) {
|
func TestGuardAllowlist_MetadataAlwaysRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -220,17 +215,15 @@ type metadataAlwaysRefusedCase struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// metadataAlwaysRefusedCases enumerates every unconditionally
|
// metadataAlwaysRefusedCases enumerates every unconditionally
|
||||||
// blocked address (link-local, the cloud metadata endpoints and
|
// blocked address together with an allowlist entry that would
|
||||||
// the unspecified addresses) together with an allowlist entry
|
// otherwise reach it. Split by family of address only to stay
|
||||||
// that would otherwise reach it. Split by family of address only
|
// under the function-length limit.
|
||||||
// to stay under the function-length limit.
|
|
||||||
func metadataAlwaysRefusedCases() []metadataAlwaysRefusedCase {
|
func metadataAlwaysRefusedCases() []metadataAlwaysRefusedCase {
|
||||||
cases := linkLocalRefusedCases()
|
cases := linkLocalRefusedCases()
|
||||||
cases = append(cases, ulaMetadataRefusedCases()...)
|
cases = append(cases, ulaMetadataRefusedCases()...)
|
||||||
cases = append(cases, ipv4MetadataRefusedCases()...)
|
cases = append(cases, ipv4MetadataRefusedCases()...)
|
||||||
cases = append(cases, encodedMetadataRefusedCases()...)
|
|
||||||
|
|
||||||
return append(cases, unspecifiedRefusedCases()...)
|
return append(cases, encodedMetadataRefusedCases()...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// linkLocalRefusedCases covers the link-local blocks, including
|
// linkLocalRefusedCases covers the link-local blocks, including
|
||||||
@@ -244,7 +237,7 @@ func linkLocalRefusedCases() []metadataAlwaysRefusedCase {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "whole link-local block",
|
name: "whole link-local block",
|
||||||
allow: linkLocalIPv4,
|
allow: "169.254.0.0/16",
|
||||||
target: metadataURL,
|
target: metadataURL,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -370,23 +363,6 @@ func encodedMetadataRefusedCases() []metadataAlwaysRefusedCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// unspecifiedRefusedCases covers the unspecified addresses, each
|
|
||||||
// of which reaches this host's loopback on Linux.
|
|
||||||
func unspecifiedRefusedCases() []metadataAlwaysRefusedCase {
|
|
||||||
return []metadataAlwaysRefusedCase{
|
|
||||||
{
|
|
||||||
name: "IPv4 unspecified address under 0.0.0.0/0",
|
|
||||||
allow: allowAllIPv4,
|
|
||||||
target: "http://0.0.0.0:8080/hook",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "IPv6 unspecified address under ::/0",
|
|
||||||
allow: allowAllIPv6,
|
|
||||||
target: "http://[::]:8080/hook",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGuardAllowlist_PublicUnaffected asserts the allowlist does
|
// TestGuardAllowlist_PublicUnaffected asserts the allowlist does
|
||||||
// not narrow anything: public addresses were reachable before it
|
// not narrow anything: public addresses were reachable before it
|
||||||
// existed and stay reachable, whether or not a list is set.
|
// existed and stay reachable, whether or not a list is set.
|
||||||
@@ -436,9 +412,6 @@ func TestGuardAllowlist_AzureWireServerReopenable(t *testing.T) {
|
|||||||
"WireServer must be refused by the default blocklist, "+
|
"WireServer must be refused by the default blocklist, "+
|
||||||
"which an allowlist can override",
|
"which an allowlist can override",
|
||||||
)
|
)
|
||||||
require.NotErrorIs(t, err, delivery.ErrBlockedPrivateOrReservedIP,
|
|
||||||
"WireServer is public, not private or reserved",
|
|
||||||
)
|
|
||||||
|
|
||||||
assertDialRefused(t, defaultGuard, target)
|
assertDialRefused(t, defaultGuard, target)
|
||||||
|
|
||||||
@@ -523,7 +496,7 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
|
|||||||
want := []string{
|
want := []string{
|
||||||
// IPv4 link-local: the 169.254.169.254 metadata
|
// IPv4 link-local: the 169.254.169.254 metadata
|
||||||
// service on AWS, Azure and others.
|
// service on AWS, Azure and others.
|
||||||
linkLocalIPv4,
|
"169.254.0.0/16",
|
||||||
// IPv6 link-local.
|
// IPv6 link-local.
|
||||||
"fe80::/10",
|
"fe80::/10",
|
||||||
// AWS IPv6 IMDS, inside the ULA space an operator may
|
// AWS IPv6 IMDS, inside the ULA space an operator may
|
||||||
@@ -544,10 +517,6 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
|
|||||||
// Oracle Cloud Classic metadata, inside the blocked
|
// Oracle Cloud Classic metadata, inside the blocked
|
||||||
// 192.0.0.0/24.
|
// 192.0.0.0/24.
|
||||||
"192.0.0.192/32",
|
"192.0.0.192/32",
|
||||||
// The IPv4 and IPv6 unspecified addresses, each of
|
|
||||||
// which reaches this host's loopback on Linux.
|
|
||||||
"0.0.0.0/32",
|
|
||||||
"::/128",
|
|
||||||
// 169.254.169.254 as an IPv4-compatible IPv6 address.
|
// 169.254.169.254 as an IPv4-compatible IPv6 address.
|
||||||
"::a9fe:a9fe/128",
|
"::a9fe:a9fe/128",
|
||||||
// 169.254.169.254 behind the NAT64 well-known prefix.
|
// 169.254.169.254 behind the NAT64 well-known prefix.
|
||||||
@@ -557,94 +526,6 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
|
|||||||
assert.Equal(t, want, got)
|
assert.Equal(t, want, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDefaultBlocklist_PinnedSet pins each list of the default
|
|
||||||
// blocklist on its own, the private and reserved ranges in
|
|
||||||
// blockedNetworks and the public addresses in
|
|
||||||
// blockedPublicNetworks, so moving an entry from one list to the
|
|
||||||
// other fails it. For the first address of each entry it then
|
|
||||||
// checks that the default guard refuses it, and that listing the
|
|
||||||
// entry in ALLOWED_EGRESS_CIDRS opens it unless the unconditional
|
|
||||||
// set holds that address.
|
|
||||||
func TestDefaultBlocklist_PinnedSet(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// public marks an entry of blockedPublicNetworks; every other
|
|
||||||
// entry belongs in blockedNetworks.
|
|
||||||
tests := []struct {
|
|
||||||
cidr string
|
|
||||||
public bool
|
|
||||||
reopenable bool
|
|
||||||
}{
|
|
||||||
{cidr: "127.0.0.0/8", reopenable: true},
|
|
||||||
{cidr: "10.0.0.0/8", reopenable: true},
|
|
||||||
{cidr: "172.16.0.0/12", reopenable: true},
|
|
||||||
{cidr: "192.168.0.0/16", reopenable: true},
|
|
||||||
{cidr: linkLocalIPv4, reopenable: false},
|
|
||||||
// Its first address, 0.0.0.0, is in the unconditional set.
|
|
||||||
{cidr: "0.0.0.0/8", reopenable: false},
|
|
||||||
{cidr: "100.64.0.0/10", reopenable: true},
|
|
||||||
{cidr: "192.0.0.0/24", reopenable: true},
|
|
||||||
{cidr: "192.0.2.0/24", reopenable: true},
|
|
||||||
{cidr: "198.18.0.0/15", reopenable: true},
|
|
||||||
{cidr: "198.51.100.0/24", reopenable: true},
|
|
||||||
{cidr: "203.0.113.0/24", reopenable: true},
|
|
||||||
{cidr: "224.0.0.0/4", reopenable: true},
|
|
||||||
{cidr: "240.0.0.0/4", reopenable: true},
|
|
||||||
{cidr: "::1/128", reopenable: true},
|
|
||||||
{cidr: "::/128", reopenable: false},
|
|
||||||
{cidr: "fc00::/7", reopenable: true},
|
|
||||||
{cidr: "fe80::/10", reopenable: false},
|
|
||||||
{cidr: "ff00::/8", reopenable: true},
|
|
||||||
{cidr: "2001:db8::/32", reopenable: true},
|
|
||||||
{cidr: "168.63.129.16/32", public: true, reopenable: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
wantPrivate := make([]string, 0, len(tests))
|
|
||||||
wantPublic := make([]string, 0, len(tests))
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
if tt.public {
|
|
||||||
wantPublic = append(wantPublic, tt.cidr)
|
|
||||||
} else {
|
|
||||||
wantPrivate = append(wantPrivate, tt.cidr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
gotPrivate := make([]string, 0, len(tests))
|
|
||||||
for _, n := range delivery.ExportBlockedNetworks() {
|
|
||||||
gotPrivate = append(gotPrivate, n.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
gotPublic := make([]string, 0, len(tests))
|
|
||||||
for _, n := range delivery.ExportBlockedPublicNetworks() {
|
|
||||||
gotPublic = append(gotPublic, n.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.ElementsMatch(t, wantPrivate, gotPrivate, "blockedNetworks")
|
|
||||||
assert.ElementsMatch(t, wantPublic, gotPublic, "blockedPublicNetworks")
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.cidr, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
prefix := netip.MustParsePrefix(tt.cidr)
|
|
||||||
ip := net.IP(prefix.Addr().AsSlice())
|
|
||||||
|
|
||||||
require.Error(t,
|
|
||||||
delivery.NewTestGuard().ExportCheckIP(ip),
|
|
||||||
"the default guard must refuse %s", ip,
|
|
||||||
)
|
|
||||||
|
|
||||||
err := delivery.NewTestGuard(prefix).ExportCheckIP(ip)
|
|
||||||
if tt.reopenable {
|
|
||||||
assert.NoError(t, err, "listing %s must open it", tt.cidr)
|
|
||||||
} else {
|
|
||||||
assert.Error(t, err, "listing %s must not open it", tt.cidr)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireLoopback fails the test unless rawURL's host is a
|
// requireLoopback fails the test unless rawURL's host is a
|
||||||
// loopback address, so the allowlist test cannot silently stop
|
// loopback address, so the allowlist test cannot silently stop
|
||||||
// exercising a blocked range.
|
// exercising a blocked range.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGuardCheckIP_PrivateRanges(t *testing.T) {
|
func TestIsBlockedIP_PrivateRanges(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -56,14 +56,12 @@ func TestGuardCheckIP_PrivateRanges(t *testing.T) {
|
|||||||
"failed to parse IP %s", tt.ip,
|
"failed to parse IP %s", tt.ip,
|
||||||
)
|
)
|
||||||
|
|
||||||
refused := delivery.NewTestGuard().ExportCheckIP(ip) != nil
|
|
||||||
|
|
||||||
assert.Equal(t,
|
assert.Equal(t,
|
||||||
tt.blocked,
|
tt.blocked,
|
||||||
refused,
|
delivery.ExportIsBlockedIP(ip),
|
||||||
"default guard refuses %s = %v, want %v",
|
"isBlockedIP(%s) = %v, want %v",
|
||||||
tt.ip,
|
tt.ip,
|
||||||
refused,
|
delivery.ExportIsBlockedIP(ip),
|
||||||
tt.blocked,
|
tt.blocked,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -101,42 +99,6 @@ func TestValidateTargetURL_Blocked(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDefaultGuard_RefusesUnspecifiedMulticastAndDocumentation
|
|
||||||
// covers the unspecified addresses and the IPv6 multicast and
|
|
||||||
// documentation ranges: with no allowlist set, each is refused
|
|
||||||
// both when a target is created and when a delivery dials it.
|
|
||||||
func TestDefaultGuard_RefusesUnspecifiedMulticastAndDocumentation(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
guard := delivery.NewTestGuard()
|
|
||||||
|
|
||||||
targets := []string{
|
|
||||||
// The unspecified addresses. On Linux a connection to
|
|
||||||
// either reaches this host's loopback.
|
|
||||||
"http://0.0.0.0:8080/hook",
|
|
||||||
"http://[::]:8080/hook",
|
|
||||||
// IPv6 multicast, all nodes.
|
|
||||||
"http://[ff02::1]/hook",
|
|
||||||
// IPv6 documentation.
|
|
||||||
"http://[2001:db8::1]/hook",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, target := range targets {
|
|
||||||
t.Run(target, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
require.Error(t,
|
|
||||||
guard.ValidateTargetURL(context.Background(), target),
|
|
||||||
"%s must be refused at target creation", target,
|
|
||||||
)
|
|
||||||
|
|
||||||
assertDialRefused(t, guard, target)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateTargetURL_Allowed(t *testing.T) {
|
func TestValidateTargetURL_Allowed(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,6 @@ func (e *Engine) initTargets(client *http.Client) {
|
|||||||
dbT := &databaseTarget{eng: e}
|
dbT := &databaseTarget{eng: e}
|
||||||
|
|
||||||
e.httpTarget = httpT
|
e.httpTarget = httpT
|
||||||
e.slackTarget = slackT
|
|
||||||
e.dbTarget = dbT
|
e.dbTarget = dbT
|
||||||
|
|
||||||
e.targets = map[database.TargetType]Target{
|
e.targets = map[database.TargetType]Target{
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ type TargetConfigForm struct {
|
|||||||
Timeout string
|
Timeout string
|
||||||
// Expiry is the database (archive) target's row expiry.
|
// Expiry is the database (archive) target's row expiry.
|
||||||
Expiry string
|
Expiry string
|
||||||
// Rotation is the database (archive) target's rotation.
|
|
||||||
Rotation string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTargetConfigForm parses a target's stored configuration into
|
// NewTargetConfigForm parses a target's stored configuration into
|
||||||
@@ -87,13 +85,11 @@ func NewTargetConfigForm(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// databaseConfigForm parses an archive target's optional expiry and
|
// databaseConfigForm parses an archive target's optional expiry.
|
||||||
// rotation. An absent, empty or never expiry yields an empty expiry,
|
// An absent or empty configuration is the keep-forever default and
|
||||||
// on which the edit form starts at never; saving it unchanged stores
|
// yields an empty field, so re-saving the form unchanged stores the
|
||||||
// never, which means the same as an empty expiry. An absent rotation
|
// same empty configuration it started with. An expiry that is set
|
||||||
// is empty too, and the form starts at none. An expiry that is set
|
// but not a valid duration is an error, not a blank field.
|
||||||
// but not a valid duration, or a rotation that is not one of the
|
|
||||||
// four, is an error, not a blank field.
|
|
||||||
func databaseConfigForm(
|
func databaseConfigForm(
|
||||||
configJSON string,
|
configJSON string,
|
||||||
) (TargetConfigForm, error) {
|
) (TargetConfigForm, error) {
|
||||||
@@ -110,15 +106,8 @@ func databaseConfigForm(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = ValidateArchiveRotation(cfg.Rotation)
|
|
||||||
if err != nil {
|
|
||||||
return TargetConfigForm{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
form := TargetConfigForm{Rotation: cfg.Rotation}
|
|
||||||
|
|
||||||
if cfg.Expiry == "" || cfg.Expiry == archiveExpiryNever {
|
if cfg.Expiry == "" || cfg.Expiry == archiveExpiryNever {
|
||||||
return form, nil
|
return TargetConfigForm{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err = ValidateArchiveExpiry(cfg.Expiry)
|
err = ValidateArchiveExpiry(cfg.Expiry)
|
||||||
@@ -126,7 +115,5 @@ func databaseConfigForm(
|
|||||||
return TargetConfigForm{}, err
|
return TargetConfigForm{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
form.Expiry = cfg.Expiry
|
return TargetConfigForm{Expiry: cfg.Expiry}, nil
|
||||||
|
|
||||||
return form, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package delivery
|
package delivery
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
@@ -97,7 +97,7 @@ func targetConfigFields(
|
|||||||
) []ConfigField {
|
) []ConfigField {
|
||||||
switch t.Type {
|
switch t.Type {
|
||||||
case database.TargetTypeSlack:
|
case database.TargetTypeSlack:
|
||||||
return slackConfigFields(t)
|
return slackConfigFields(t.Config)
|
||||||
case database.TargetTypeHTTP:
|
case database.TargetTypeHTTP:
|
||||||
return httpConfigFields(t)
|
return httpConfigFields(t)
|
||||||
case database.TargetTypeDatabase:
|
case database.TargetTypeDatabase:
|
||||||
@@ -119,11 +119,10 @@ func unavailableConfigFields() []ConfigField {
|
|||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// slackConfigFields describes a Slack target: its masked
|
// slackConfigFields describes a Slack target. Only the masked
|
||||||
// webhook URL and its retry count. Only the masked URL is
|
// webhook URL is shown; the full URL is the credential.
|
||||||
// shown; the full URL is the credential.
|
func slackConfigFields(configJSON string) []ConfigField {
|
||||||
func slackConfigFields(t *database.Target) []ConfigField {
|
cfg, err := parseSlackConfig(configJSON)
|
||||||
cfg, err := parseSlackConfig(t.Config)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return unavailableConfigFields()
|
return unavailableConfigFields()
|
||||||
}
|
}
|
||||||
@@ -131,7 +130,7 @@ func slackConfigFields(t *database.Target) []ConfigField {
|
|||||||
return []ConfigField{{
|
return []ConfigField{{
|
||||||
Label: "Webhook URL",
|
Label: "Webhook URL",
|
||||||
Value: cfg.MaskedWebhookURL(),
|
Value: cfg.MaskedWebhookURL(),
|
||||||
}, maxRetriesField(t)}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// httpConfigFields describes an HTTP target: its destination
|
// httpConfigFields describes an HTTP target: its destination
|
||||||
@@ -171,90 +170,63 @@ func httpConfigFields(t *database.Target) []ConfigField {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fields = append(fields, maxRetriesField(t))
|
return append(fields, retryFields(t)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// retryFields describes a target's retry settings, which live
|
||||||
|
// on the target row rather than in its configuration blob.
|
||||||
|
func retryFields(t *database.Target) []ConfigField {
|
||||||
|
retries := strconv.Itoa(t.MaxRetries)
|
||||||
|
if t.MaxRetries == 0 {
|
||||||
|
retries += " (fire-and-forget)"
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := []ConfigField{{
|
||||||
|
Label: "Max Retries",
|
||||||
|
Value: retries,
|
||||||
|
}}
|
||||||
|
|
||||||
|
if t.MaxQueueSize > 0 {
|
||||||
|
fields = append(fields, ConfigField{
|
||||||
|
Label: "Max Queue Size",
|
||||||
|
Value: strconv.Itoa(t.MaxQueueSize),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return fields
|
return fields
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxRetriesField describes a target's retry count, which lives
|
// databaseConfigFields describes an archive target. Its
|
||||||
// on the target row rather than in its configuration blob. A
|
// configuration is optional, and an absent or empty expiry
|
||||||
// stored 0 makes a single attempt, so it is shown as 1.
|
// means the archive is kept forever. An expiry that is set
|
||||||
func maxRetriesField(t *database.Target) ConfigField {
|
// but not a valid duration is reported as unavailable rather
|
||||||
attempts := strconv.Itoa(t.MaxRetries)
|
// than echoed back.
|
||||||
if t.MaxRetries == 0 {
|
|
||||||
attempts = "1 (fire-and-forget: no retries, no circuit breaker)"
|
|
||||||
}
|
|
||||||
|
|
||||||
return ConfigField{
|
|
||||||
Label: "Delivery attempts",
|
|
||||||
Value: attempts,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// databaseConfigFields describes an archive target by its
|
|
||||||
// expiry in plain units, such as "30 days", or "never" when
|
|
||||||
// the archive is kept forever, and by its rotation. An expiry
|
|
||||||
// that is set but not a valid duration, or a rotation that is
|
|
||||||
// not one of the four, is reported as unavailable rather than
|
|
||||||
// echoed back.
|
|
||||||
func databaseConfigFields(configJSON string) []ConfigField {
|
func databaseConfigFields(configJSON string) []ConfigField {
|
||||||
expiry, err := parseArchiveExpiry(configJSON)
|
expiry := archiveExpiryNever
|
||||||
|
|
||||||
|
if configJSON != "" {
|
||||||
|
var cfg databaseTargetConfig
|
||||||
|
|
||||||
|
err := json.Unmarshal([]byte(configJSON), &cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return unavailableConfigFields()
|
return unavailableConfigFields()
|
||||||
}
|
}
|
||||||
|
|
||||||
rotation, err := parseArchiveRotation(configJSON)
|
if cfg.Expiry != "" {
|
||||||
if err != nil {
|
if ValidateArchiveExpiry(cfg.Expiry) != nil {
|
||||||
return unavailableConfigFields()
|
return unavailableConfigFields()
|
||||||
}
|
}
|
||||||
|
|
||||||
value := archiveExpiryNever
|
expiry = cfg.Expiry
|
||||||
if expiry > 0 {
|
}
|
||||||
value = plainDuration(expiry)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return []ConfigField{{
|
return []ConfigField{{
|
||||||
Label: "Archive expiry",
|
Label: "Archive Expiry",
|
||||||
Value: value,
|
Value: expiry,
|
||||||
}, {
|
|
||||||
Label: "Archive rotation",
|
|
||||||
Value: rotation,
|
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// plainDuration writes a positive duration as a count of the
|
|
||||||
// largest whole unit it divides into: "30 days", "12 hours",
|
|
||||||
// "1 minute". A duration with a fraction of a second is
|
|
||||||
// written as Go writes it.
|
|
||||||
func plainDuration(d time.Duration) string {
|
|
||||||
const day = 24 * time.Hour
|
|
||||||
|
|
||||||
units := []struct {
|
|
||||||
size time.Duration
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{day, "day"},
|
|
||||||
{time.Hour, "hour"},
|
|
||||||
{time.Minute, "minute"},
|
|
||||||
{time.Second, "second"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, unit := range units {
|
|
||||||
if d%unit.size != 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
count := int64(d / unit.size)
|
|
||||||
if count == 1 {
|
|
||||||
return "1 " + unit.name
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("%d %ss", count, unit.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
return d.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MaskedWebhookURL returns the Slack webhook URL reduced to
|
// MaskedWebhookURL returns the Slack webhook URL reduced to
|
||||||
// its scheme and host, with the path, query and any userinfo
|
// its scheme and host, with the path, query and any userinfo
|
||||||
// elided. The path segments are the credential, so none of
|
// elided. The path segments are the credential, so none of
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ const (
|
|||||||
viewMaskedOrigin = viewExampleOrigin + "/..."
|
viewMaskedOrigin = viewExampleOrigin + "/..."
|
||||||
viewUnavailable = "(unavailable)"
|
viewUnavailable = "(unavailable)"
|
||||||
viewExpiryNever = "never"
|
viewExpiryNever = "never"
|
||||||
viewMaxRetries = "Delivery attempts"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMaskedWebhookURL(t *testing.T) {
|
func TestMaskedWebhookURL(t *testing.T) {
|
||||||
@@ -158,7 +157,9 @@ func TestNewTargetViews_DeletedTarget(t *testing.T) {
|
|||||||
t, slackTargetName+" (deleted)", view.DisplayName(),
|
t, slackTargetName+" (deleted)", view.DisplayName(),
|
||||||
)
|
)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, viewFor(t, slackTarget()).Config, view.Config,
|
t,
|
||||||
|
map[string]string{"Webhook URL": slackMaskedURL},
|
||||||
|
fieldMap(view.Config),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,30 +189,7 @@ func TestNewTargetViews_Slack(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
map[string]string{
|
map[string]string{"Webhook URL": slackMaskedURL},
|
||||||
"Webhook URL": slackMaskedURL,
|
|
||||||
viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
|
|
||||||
},
|
|
||||||
fieldMap(view.Config),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewTargetViews_SlackRetries proves a Slack target shows
|
|
||||||
// its retry count the same way an HTTP target does.
|
|
||||||
func TestNewTargetViews_SlackRetries(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
target := slackTarget()
|
|
||||||
target.MaxRetries = 2
|
|
||||||
|
|
||||||
view := viewFor(t, target)
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t,
|
|
||||||
map[string]string{
|
|
||||||
"Webhook URL": slackMaskedURL,
|
|
||||||
viewMaxRetries: "2",
|
|
||||||
},
|
|
||||||
fieldMap(view.Config),
|
fieldMap(view.Config),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -225,6 +203,7 @@ func TestNewTargetViews_HTTP(t *testing.T) {
|
|||||||
`"timeout":30,` +
|
`"timeout":30,` +
|
||||||
`"headers":{"Authorization":"Bearer sekrit"}}`,
|
`"headers":{"Authorization":"Bearer sekrit"}}`,
|
||||||
MaxRetries: 5,
|
MaxRetries: 5,
|
||||||
|
MaxQueueSize: 100,
|
||||||
})
|
})
|
||||||
|
|
||||||
fields := fieldMap(view.Config)
|
fields := fieldMap(view.Config)
|
||||||
@@ -235,7 +214,8 @@ func TestNewTargetViews_HTTP(t *testing.T) {
|
|||||||
"Destination URL": viewMaskedOrigin,
|
"Destination URL": viewMaskedOrigin,
|
||||||
"Timeout": "30s",
|
"Timeout": "30s",
|
||||||
"Headers": "1 configured",
|
"Headers": "1 configured",
|
||||||
viewMaxRetries: "5",
|
"Max Retries": "5",
|
||||||
|
"Max Queue Size": "100",
|
||||||
},
|
},
|
||||||
fields,
|
fields,
|
||||||
)
|
)
|
||||||
@@ -258,7 +238,7 @@ func TestNewTargetViews_HTTPFireAndForget(t *testing.T) {
|
|||||||
t,
|
t,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"Destination URL": viewMaskedOrigin,
|
"Destination URL": viewMaskedOrigin,
|
||||||
viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
|
"Max Retries": "0 (fire-and-forget)",
|
||||||
},
|
},
|
||||||
fieldMap(view.Config),
|
fieldMap(view.Config),
|
||||||
)
|
)
|
||||||
@@ -301,20 +281,14 @@ func TestNewTargetViews_Database(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
"empty config": {config: "", want: viewExpiryNever},
|
"empty config": {config: "", want: viewExpiryNever},
|
||||||
"empty expiry": {config: `{}`, want: viewExpiryNever},
|
"empty expiry": {config: `{}`, want: viewExpiryNever},
|
||||||
|
"explicit": {
|
||||||
|
config: `{"expiry":"720h"}`,
|
||||||
|
want: "720h",
|
||||||
|
},
|
||||||
"never literal": {
|
"never literal": {
|
||||||
config: `{"expiry":"` + viewExpiryNever + `"}`,
|
config: `{"expiry":"` + viewExpiryNever + `"}`,
|
||||||
want: viewExpiryNever,
|
want: viewExpiryNever,
|
||||||
},
|
},
|
||||||
"1h": {config: `{"expiry":"1h"}`, want: "1 hour"},
|
|
||||||
"12h": {config: `{"expiry":"12h"}`, want: "12 hours"},
|
|
||||||
"24h": {config: `{"expiry":"24h"}`, want: "1 day"},
|
|
||||||
"720h": {config: `{"expiry":"720h"}`, want: "30 days"},
|
|
||||||
"2160h": {config: `{"expiry":"2160h"}`, want: "90 days"},
|
|
||||||
"8760h": {config: `{"expiry":"8760h"}`, want: "365 days"},
|
|
||||||
"36h": {config: `{"expiry":"36h"}`, want: "36 hours"},
|
|
||||||
"1h30m": {config: `{"expiry":"1h30m"}`, want: "90 minutes"},
|
|
||||||
"45s": {config: `{"expiry":"45s"}`, want: "45 seconds"},
|
|
||||||
"1.5s": {config: `{"expiry":"1.5s"}`, want: "1.5s"},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, tc := range tests {
|
for name, tc := range tests {
|
||||||
@@ -328,49 +302,13 @@ func TestNewTargetViews_Database(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
map[string]string{
|
map[string]string{"Archive Expiry": tc.want},
|
||||||
"Archive expiry": tc.want,
|
|
||||||
"Archive rotation": rotationNone,
|
|
||||||
},
|
|
||||||
fieldMap(view.Config),
|
fieldMap(view.Config),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestNewTargetViews_DatabaseRotation proves the target list shows a
|
|
||||||
// database target's rotation, none when it has none stored.
|
|
||||||
func TestNewTargetViews_DatabaseRotation(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Each stored config, and the rotation the list shows for it.
|
|
||||||
tests := map[string]string{
|
|
||||||
"": rotationNone,
|
|
||||||
`{"rotation":""}`: rotationNone,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, rotation := range []string{
|
|
||||||
rotationNone, rotationMonthly, rotationDaily, rotationHourly,
|
|
||||||
} {
|
|
||||||
tests[`{"rotation":"`+rotation+`"}`] = rotation
|
|
||||||
}
|
|
||||||
|
|
||||||
for config, want := range tests {
|
|
||||||
t.Run(config, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
view := viewFor(t, database.Target{
|
|
||||||
Type: database.TargetTypeDatabase,
|
|
||||||
Config: config,
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t, want, fieldMap(view.Config)["Archive rotation"],
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewTargetViews_Log(t *testing.T) {
|
func TestNewTargetViews_Log(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -418,10 +356,6 @@ func TestNewTargetViews_Unpresentable(t *testing.T) {
|
|||||||
Type: database.TargetTypeDatabase,
|
Type: database.TargetTypeDatabase,
|
||||||
Config: `{"expiry":"a fortnight"}`,
|
Config: `{"expiry":"a fortnight"}`,
|
||||||
},
|
},
|
||||||
"invalid archive rotation": {
|
|
||||||
Type: database.TargetTypeDatabase,
|
|
||||||
Config: weeklyConfig,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, target := range tests {
|
for name, target := range tests {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package delivery
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -11,76 +11,22 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// archiveNameMaxLen is how many characters of a webhook or target
|
// databaseTarget is a no-retry target that archives the
|
||||||
// name an archive file name keeps.
|
// full inbound event into a per-webhook archive SQLite file,
|
||||||
const archiveNameMaxLen = 40
|
// separate from the per-webhook event database. The event is
|
||||||
|
// already persisted in the per-webhook event DB by the time
|
||||||
// databaseTarget is a no-retry target that archives the full
|
// delivery runs; the database target additionally writes a
|
||||||
// inbound event into the target's own archive SQLite file, separate
|
// durable long-term copy into archive-{webhookID}.db and then
|
||||||
// from the per-webhook event database. The event is already
|
// records a single attempt whose outcome reflects whether the
|
||||||
// persisted in the per-webhook event DB by the time delivery runs;
|
// archive write succeeded. See archiveWriter for the
|
||||||
// the database target additionally writes a durable long-term copy
|
// close/reopen, auto-recreate, and expiry semantics.
|
||||||
// into the file ArchiveFileName names, with a period added when the
|
|
||||||
// target rotates (see archivePeriodPath), and then records a single
|
|
||||||
// attempt whose outcome reflects whether the archive write
|
|
||||||
// succeeded. See archiveWriter for the close/reopen, auto-recreate,
|
|
||||||
// and expiry semantics.
|
|
||||||
type databaseTarget struct {
|
type databaseTarget struct {
|
||||||
eng *Engine
|
eng *Engine
|
||||||
|
|
||||||
// writers holds one archive writer per database target, keyed
|
|
||||||
// by target ID.
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
writers map[string]*archiveWriter
|
writers map[string]*archiveWriter
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArchiveFileName returns the file name of a database target's
|
|
||||||
// archive: archive-WEBHOOKNAME-TARGETNAME-TARGETID.db, with both
|
|
||||||
// names passed through archiveNamePart. The target ID keeps the
|
|
||||||
// name unique when two targets' names come out the same.
|
|
||||||
func ArchiveFileName(webhookName, targetName, targetID string) string {
|
|
||||||
return "archive-" + archiveNamePart(webhookName) + "-" +
|
|
||||||
archiveNamePart(targetName) + "-" + targetID + ".db"
|
|
||||||
}
|
|
||||||
|
|
||||||
// archiveNamePart makes a webhook or target name safe to put in a
|
|
||||||
// file name. It is lowercased; ASCII letters and digits are kept,
|
|
||||||
// every other run of characters becomes a single "-", and no "-" is
|
|
||||||
// left at either end. It is cut to archiveNameMaxLen characters, and
|
|
||||||
// a name with nothing left is "unnamed".
|
|
||||||
func archiveNamePart(name string) string {
|
|
||||||
var b strings.Builder
|
|
||||||
|
|
||||||
dash := false
|
|
||||||
|
|
||||||
for _, r := range strings.ToLower(name) {
|
|
||||||
if (r < 'a' || r > 'z') && (r < '0' || r > '9') {
|
|
||||||
dash = b.Len() > 0
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if dash {
|
|
||||||
b.WriteByte('-')
|
|
||||||
|
|
||||||
dash = false
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteRune(r)
|
|
||||||
}
|
|
||||||
|
|
||||||
part := b.String()
|
|
||||||
if len(part) > archiveNameMaxLen {
|
|
||||||
part = strings.TrimRight(part[:archiveNameMaxLen], "-")
|
|
||||||
}
|
|
||||||
|
|
||||||
if part == "" {
|
|
||||||
return "unnamed"
|
|
||||||
}
|
|
||||||
|
|
||||||
return part
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deliver implements Target. It archives the event, then
|
// Deliver implements Target. It archives the event, then
|
||||||
// records one successful attempt and marks the delivery
|
// records one successful attempt and marks the delivery
|
||||||
// delivered. An archiving error fails the delivery: the
|
// delivered. An archiving error fails the delivery: the
|
||||||
@@ -146,10 +92,9 @@ func (t *databaseTarget) Deliver(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// archive writes the full event as a row into the target's
|
// archive writes the full event as a row into the webhook's
|
||||||
// archive database, honouring the optional per-target expiry and
|
// archive database, honouring the optional per-target expiry
|
||||||
// rotation parsed from the target config JSON. With rotation, the
|
// parsed from the target config JSON.
|
||||||
// event goes to the file for the period of its receive time.
|
|
||||||
func (t *databaseTarget) archive(d *database.Delivery) error {
|
func (t *databaseTarget) archive(d *database.Delivery) error {
|
||||||
webhookID := d.Event.WebhookID
|
webhookID := d.Event.WebhookID
|
||||||
if webhookID == "" {
|
if webhookID == "" {
|
||||||
@@ -161,20 +106,7 @@ func (t *databaseTarget) archive(d *database.Delivery) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
rotation, err := parseArchiveRotation(d.Target.Config)
|
w, err := t.writerFor(webhookID)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// An event whose stored row was gone before its delivery ran has
|
|
||||||
// no receive time (see Engine.hydrateEvent), and goes to the
|
|
||||||
// file for now.
|
|
||||||
receivedAt := d.Event.CreatedAt
|
|
||||||
if receivedAt.IsZero() {
|
|
||||||
receivedAt = time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
w, err := t.writerFor(d.TargetID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -189,34 +121,33 @@ func (t *databaseTarget) archive(d *database.Delivery) error {
|
|||||||
ContentType: d.Event.ContentType,
|
ContentType: d.Event.ContentType,
|
||||||
}
|
}
|
||||||
|
|
||||||
return w.write(row, expiry, archivePeriod(rotation, receivedAt))
|
return w.write(row, expiry)
|
||||||
}
|
}
|
||||||
|
|
||||||
// writerFor returns the archive writer for a database target,
|
// writerFor returns the archiveWriter for a webhook, creating
|
||||||
// creating and caching it on first use. Each target has one writer
|
// and caching it on first use. Each webhook has one writer so
|
||||||
// so its close/reopen debounce state is shared across concurrent
|
// its close/reopen debounce state is shared across concurrent
|
||||||
// deliveries, and so a rename and the idle sweep take the same lock
|
// deliveries. The archive file lives beside the per-webhook
|
||||||
// as its writes.
|
// event database in the data directory.
|
||||||
func (t *databaseTarget) writerFor(
|
func (t *databaseTarget) writerFor(
|
||||||
targetID string,
|
webhookID string,
|
||||||
) (*archiveWriter, error) {
|
) (*archiveWriter, error) {
|
||||||
t.mu.Lock()
|
path, err := t.archivePath(webhookID)
|
||||||
defer t.mu.Unlock()
|
|
||||||
|
|
||||||
w, ok := t.writers[targetID]
|
|
||||||
if !ok {
|
|
||||||
var err error
|
|
||||||
|
|
||||||
w, err = t.newWriter(targetID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
if t.writers == nil {
|
if t.writers == nil {
|
||||||
t.writers = make(map[string]*archiveWriter)
|
t.writers = make(map[string]*archiveWriter)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.writers[targetID] = w
|
w, ok := t.writers[webhookID]
|
||||||
|
if !ok {
|
||||||
|
w = newArchiveWriter(path, t.eng.log)
|
||||||
|
t.writers[webhookID] = w
|
||||||
}
|
}
|
||||||
|
|
||||||
// A delivery claims the entry: even if the idle sweep created
|
// A delivery claims the entry: even if the idle sweep created
|
||||||
@@ -228,39 +159,40 @@ func (t *databaseTarget) writerFor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sweepWriterFor returns the archive writer the idle sweep should
|
// sweepWriterFor returns the archive writer the idle sweep should
|
||||||
// prune a target's archive through, together with whether the sweep
|
// prune a webhook through, together with whether the sweep itself
|
||||||
// itself created the registry entry.
|
// created the registry entry.
|
||||||
//
|
//
|
||||||
// The sweep must route its prune through the registered writer so
|
// The sweep must route its prune through the registered writer so
|
||||||
// the writer's mutex orders it against concurrent writes, but it
|
// the writer's mutex orders it against concurrent writes, but it
|
||||||
// must never leave a registry entry behind: a sweep that ran
|
// must never leave a registry entry behind: a sweep that ran
|
||||||
// concurrently with the target's deletion would otherwise
|
// concurrently with the webhook's deletion would otherwise
|
||||||
// re-create an entry that nothing will ever evict again, which is
|
// re-create an entry that nothing will ever evict again, which is
|
||||||
// exactly the leak eviction exists to prevent. An entry the sweep
|
// exactly the leak eviction exists to prevent. An entry the sweep
|
||||||
// creates is therefore marked sweep-owned and handed back to
|
// creates is therefore marked sweep-owned and handed back to
|
||||||
// releaseSweepWriter when the sweep is done.
|
// releaseSweepWriter when the sweep is done.
|
||||||
func (t *databaseTarget) sweepWriterFor(
|
func (t *databaseTarget) sweepWriterFor(
|
||||||
targetID string,
|
webhookID string,
|
||||||
) (*archiveWriter, bool, error) {
|
) (*archiveWriter, bool, error) {
|
||||||
t.mu.Lock()
|
path, err := t.archivePath(webhookID)
|
||||||
defer t.mu.Unlock()
|
|
||||||
|
|
||||||
w, ok := t.writers[targetID]
|
|
||||||
if ok {
|
|
||||||
return w, false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
w, err := t.newWriter(targetID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
if t.writers == nil {
|
if t.writers == nil {
|
||||||
t.writers = make(map[string]*archiveWriter)
|
t.writers = make(map[string]*archiveWriter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
w, ok := t.writers[webhookID]
|
||||||
|
if ok {
|
||||||
|
return w, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
w = newArchiveWriter(path, t.eng.log)
|
||||||
w.sweepOwned = true
|
w.sweepOwned = true
|
||||||
t.writers[targetID] = w
|
t.writers[webhookID] = w
|
||||||
|
|
||||||
return w, true, nil
|
return w, true, nil
|
||||||
}
|
}
|
||||||
@@ -277,92 +209,57 @@ func (t *databaseTarget) sweepWriterFor(
|
|||||||
// delivery that adopted the writer keeps a registered, evictable
|
// delivery that adopted the writer keeps a registered, evictable
|
||||||
// one.
|
// one.
|
||||||
func (t *databaseTarget) releaseSweepWriter(
|
func (t *databaseTarget) releaseSweepWriter(
|
||||||
targetID string, w *archiveWriter,
|
webhookID string, w *archiveWriter,
|
||||||
) {
|
) {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
defer t.mu.Unlock()
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
cur, ok := t.writers[targetID]
|
cur, ok := t.writers[webhookID]
|
||||||
if !ok || cur != w || !cur.sweepOwned {
|
if !ok || cur != w || !cur.sweepOwned {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(t.writers, targetID)
|
delete(t.writers, webhookID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// newWriter builds the writer for a database target's archive. Its
|
// archivePath returns the archive file path for a webhook: it
|
||||||
// path is the one ArchivePath gives for the webhook and the target as
|
// lives beside the per-webhook event database in the data
|
||||||
// the main database names them now; from then on only rename changes
|
// directory. It does not touch the filesystem.
|
||||||
// the name the writer uses. It does not touch the archive files.
|
func (t *databaseTarget) archivePath(
|
||||||
func (t *databaseTarget) newWriter(
|
webhookID string,
|
||||||
targetID string,
|
) (string, error) {
|
||||||
) (*archiveWriter, error) {
|
|
||||||
if t.eng.dbManager == nil {
|
if t.eng.dbManager == nil {
|
||||||
return nil, errArchiveNoDataDir
|
return "", errArchiveNoDataDir
|
||||||
}
|
}
|
||||||
|
|
||||||
var target database.Target
|
dir := filepath.Dir(t.eng.dbManager.DBPath(webhookID))
|
||||||
|
|
||||||
err := t.eng.database.DB().
|
return filepath.Join(
|
||||||
Preload("Webhook").
|
dir, fmt.Sprintf("archive-%s.db", webhookID),
|
||||||
First(&target, "id = ?", targetID).Error
|
), nil
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"loading database target %s: %w", targetID, err,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
w := newArchiveWriter(
|
// evict drops a webhook's archive writer from the registry and
|
||||||
ArchivePath(t.eng.dbManager, &target.Webhook, &target),
|
// closes its handle, so a deleted webhook does not leave a
|
||||||
t.eng.log,
|
// writer (and an open archive handle within its debounce
|
||||||
)
|
// window) alive for the process lifetime.
|
||||||
w.webhookID = target.WebhookID
|
|
||||||
|
|
||||||
return w, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// rename moves every one of a database target's archive files to the
|
|
||||||
// name for webhookName and targetName. It goes through the target's
|
|
||||||
// writer, so the move holds the lock that writes and the idle sweep
|
|
||||||
// take, and later writes use the new name.
|
|
||||||
//
|
|
||||||
// The writer is created if there is none, and it stays cached. The
|
|
||||||
// handlers rename before they save the new name, so until the save
|
|
||||||
// the main database still has the old one; a delivery in that window
|
|
||||||
// must find this writer rather than build one from the old name.
|
|
||||||
func (t *databaseTarget) rename(
|
|
||||||
targetID, webhookName, targetName string,
|
|
||||||
) error {
|
|
||||||
w, err := t.writerFor(targetID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return w.rename(ArchiveFileName(webhookName, targetName, targetID))
|
|
||||||
}
|
|
||||||
|
|
||||||
// evict drops a database target's archive writer from the registry
|
|
||||||
// and closes its handle, so a deleted target does not leave a
|
|
||||||
// writer (and an open archive handle within its debounce window)
|
|
||||||
// alive for the process lifetime.
|
|
||||||
//
|
//
|
||||||
// The map entry is removed under the registry lock, which is
|
// The map entry is removed under the registry lock, which is
|
||||||
// then released before the handle is closed under the writer's
|
// then released before the handle is closed under the writer's
|
||||||
// own lock: that ordering keeps the registry available to other
|
// own lock: that ordering keeps the registry available to other
|
||||||
// targets while an in-flight write on this one drains, and
|
// webhooks while an in-flight write on this one drains, and
|
||||||
// closing under the writer's lock means eviction can never race
|
// closing under the writer's lock means eviction can never race
|
||||||
// a write.
|
// a write.
|
||||||
//
|
//
|
||||||
// Eviction is idempotent and silent for a target with no writer,
|
// Eviction is idempotent and silent for a webhook with no
|
||||||
// which is the common case: only a database target that has
|
// writer, which is the common case: a webhook with no database
|
||||||
// received an event or been renamed has one. It never deletes the
|
// target never creates one. It never deletes the archive file.
|
||||||
// archive file.
|
func (t *databaseTarget) evict(webhookID string) {
|
||||||
func (t *databaseTarget) evict(targetID string) {
|
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
|
|
||||||
w, ok := t.writers[targetID]
|
w, ok := t.writers[webhookID]
|
||||||
if ok {
|
if ok {
|
||||||
delete(t.writers, targetID)
|
delete(t.writers, webhookID)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
@@ -373,43 +270,15 @@ func (t *databaseTarget) evict(targetID string) {
|
|||||||
|
|
||||||
w.evict()
|
w.evict()
|
||||||
|
|
||||||
t.eng.log.Info(
|
|
||||||
"evicted archive writer",
|
|
||||||
"target_id", targetID,
|
|
||||||
"path", w.path,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// evictWebhook evicts, exactly as evict does, the writer of every
|
|
||||||
// database target of a webhook.
|
|
||||||
func (t *databaseTarget) evictWebhook(webhookID string) {
|
|
||||||
t.mu.Lock()
|
|
||||||
|
|
||||||
var gone []*archiveWriter
|
|
||||||
|
|
||||||
for targetID, w := range t.writers {
|
|
||||||
if w.webhookID == webhookID {
|
|
||||||
delete(t.writers, targetID)
|
|
||||||
|
|
||||||
gone = append(gone, w)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t.mu.Unlock()
|
|
||||||
|
|
||||||
for _, w := range gone {
|
|
||||||
w.evict()
|
|
||||||
|
|
||||||
t.eng.log.Info(
|
t.eng.log.Info(
|
||||||
"evicted archive writer",
|
"evicted archive writer",
|
||||||
"webhook_id", webhookID,
|
"webhook_id", webhookID,
|
||||||
"path", w.path,
|
"path", w.path,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// evictAll evicts every cached archive writer, exactly as evict
|
// evictAll evicts every cached archive writer, exactly as evict
|
||||||
// does for one target. The engine calls it at shutdown, once its
|
// does for one webhook. The engine calls it at shutdown, once its
|
||||||
// workers have returned. Closing the last handle on an archive
|
// workers have returned. Closing the last handle on an archive
|
||||||
// moves the contents of its -wal into the .db and removes the
|
// moves the contents of its -wal into the .db and removes the
|
||||||
// -wal, so a clean stop leaves each archive as a single file.
|
// -wal, so a clean stop leaves each archive as a single file.
|
||||||
@@ -426,25 +295,38 @@ func (t *databaseTarget) evictAll() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sweepArchive prunes one database target's archive of rows older
|
// sweepWebhook prunes one webhook's archive of rows older than
|
||||||
// than expiry, without requiring a write. A missing archive file is
|
// expiry, without requiring a write. It returns nil (nothing to
|
||||||
// left missing (see sweepExpired), so a sweep never creates an
|
// do) when the archive file does not exist, so a sweep never
|
||||||
// archive for a target that has never received an event.
|
// creates an archive for a webhook that has a database target
|
||||||
|
// but has never received an event.
|
||||||
//
|
//
|
||||||
// It also never leaves a registry entry behind: an entry it had
|
// It also never leaves a registry entry behind: an entry it had
|
||||||
// to create to reach the writer's mutex is released again once
|
// to create to reach the writer's mutex is released again once
|
||||||
// the prune is done, so a sweep racing a target deletion cannot
|
// the prune is done, so a sweep racing a webhook deletion cannot
|
||||||
// resurrect the writer the eviction just dropped.
|
// resurrect the writer the eviction just dropped.
|
||||||
func (t *databaseTarget) sweepArchive(
|
func (t *databaseTarget) sweepWebhook(
|
||||||
targetID string, expiry time.Duration,
|
webhookID string, expiry time.Duration,
|
||||||
) error {
|
) error {
|
||||||
w, created, err := t.sweepWriterFor(targetID)
|
path, err := t.archivePath(webhookID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check before taking a writer at all: a webhook whose
|
||||||
|
// archive has never been created gets no writer, no handle,
|
||||||
|
// and no file.
|
||||||
|
if !fileExists(path) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
w, created, err := t.sweepWriterFor(webhookID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if created {
|
if created {
|
||||||
defer t.releaseSweepWriter(targetID, w)
|
defer t.releaseSweepWriter(webhookID, w)
|
||||||
}
|
}
|
||||||
|
|
||||||
return w.sweepExpired(expiry)
|
return w.sweepExpired(expiry)
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -43,7 +41,7 @@ const (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
// errArchiveMissingWebhookID is returned when an event to
|
// errArchiveMissingWebhookID is returned when an event to
|
||||||
// archive has no webhook id to record in its archive row.
|
// archive has no webhook id to key its archive file on.
|
||||||
errArchiveMissingWebhookID = errors.New(
|
errArchiveMissingWebhookID = errors.New(
|
||||||
"cannot archive event without a webhook id",
|
"cannot archive event without a webhook id",
|
||||||
)
|
)
|
||||||
@@ -63,19 +61,13 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// errArchiveWriterEvicted is returned when a writer that has
|
// errArchiveWriterEvicted is returned when a writer that has
|
||||||
// been evicted (its target or its webhook was deleted) is used
|
// been evicted (its webhook was deleted, or its last database
|
||||||
// again. An evicted writer is no longer in the registry, so
|
// target was removed) is used again. An evicted writer is no
|
||||||
// reopening its file would leak a handle nothing owns.
|
// longer in the registry, so reopening its file would leak a
|
||||||
|
// handle nothing owns.
|
||||||
errArchiveWriterEvicted = errors.New(
|
errArchiveWriterEvicted = errors.New(
|
||||||
"archive writer has been evicted",
|
"archive writer has been evicted",
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrArchiveNameTaken is returned when an archive cannot be
|
|
||||||
// renamed because a file already has the new name. That file may
|
|
||||||
// be an archive with rows of its own, so it is never replaced.
|
|
||||||
ErrArchiveNameTaken = errors.New(
|
|
||||||
"a file already has the archive's new name",
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// databaseTargetConfig is the optional per-target JSON config
|
// databaseTargetConfig is the optional per-target JSON config
|
||||||
@@ -85,14 +77,10 @@ type databaseTargetConfig struct {
|
|||||||
// archived rows are pruned, or "never" (the default) to
|
// archived rows are pruned, or "never" (the default) to
|
||||||
// keep them forever.
|
// keep them forever.
|
||||||
Expiry string `json:"expiry"`
|
Expiry string `json:"expiry"`
|
||||||
|
|
||||||
// Rotation is none (the default), monthly, daily or hourly: see
|
|
||||||
// archivePeriod.
|
|
||||||
Rotation string `json:"rotation"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// archivedEvent is one fully captured webhook event stored in a
|
// archivedEvent is one fully captured webhook event stored in a
|
||||||
// database target's archive for long-term retention. It is a
|
// per-webhook archive database for long-term retention. It is a
|
||||||
// self-contained copy — independent of the per-webhook event
|
// self-contained copy — independent of the per-webhook event
|
||||||
// database, which may prune events under its own retention.
|
// database, which may prune events under its own retention.
|
||||||
type archivedEvent struct {
|
type archivedEvent struct {
|
||||||
@@ -182,8 +170,8 @@ func ValidateArchiveExpiry(expiry string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// archiveWriter owns one database target's archive SQLite files.
|
// archiveWriter owns one per-webhook archive SQLite file. It
|
||||||
// It serialises writes, and after each write closes and reopens
|
// serialises writes, and after each write closes and reopens
|
||||||
// the file (debounced to at most once per debounce window) so
|
// the file (debounced to at most once per debounce window) so
|
||||||
// an operator can move the file away for offline archiving. The
|
// an operator can move the file away for offline archiving. The
|
||||||
// next write recreates a moved or removed file, because the
|
// next write recreates a moved or removed file, because the
|
||||||
@@ -191,41 +179,24 @@ func ValidateArchiveExpiry(expiry string) error {
|
|||||||
// on every open.
|
// on every open.
|
||||||
type archiveWriter struct {
|
type archiveWriter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
|
||||||
// path is the target's archive file as ArchivePath names it. A
|
|
||||||
// target that rotates writes to the files archivePeriodPath names
|
|
||||||
// for path and a period instead.
|
|
||||||
path string
|
path string
|
||||||
|
|
||||||
// current is the file db is open on.
|
|
||||||
current string
|
|
||||||
|
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
debounce time.Duration
|
debounce time.Duration
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
lastReopen time.Time
|
lastReopen time.Time
|
||||||
reopens int
|
reopens int
|
||||||
|
|
||||||
// now is the clock the reopen debounce is measured on. It is
|
|
||||||
// time.Now outside tests.
|
|
||||||
now func() time.Time
|
|
||||||
|
|
||||||
// evicted marks a writer that has been removed from the
|
// evicted marks a writer that has been removed from the
|
||||||
// registry. Its handle is closed and it must never open the
|
// per-webhook registry. Its handle is closed and it must
|
||||||
// file again: nothing holds it any more, so a reopen would
|
// never open the file again: nothing holds it any more, so a
|
||||||
// leak the handle for the process lifetime.
|
// reopen would leak the handle for the process lifetime.
|
||||||
evicted bool
|
evicted bool
|
||||||
|
|
||||||
// webhookID is the webhook the archive's target belongs to,
|
|
||||||
// so deleting the webhook can find its writers. It is set
|
|
||||||
// when the writer is created and never changes.
|
|
||||||
webhookID string
|
|
||||||
|
|
||||||
// sweepOwned marks a registry entry that the idle sweep
|
// sweepOwned marks a registry entry that the idle sweep
|
||||||
// created because no writer was cached for the target. The
|
// created because no writer was cached for the webhook. The
|
||||||
// sweep removes such an entry again when it is done, so a
|
// sweep removes such an entry again when it is done, so a
|
||||||
// sweep can never leave — or resurrect — a registry entry
|
// sweep can never leave — or resurrect — a registry entry
|
||||||
// for a target that has been deleted. A delivery that adopts
|
// for a webhook that has been deleted. A delivery that adopts
|
||||||
// the writer clears the flag, handing the entry to the
|
// the writer clears the flag, handing the entry to the
|
||||||
// registry proper.
|
// registry proper.
|
||||||
//
|
//
|
||||||
@@ -244,18 +215,15 @@ func newArchiveWriter(
|
|||||||
path: path,
|
path: path,
|
||||||
log: log,
|
log: log,
|
||||||
debounce: archiveReopenDebounce,
|
debounce: archiveReopenDebounce,
|
||||||
now: time.Now,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// write appends the event as a row to the archive file for period
|
// write appends the event as a row, then applies the debounced
|
||||||
// (see archivePeriodPath), then applies the debounced close/reopen.
|
// close/reopen. It recreates the archive file if it was moved
|
||||||
// When period names a different file from the one open, the open one
|
// or removed since the last open. A positive expiry prunes rows
|
||||||
// is closed first. It recreates the archive file if it was moved or
|
// older than it on each (re)open.
|
||||||
// removed since the last open. A positive expiry prunes rows older
|
|
||||||
// than it on each (re)open.
|
|
||||||
func (w *archiveWriter) write(
|
func (w *archiveWriter) write(
|
||||||
row archivedEvent, expiry time.Duration, period string,
|
row archivedEvent, expiry time.Duration,
|
||||||
) error {
|
) error {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
@@ -266,10 +234,8 @@ func (w *archiveWriter) write(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
file := archivePeriodPath(w.path, period)
|
if w.db == nil || !fileExists(w.path) {
|
||||||
|
err := w.reopen(expiry)
|
||||||
if w.db == nil || w.current != file || !fileExists(file) {
|
|
||||||
err := w.reopen(file, expiry)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -280,41 +246,41 @@ func (w *archiveWriter) write(
|
|||||||
err := w.db.Create(&row).Error
|
err := w.db.Create(&row).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"archiving event to %s: %w", file, err,
|
"archiving event to %s: %w", w.path, err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if w.now().Sub(w.lastReopen) >= w.debounce {
|
if time.Since(w.lastReopen) >= w.debounce {
|
||||||
return w.reopen(file, expiry)
|
return w.reopen(expiry)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// open opens (creating if missing) an archive file, migrates
|
// open opens (creating if missing) the archive file, migrates
|
||||||
// its schema, records the reopen time, and prunes expired rows
|
// its schema, records the reopen time, and prunes expired rows
|
||||||
// when expiry is positive.
|
// when expiry is positive.
|
||||||
func (w *archiveWriter) open(file string, expiry time.Duration) error {
|
func (w *archiveWriter) open(expiry time.Duration) error {
|
||||||
return w.openMode(file, archiveModeCreate, expiry)
|
return w.openMode(archiveModeCreate, expiry)
|
||||||
}
|
}
|
||||||
|
|
||||||
// openMode opens an archive file with the given SQLite URI
|
// openMode opens the archive file with the given SQLite URI
|
||||||
// mode, migrates its schema, records the reopen time, and
|
// mode, migrates its schema, records the reopen time, and
|
||||||
// prunes expired rows when expiry is positive. The write path
|
// prunes expired rows when expiry is positive. The write path
|
||||||
// passes archiveModeCreate so a missing file is recreated; the
|
// passes archiveModeCreate so a missing file is recreated; the
|
||||||
// idle sweep passes archiveModeExisting so a missing file is an
|
// idle sweep passes archiveModeExisting so a missing file is an
|
||||||
// error rather than a newly conjured empty archive.
|
// error rather than a newly conjured empty archive.
|
||||||
func (w *archiveWriter) openMode(
|
func (w *archiveWriter) openMode(
|
||||||
file, mode string, expiry time.Duration,
|
mode string, expiry time.Duration,
|
||||||
) error {
|
) error {
|
||||||
// Opened through database.OpenSQLite so an archive file carries
|
// Opened through database.OpenSQLite so an archive file carries
|
||||||
// the same WAL journaling, busy timeout, immediate-transaction
|
// the same WAL journaling, busy timeout, immediate-transaction
|
||||||
// locking, and pool bounds as every other database file. See
|
// locking, and pool bounds as every other database file. See
|
||||||
// internal/database/sqlite_open.go.
|
// internal/database/sqlite_open.go.
|
||||||
sqlDB, err := database.OpenSQLite(file, mode)
|
sqlDB, err := database.OpenSQLite(w.path, mode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"opening archive database %s: %w", file, err,
|
"opening archive database %s: %w", w.path, err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,7 +296,7 @@ func (w *archiveWriter) openMode(
|
|||||||
|
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"connecting to archive database %s: %w",
|
"connecting to archive database %s: %w",
|
||||||
file, err,
|
w.path, err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,13 +305,12 @@ func (w *archiveWriter) openMode(
|
|||||||
_ = sqlDB.Close()
|
_ = sqlDB.Close()
|
||||||
|
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"migrating archive database %s: %w", file, err,
|
"migrating archive database %s: %w", w.path, err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
w.db = gdb
|
w.db = gdb
|
||||||
w.current = file
|
w.lastReopen = time.Now()
|
||||||
w.lastReopen = w.now()
|
|
||||||
w.reopens++
|
w.reopens++
|
||||||
|
|
||||||
if expiry > 0 {
|
if expiry > 0 {
|
||||||
@@ -355,12 +320,12 @@ func (w *archiveWriter) openMode(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// reopen closes any open handle and opens file afresh. The
|
// reopen closes any open handle and opens the file afresh. The
|
||||||
// fresh open recreates the file if it was moved away.
|
// fresh open recreates the file if it was moved away.
|
||||||
func (w *archiveWriter) reopen(file string, expiry time.Duration) error {
|
func (w *archiveWriter) reopen(expiry time.Duration) error {
|
||||||
w.close()
|
w.close()
|
||||||
|
|
||||||
return w.open(file, expiry)
|
return w.open(expiry)
|
||||||
}
|
}
|
||||||
|
|
||||||
// close closes the underlying handle, if any.
|
// close closes the underlying handle, if any.
|
||||||
@@ -377,56 +342,22 @@ func (w *archiveWriter) close() {
|
|||||||
w.db = nil
|
w.db = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sweepExpired prunes the target's archive files, which may have
|
// sweepExpired prunes an archive that may have gone idle, with
|
||||||
// gone idle, with no write to trigger the usual on-reopen prune. It
|
// no write to trigger the usual on-reopen prune. It takes the
|
||||||
// lists the files under the writer's own mutex, then takes the mutex
|
// writer's own mutex for the whole operation, so a sweep is
|
||||||
// again for one file at a time, so a write waits for at most one
|
// ordered against concurrent writes rather than reaching around
|
||||||
// file's prune, and each prune is ordered against concurrent writes
|
// them to the file.
|
||||||
// rather than reaching around them to the file.
|
|
||||||
//
|
//
|
||||||
// It never creates an archive file: it prunes only the files
|
// It never creates the archive file: a missing file is skipped,
|
||||||
// archiveFiles lists, skips one that is gone by the time it is
|
// and the reopen uses archiveModeExisting so SQLite itself
|
||||||
// reached (moved away, or renamed since the listing), and opens each
|
// refuses to create one if the file disappears between the
|
||||||
// with archiveModeExisting so SQLite itself refuses to create one if
|
// check and the open.
|
||||||
// the file disappears between the check and the open. A file named
|
|
||||||
// for a period that the prune leaves empty is deleted.
|
|
||||||
//
|
//
|
||||||
// The archive is left CLOSED afterwards. An idle archive holding
|
// The archive is left CLOSED afterwards. An idle archive holding
|
||||||
// no handle is what keeps the operator's move-the-file-away
|
// no handle is what keeps the operator's move-the-file-away
|
||||||
// workflow working; the next write reopens (and recreates) the
|
// workflow working; the next write reopens (and recreates) the
|
||||||
// file as it always has.
|
// file as it always has.
|
||||||
func (w *archiveWriter) sweepExpired(expiry time.Duration) error {
|
func (w *archiveWriter) sweepExpired(expiry time.Duration) error {
|
||||||
w.mu.Lock()
|
|
||||||
files, err := archiveFiles(w.path)
|
|
||||||
w.mu.Unlock()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var errs []error
|
|
||||||
|
|
||||||
for _, file := range files {
|
|
||||||
err = w.sweepFile(file, expiry)
|
|
||||||
if errors.Is(err, errArchiveWriterEvicted) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
errs = append(errs, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors.Join(errs...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// sweepFile prunes one of the target's archive files for sweepExpired,
|
|
||||||
// holding w.mu while it does. It skips a file that is gone, and deletes
|
|
||||||
// the file, with its -wal and -shm, when it is named for a period and
|
|
||||||
// the prune leaves it empty.
|
|
||||||
func (w *archiveWriter) sweepFile(
|
|
||||||
file archiveFile, expiry time.Duration,
|
|
||||||
) error {
|
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
@@ -436,7 +367,7 @@ func (w *archiveWriter) sweepFile(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !fileExists(file.path) {
|
if !fileExists(w.path) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,133 +375,21 @@ func (w *archiveWriter) sweepFile(
|
|||||||
// freshly opened file, matching the write path's semantics.
|
// freshly opened file, matching the write path's semantics.
|
||||||
w.close()
|
w.close()
|
||||||
|
|
||||||
err := w.openMode(file.path, archiveModeExisting, expiry)
|
err := w.openMode(archiveModeExisting, expiry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if file.period == "" {
|
|
||||||
w.close()
|
w.close()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var rows int64
|
|
||||||
|
|
||||||
err = w.db.Model(&archivedEvent{}).Count(&rows).Error
|
|
||||||
|
|
||||||
w.close()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"counting rows in archive %s: %w", file.path, err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if rows > 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, suffix := range []string{"", "-wal", "-shm"} {
|
|
||||||
err = os.Remove(file.path + suffix)
|
|
||||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return fmt.Errorf("deleting empty archive file: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
w.log.Info("deleted empty archive file", "path", file.path)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// rename gives every one of the target's archive files the new
|
|
||||||
// name, keeping the period in the name of each (see
|
|
||||||
// archivePeriodPath), and the writer uses the files under that name
|
|
||||||
// from now on. The handle is closed first, which folds the -wal into
|
|
||||||
// the .db; any -wal or -shm still beside a file (left by a crash) is
|
|
||||||
// moved with it, because SQLite finds them by name. A target with no
|
|
||||||
// files is not an error: the operator may have moved them away, and
|
|
||||||
// the next write creates its file under the new name.
|
|
||||||
//
|
|
||||||
// If a file already has one of the new names, nothing is moved and
|
|
||||||
// the error is ErrArchiveNameTaken. If one file fails to move, those
|
|
||||||
// already moved are moved back before the error is returned, so the
|
|
||||||
// archive is never split across two names.
|
|
||||||
func (w *archiveWriter) rename(name string) error {
|
|
||||||
w.mu.Lock()
|
|
||||||
defer w.mu.Unlock()
|
|
||||||
|
|
||||||
if w.evicted {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%w: %s", errArchiveWriterEvicted, w.path,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
path := filepath.Join(filepath.Dir(w.path), name)
|
|
||||||
if path == w.path {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
files, err := archiveFiles(w.path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// from[i] moves to to[i].
|
|
||||||
var from, to []string
|
|
||||||
|
|
||||||
for _, file := range files {
|
|
||||||
renamed := archivePeriodPath(path, file.period)
|
|
||||||
|
|
||||||
for _, suffix := range []string{"", "-wal", "-shm"} {
|
|
||||||
from = append(from, file.path+suffix)
|
|
||||||
to = append(to, renamed+suffix)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, taken := range to {
|
|
||||||
if fileExists(taken) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%w: %s", ErrArchiveNameTaken, filepath.Base(taken),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
w.close()
|
|
||||||
|
|
||||||
for i := range from {
|
|
||||||
err = os.Rename(from[i], to[i])
|
|
||||||
if err == nil || errors.Is(err, fs.ErrNotExist) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for j := range i {
|
|
||||||
backErr := os.Rename(to[j], from[j])
|
|
||||||
if backErr != nil && !errors.Is(backErr, fs.ErrNotExist) {
|
|
||||||
w.log.Error(
|
|
||||||
"failed to move archive file back",
|
|
||||||
"from", to[j],
|
|
||||||
"to", from[j],
|
|
||||||
"error", backErr,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf(
|
|
||||||
"renaming archive %s to %s: %w", from[i], to[i], err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
w.path = path
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// evict closes the writer's handle and marks it unusable. It is
|
// evict closes the writer's handle and marks it unusable. It is
|
||||||
// called when the writer leaves the registry, because its target
|
// called when the writer leaves the registry, either because the
|
||||||
// or its webhook was deleted, or at shutdown. The archive FILE is
|
// webhook was deleted or because its last database target was
|
||||||
// deliberately left on disk: it is long-term storage an operator
|
// removed. The archive FILE is deliberately left on disk: it is
|
||||||
// may still want.
|
// long-term storage an operator may still want.
|
||||||
func (w *archiveWriter) evict() {
|
func (w *archiveWriter) evict() {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
@@ -595,7 +414,7 @@ func (w *archiveWriter) prune(expiry time.Duration) {
|
|||||||
if res.Error != nil {
|
if res.Error != nil {
|
||||||
w.log.Error(
|
w.log.Error(
|
||||||
"failed to prune expired archive rows",
|
"failed to prune expired archive rows",
|
||||||
"path", w.current,
|
"path", w.path,
|
||||||
"error", res.Error,
|
"error", res.Error,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -605,81 +424,12 @@ func (w *archiveWriter) prune(expiry time.Duration) {
|
|||||||
if res.RowsAffected > 0 {
|
if res.RowsAffected > 0 {
|
||||||
w.log.Info(
|
w.log.Info(
|
||||||
"pruned expired archive rows",
|
"pruned expired archive rows",
|
||||||
"path", w.current,
|
"path", w.path,
|
||||||
"rows_deleted", res.RowsAffected,
|
"rows_deleted", res.RowsAffected,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArchiveFileInfo is what the metadata of a database target's archive
|
|
||||||
// files says about them.
|
|
||||||
type ArchiveFileInfo struct {
|
|
||||||
// Files counts the files.
|
|
||||||
Files int
|
|
||||||
|
|
||||||
// Size is the bytes on disk of the files and their -wal together.
|
|
||||||
Size int64
|
|
||||||
|
|
||||||
// Written is when a file or a -wal was last modified, whichever is
|
|
||||||
// latest: a write lands in the -wal first.
|
|
||||||
Written time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// StatArchive reads the metadata of a database target's archive
|
|
||||||
// files, given the path ArchivePath gives it (see archiveFiles), and
|
|
||||||
// of their -wal, without opening them. With no files, which is so
|
|
||||||
// before the first write and after the operator moved them away, the
|
|
||||||
// error wraps fs.ErrNotExist.
|
|
||||||
func StatArchive(path string) (ArchiveFileInfo, error) {
|
|
||||||
files, err := archiveFiles(path)
|
|
||||||
if err != nil {
|
|
||||||
return ArchiveFileInfo{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var info ArchiveFileInfo
|
|
||||||
|
|
||||||
for _, file := range files {
|
|
||||||
db, err := os.Stat(file.path)
|
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return ArchiveFileInfo{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
info.Files++
|
|
||||||
info.Size += db.Size()
|
|
||||||
|
|
||||||
if db.ModTime().After(info.Written) {
|
|
||||||
info.Written = db.ModTime()
|
|
||||||
}
|
|
||||||
|
|
||||||
wal, err := os.Stat(file.path + "-wal")
|
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return ArchiveFileInfo{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
info.Size += wal.Size()
|
|
||||||
|
|
||||||
if wal.ModTime().After(info.Written) {
|
|
||||||
info.Written = wal.ModTime()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if info.Files == 0 {
|
|
||||||
return ArchiveFileInfo{}, fmt.Errorf(
|
|
||||||
"no archive file for %s: %w", path, fs.ErrNotExist,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return info, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// fileExists reports whether a path currently exists.
|
// fileExists reports whether a path currently exists.
|
||||||
func fileExists(path string) bool {
|
func fileExists(path string) bool {
|
||||||
_, err := os.Stat(path)
|
_, err := os.Stat(path)
|
||||||
|
|||||||
@@ -17,109 +17,85 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
)
|
)
|
||||||
|
|
||||||
// deliverTo archives one event to a database target, leaving the
|
// evictTestEngine builds an engine backed by a temporary data
|
||||||
// target's writer cached with its handle open.
|
// directory and returns it along with that directory.
|
||||||
func deliverTo(
|
func evictTestEngine(t *testing.T) (*delivery.Engine, string) {
|
||||||
t *testing.T, env *archiveEnv, tgt *database.Target,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
webhookDB := testWebhookDB(t)
|
dataDir := t.TempDir()
|
||||||
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
|
||||||
|
|
||||||
env.eng.ExportDeliverDatabase(
|
eng := delivery.NewTestEngineWithDB(
|
||||||
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
|
nil,
|
||||||
|
database.NewTestWebhookDBManager(dataDir),
|
||||||
|
archiveTestLogger(),
|
||||||
|
&http.Client{Timeout: 5 * time.Second},
|
||||||
|
1,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return eng, dataDir
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEvictWebhook_ClosesAndRemovesWriter proves that evicting
|
// TestEvictWebhook_ClosesAndRemovesWriter proves that evicting
|
||||||
// a webhook drops the archive writers of its database targets
|
// a webhook drops its archive writer from the registry and
|
||||||
// from the registry and closes their open handles, rather than
|
// closes the open archive handle, rather than leaving both
|
||||||
// leaving them alive for the process lifetime, and leaves another
|
// alive for the process lifetime.
|
||||||
// webhook's writer alone.
|
|
||||||
func TestEvictWebhook_ClosesAndRemovesWriter(t *testing.T) {
|
func TestEvictWebhook_ClosesAndRemovesWriter(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
eng, dataDir := evictTestEngine(t)
|
||||||
first := env.seedDatabaseTarget(t, "")
|
|
||||||
second := env.addDatabaseTarget(t, first.WebhookID, "")
|
|
||||||
other := env.seedDatabaseTarget(t, "")
|
|
||||||
|
|
||||||
for _, tgt := range []*database.Target{first, second, other} {
|
webhookDB := testWebhookDB(t)
|
||||||
deliverTo(t, env, tgt)
|
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
||||||
|
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
|
||||||
|
|
||||||
|
eng.ExportDeliverDatabase(webhookDB, d)
|
||||||
|
|
||||||
|
webhookID := event.WebhookID
|
||||||
|
|
||||||
require.True(
|
require.True(
|
||||||
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
|
t, eng.ExportHasArchiveWriter(webhookID),
|
||||||
|
"a delivery should have cached an archive writer",
|
||||||
|
)
|
||||||
|
require.True(
|
||||||
|
t, eng.ExportArchiveHandleOpen(webhookID),
|
||||||
"the writer should hold an open handle after a write",
|
"the writer should hold an open handle after a write",
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
env.eng.EvictWebhook(first.WebhookID)
|
eng.EvictWebhook(webhookID)
|
||||||
|
|
||||||
for _, tgt := range []*database.Target{first, second} {
|
|
||||||
assert.False(
|
assert.False(
|
||||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
t, eng.ExportHasArchiveWriter(webhookID),
|
||||||
"eviction should remove the registry entry",
|
"eviction should remove the registry entry",
|
||||||
)
|
)
|
||||||
assert.False(
|
assert.False(
|
||||||
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
|
t, eng.ExportArchiveHandleOpen(webhookID),
|
||||||
"eviction should close the archive handle",
|
"eviction should close the archive handle",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
archivePath := filepath.Join(
|
||||||
|
dataDir, fmt.Sprintf("archive-%s.db", webhookID),
|
||||||
|
)
|
||||||
assert.FileExists(
|
assert.FileExists(
|
||||||
t, env.archivePath(tgt),
|
t, archivePath,
|
||||||
"eviction must not delete the archive file",
|
"eviction must not delete the archive file",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.True(
|
|
||||||
t, env.eng.ExportArchiveHandleOpen(other.ID),
|
|
||||||
"another webhook's writer must be left alone",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEvictTarget_LeavesOtherTargets proves that evicting one
|
|
||||||
// database target leaves the writer of another target of the same
|
|
||||||
// webhook in place.
|
|
||||||
func TestEvictTarget_LeavesOtherTargets(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
|
||||||
doomed := env.seedDatabaseTarget(t, "")
|
|
||||||
kept := env.addDatabaseTarget(t, doomed.WebhookID, "")
|
|
||||||
|
|
||||||
deliverTo(t, env, doomed)
|
|
||||||
deliverTo(t, env, kept)
|
|
||||||
|
|
||||||
env.eng.EvictTarget(doomed.ID)
|
|
||||||
|
|
||||||
assert.False(t, env.eng.ExportHasArchiveWriter(doomed.ID))
|
|
||||||
assert.FileExists(
|
|
||||||
t, env.archivePath(doomed),
|
|
||||||
"eviction must not delete the archive file",
|
|
||||||
)
|
|
||||||
assert.True(
|
|
||||||
t, env.eng.ExportArchiveHandleOpen(kept.ID),
|
|
||||||
"the other target's writer must be left alone",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEvictWebhook_UnknownWebhookIsNoOp proves eviction is safe
|
// TestEvictWebhook_UnknownWebhookIsNoOp proves eviction is safe
|
||||||
// for the common case of a webhook or target that never had an
|
// for the common case of a webhook that never had a database
|
||||||
// archive writer, and that repeating it does not panic.
|
// target, and that repeating it does not panic.
|
||||||
func TestEvictWebhook_UnknownWebhookIsNoOp(t *testing.T) {
|
func TestEvictWebhook_UnknownWebhookIsNoOp(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
eng, _ := evictTestEngine(t)
|
||||||
|
|
||||||
assert.NotPanics(t, func() {
|
assert.NotPanics(t, func() {
|
||||||
env.eng.EvictWebhook("no-such-webhook")
|
eng.EvictWebhook("no-such-webhook")
|
||||||
env.eng.EvictWebhook("no-such-webhook")
|
eng.EvictWebhook("no-such-webhook")
|
||||||
env.eng.EvictTarget("no-such-target")
|
|
||||||
env.eng.EvictTarget("no-such-target")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
assert.False(
|
assert.False(
|
||||||
t, env.eng.ExportHasArchiveWriter("no-such-target"),
|
t, eng.ExportHasArchiveWriter("no-such-webhook"),
|
||||||
"eviction must not create a writer",
|
"eviction must not create a writer",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -313,14 +289,17 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
eng, _ := evictTestEngine(t)
|
||||||
tgt := env.seedDatabaseTarget(t, "")
|
|
||||||
|
webhookDB := testWebhookDB(t)
|
||||||
|
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
||||||
|
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
|
||||||
|
|
||||||
// Prime the registry so the test can hold the very writer the
|
// Prime the registry so the test can hold the very writer the
|
||||||
// eviction is about to detach.
|
// eviction is about to detach.
|
||||||
deliverTo(t, env, tgt)
|
eng.ExportDeliverDatabase(webhookDB, d)
|
||||||
|
|
||||||
w := env.eng.ExportArchiveWriterFor(tgt.ID)
|
w := eng.ExportArchiveWriterFor(event.WebhookID)
|
||||||
require.NotNil(t, w)
|
require.NotNil(t, w)
|
||||||
require.True(t, w.HandleOpen())
|
require.True(t, w.HandleOpen())
|
||||||
|
|
||||||
@@ -330,7 +309,7 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
|
|||||||
// eviction has to contend for the writer's mutex.
|
// eviction has to contend for the writer's mutex.
|
||||||
race.awaitFirstWrite()
|
race.awaitFirstWrite()
|
||||||
|
|
||||||
env.eng.EvictWebhook(tgt.WebhookID)
|
eng.EvictWebhook(event.WebhookID)
|
||||||
|
|
||||||
sawEvicted, otherErr := race.wait()
|
sawEvicted, otherErr := race.wait()
|
||||||
|
|
||||||
@@ -345,33 +324,41 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
|
|||||||
"been evicted",
|
"been evicted",
|
||||||
)
|
)
|
||||||
assert.False(
|
assert.False(
|
||||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
t, eng.ExportHasArchiveWriter(event.WebhookID),
|
||||||
"the registry entry must stay gone",
|
"the registry entry must stay gone",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEvictWebhook_LaterDeliveryRecreatesWriter proves eviction
|
// TestEvictWebhook_LaterDeliveryRecreatesWriter proves eviction
|
||||||
// does not break archiving for a target that is still alive: a
|
// does not break archiving for a webhook that is still alive: a
|
||||||
// subsequent delivery gets a brand new writer from the registry.
|
// subsequent delivery gets a brand new writer from the registry.
|
||||||
// It says nothing about the evicted writer itself — that is what
|
// It says nothing about the evicted writer itself — that is what
|
||||||
// TestEvictedWriter_WriteDoesNotReopenFile covers.
|
// TestEvictedWriter_WriteDoesNotReopenFile covers.
|
||||||
func TestEvictWebhook_LaterDeliveryRecreatesWriter(t *testing.T) {
|
func TestEvictWebhook_LaterDeliveryRecreatesWriter(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
eng, _ := evictTestEngine(t)
|
||||||
tgt := env.seedDatabaseTarget(t, "")
|
|
||||||
|
|
||||||
deliverTo(t, env, tgt)
|
webhookDB := testWebhookDB(t)
|
||||||
require.True(t, env.eng.ExportHasArchiveWriter(tgt.ID))
|
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
||||||
|
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
|
||||||
|
|
||||||
env.eng.EvictWebhook(tgt.WebhookID)
|
eng.ExportDeliverDatabase(webhookDB, d)
|
||||||
|
require.True(
|
||||||
|
t, eng.ExportHasArchiveWriter(event.WebhookID),
|
||||||
|
)
|
||||||
|
|
||||||
// A fresh delivery for the same target gets a brand new
|
eng.EvictWebhook(event.WebhookID)
|
||||||
|
|
||||||
|
// A fresh delivery for the same webhook gets a brand new
|
||||||
// writer from the registry, so archiving keeps working.
|
// writer from the registry, so archiving keeps working.
|
||||||
deliverTo(t, env, tgt)
|
second := seedDatabaseTargetDelivery(
|
||||||
|
t, webhookDB, event, "",
|
||||||
|
)
|
||||||
|
eng.ExportDeliverDatabase(webhookDB, second)
|
||||||
|
|
||||||
assert.True(
|
assert.True(
|
||||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
t, eng.ExportHasArchiveWriter(event.WebhookID),
|
||||||
"a later delivery should recreate the writer",
|
"a later delivery should recreate the writer",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -383,16 +370,19 @@ func TestEvictWebhook_LaterDeliveryRecreatesWriter(t *testing.T) {
|
|||||||
func TestEngineStop_WriteAfterStopIsRefused(t *testing.T) {
|
func TestEngineStop_WriteAfterStopIsRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
eng, _ := evictTestEngine(t)
|
||||||
tgt := env.seedDatabaseTarget(t, "")
|
|
||||||
|
|
||||||
deliverTo(t, env, tgt)
|
webhookDB := testWebhookDB(t)
|
||||||
|
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
||||||
|
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
|
||||||
|
|
||||||
w := env.eng.ExportArchiveWriterFor(tgt.ID)
|
eng.ExportDeliverDatabase(webhookDB, d)
|
||||||
|
|
||||||
|
w := eng.ExportArchiveWriterFor(event.WebhookID)
|
||||||
require.NotNil(t, w)
|
require.NotNil(t, w)
|
||||||
require.True(t, w.HandleOpen())
|
require.True(t, w.HandleOpen())
|
||||||
|
|
||||||
require.NoError(t, env.eng.ExportStop(context.Background()))
|
require.NoError(t, eng.ExportStop(context.Background()))
|
||||||
|
|
||||||
err := w.Write(evictTestRow("ev-after-stop"), 0)
|
err := w.Write(evictTestRow("ev-after-stop"), 0)
|
||||||
|
|
||||||
@@ -405,7 +395,7 @@ func TestEngineStop_WriteAfterStopIsRefused(t *testing.T) {
|
|||||||
"a refused write must not reopen the archive",
|
"a refused write must not reopen the archive",
|
||||||
)
|
)
|
||||||
assert.False(
|
assert.False(
|
||||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
t, eng.ExportHasArchiveWriter(event.WebhookID),
|
||||||
"the stop should empty the registry",
|
"the stop should empty the registry",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,386 +0,0 @@
|
|||||||
package delivery
|
|
||||||
|
|
||||||
import (
|
|
||||||
"compress/gzip"
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"gorm.io/driver/sqlite"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// archiveTableQuery counts the archive's table: 0 when the archive
|
|
||||||
// writer has created the file but not yet the table in it.
|
|
||||||
const archiveTableQuery = "SELECT count(*) FROM sqlite_master " +
|
|
||||||
"WHERE type = 'table' AND name = 'archived_events'"
|
|
||||||
|
|
||||||
// ArchivePath returns where a database target's archive file is: in
|
|
||||||
// the data directory, beside the webhook's event database, under the
|
|
||||||
// name ArchiveFileName gives it.
|
|
||||||
func ArchivePath(
|
|
||||||
dbMgr *database.WebhookDBManager,
|
|
||||||
webhook *database.Webhook,
|
|
||||||
target *database.Target,
|
|
||||||
) string {
|
|
||||||
return filepath.Join(
|
|
||||||
filepath.Dir(dbMgr.DBPath(webhook.ID)),
|
|
||||||
ArchiveFileName(webhook.Name, target.Name, target.ID),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ArchiveExportFileName returns the name a database target's archive
|
|
||||||
// downloads under:
|
|
||||||
// archive-WEBHOOKNAME-TARGETNAME-YYYYMMDDTHHMMSSZ.json.gz, the names
|
|
||||||
// made safe as in ArchiveFileName and the time in UTC.
|
|
||||||
func ArchiveExportFileName(
|
|
||||||
webhookName, targetName string, at time.Time,
|
|
||||||
) string {
|
|
||||||
return "archive-" + archiveNamePart(webhookName) + "-" +
|
|
||||||
archiveNamePart(targetName) + "-" +
|
|
||||||
at.UTC().Format("20060102T150405Z") + ".json.gz"
|
|
||||||
}
|
|
||||||
|
|
||||||
// ArchiveExport is a database target's archive listed for download. It
|
|
||||||
// opens one of the target's files at a time, only when its rows are
|
|
||||||
// about to be written out, and closes it before it opens the next, so
|
|
||||||
// an export holds at most one file open however many the target has.
|
|
||||||
//
|
|
||||||
// Each file is read on its own connection inside one read-only
|
|
||||||
// transaction, so its rows are written out as the file stood when it
|
|
||||||
// was opened. Archives are in WAL mode, where a reader works from a
|
|
||||||
// snapshot and never blocks a writer: archive writes go on while a file
|
|
||||||
// is open, and the export does not see them. SQLite cannot checkpoint
|
|
||||||
// a -wal past an open snapshot, so the open file's -wal grows until the
|
|
||||||
// export has written that file out.
|
|
||||||
type ArchiveExport struct {
|
|
||||||
// periods are the periods of the target's files when the export
|
|
||||||
// was listed, "" for the file without one, in the order
|
|
||||||
// archiveFiles lists them.
|
|
||||||
periods []string
|
|
||||||
|
|
||||||
// lock is held while currentPath is called and a file is opened,
|
|
||||||
// so that a rename, which holds it too, cannot move the file in
|
|
||||||
// between.
|
|
||||||
lock sync.Locker
|
|
||||||
|
|
||||||
// currentPath returns the path ArchivePath gives the target under
|
|
||||||
// the names stored for it now, which a rename may have changed since
|
|
||||||
// the export was listed.
|
|
||||||
currentPath func() (string, error)
|
|
||||||
|
|
||||||
log *slog.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// exportFile is one archive file opened for an export.
|
|
||||||
type exportFile struct {
|
|
||||||
db *sql.DB
|
|
||||||
tx *gorm.DB
|
|
||||||
|
|
||||||
// period is the period in the file's name, "" for none.
|
|
||||||
period string
|
|
||||||
|
|
||||||
// empty is true for a file without the archive's table yet.
|
|
||||||
empty bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// exportedName is how an export names its webhook and its target.
|
|
||||||
type exportedName struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewArchiveExport lists a database target's archive files for export,
|
|
||||||
// given the path ArchivePath gives it (see archiveFiles). It opens none
|
|
||||||
// of them. Its caller holds lock, which every rename of the target's
|
|
||||||
// files runs under, from reading the names path is made of until it
|
|
||||||
// returns, so the files it lists are the ones those names give.
|
|
||||||
//
|
|
||||||
// WriteGzipJSON, called without lock held, finds each file again by its
|
|
||||||
// period under the path currentPath gives, holding lock while it does
|
|
||||||
// and while it opens the file, so a rename during the export loses no
|
|
||||||
// file. A file that is gone by then, emptied by the sweep or moved
|
|
||||||
// away, is skipped. The export never creates a file: with no files, it
|
|
||||||
// has no rows.
|
|
||||||
func NewArchiveExport(
|
|
||||||
path string,
|
|
||||||
lock sync.Locker,
|
|
||||||
currentPath func() (string, error),
|
|
||||||
log *slog.Logger,
|
|
||||||
) (*ArchiveExport, error) {
|
|
||||||
files, err := archiveFiles(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
x := &ArchiveExport{lock: lock, currentPath: currentPath, log: log}
|
|
||||||
|
|
||||||
for _, file := range files {
|
|
||||||
x.periods = append(x.periods, file.period)
|
|
||||||
}
|
|
||||||
|
|
||||||
return x, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// openExportFile opens one archive file for an export and takes its
|
|
||||||
// snapshot. The transaction lasts as long as ctx does.
|
|
||||||
func openExportFile(
|
|
||||||
ctx context.Context, file archiveFile, log *slog.Logger,
|
|
||||||
) (*exportFile, error) {
|
|
||||||
db, err := database.OpenSQLite(file.path, archiveModeExisting)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("opening archive %s: %w", file.path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
gdb, err := gorm.Open(
|
|
||||||
sqlite.Dialector{Conn: db}, &gorm.Config{
|
|
||||||
// Never leave this at GORM's default. See
|
|
||||||
// internal/gormlog.
|
|
||||||
Logger: gormlog.New(log),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
_ = db.Close()
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("opening archive %s: %w", file.path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadOnly makes the driver begin a deferred transaction in place
|
|
||||||
// of the BEGIN IMMEDIATE the connection string asks for, so the
|
|
||||||
// export never takes the archive's write lock.
|
|
||||||
tx := gdb.WithContext(ctx).Begin(&sql.TxOptions{ReadOnly: true})
|
|
||||||
if tx.Error != nil {
|
|
||||||
_ = db.Close()
|
|
||||||
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"reading archive %s: %w", file.path, tx.Error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The transaction's first read is what takes the snapshot.
|
|
||||||
var tables int
|
|
||||||
|
|
||||||
err = tx.Raw(archiveTableQuery).Row().Scan(&tables)
|
|
||||||
if err != nil {
|
|
||||||
_ = tx.Rollback()
|
|
||||||
_ = db.Close()
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("reading archive %s: %w", file.path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &exportFile{
|
|
||||||
db: db, tx: tx, period: file.period, empty: tables == 0,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// WriteGzipJSON writes the export to w as one gzipped JSON object:
|
|
||||||
// webhook and target, each an id and a name; exported_at; and
|
|
||||||
// archived_events, one object per archived row, keyed by column name,
|
|
||||||
// the files in the order archiveFiles lists them. A row from a file
|
|
||||||
// named for a period has "period" beside its columns. A body that is
|
|
||||||
// not valid UTF-8 cannot be a JSON string, so it is written in base64,
|
|
||||||
// with "body_encoding": "base64" beside it.
|
|
||||||
//
|
|
||||||
// Each row is written out before the next is read, so neither the
|
|
||||||
// archive nor its JSON is ever held in memory whole, and each file is
|
|
||||||
// closed once its rows are written, before the next is opened. When it
|
|
||||||
// returns, no file is open. After an error the gzip stream is left
|
|
||||||
// unfinished, so what was written does not decompress as a whole file.
|
|
||||||
func (x *ArchiveExport) WriteGzipJSON(
|
|
||||||
ctx context.Context,
|
|
||||||
w io.Writer,
|
|
||||||
webhook *database.Webhook,
|
|
||||||
target *database.Target,
|
|
||||||
exportedAt time.Time,
|
|
||||||
) error {
|
|
||||||
head, err := json.Marshal(map[string]any{
|
|
||||||
"webhook": exportedName{ID: webhook.ID, Name: webhook.Name},
|
|
||||||
"target": exportedName{ID: target.ID, Name: target.Name},
|
|
||||||
"exported_at": exportedAt.UTC(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("encoding archive export: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
zw := gzip.NewWriter(w)
|
|
||||||
|
|
||||||
err = x.writeJSON(ctx, zw, head)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("writing archive export: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return zw.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// openFile finds the target's archive file for period under the path
|
|
||||||
// currentPath gives now, and opens it for the export, holding x.lock
|
|
||||||
// for both. For a file that is gone, the error wraps fs.ErrNotExist.
|
|
||||||
func (x *ArchiveExport) openFile(
|
|
||||||
ctx context.Context, period string,
|
|
||||||
) (*exportFile, error) {
|
|
||||||
x.lock.Lock()
|
|
||||||
defer x.lock.Unlock()
|
|
||||||
|
|
||||||
path, err := x.currentPath()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("finding archive file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
file := archiveFile{path: archivePeriodPath(path, period), period: period}
|
|
||||||
|
|
||||||
_, err = os.Stat(file.path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return openExportFile(ctx, file, x.log)
|
|
||||||
}
|
|
||||||
|
|
||||||
// close ends the file's transaction and closes its connection.
|
|
||||||
func (f *exportFile) close() error {
|
|
||||||
_ = f.tx.Rollback()
|
|
||||||
|
|
||||||
return f.db.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeJSON writes head with archived_events added as its last key,
|
|
||||||
// the rows going into it one at a time.
|
|
||||||
func (x *ArchiveExport) writeJSON(
|
|
||||||
ctx context.Context, w io.Writer, head []byte,
|
|
||||||
) error {
|
|
||||||
// head goes out without its closing brace, so that
|
|
||||||
// archived_events can follow it.
|
|
||||||
_, err := w.Write(head[:len(head)-1])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = io.WriteString(w, `,"archived_events":[`)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = x.writeRows(ctx, w)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = io.WriteString(w, "\n]}\n")
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeRows writes the archived rows of each file to w, one per line,
|
|
||||||
// separated by commas, opening each file in turn and closing it once
|
|
||||||
// its rows are written.
|
|
||||||
func (x *ArchiveExport) writeRows(ctx context.Context, w io.Writer) error {
|
|
||||||
sep := "\n"
|
|
||||||
|
|
||||||
for _, period := range x.periods {
|
|
||||||
f, err := x.openFile(ctx, period)
|
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
sep, err = f.writeRows(ctx, w, sep)
|
|
||||||
|
|
||||||
err = errors.Join(err, f.close())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeRows writes the file's archived rows to w, oldest first, the
|
|
||||||
// first after sep and each other after ",\n". It returns what goes
|
|
||||||
// before the next row: sep again when the file had no rows.
|
|
||||||
func (f *exportFile) writeRows(
|
|
||||||
ctx context.Context, w io.Writer, sep string,
|
|
||||||
) (string, error) {
|
|
||||||
if f.empty {
|
|
||||||
return sep, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := f.tx.WithContext(ctx).
|
|
||||||
Model(&archivedEvent{}).Order("id").Rows()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
|
||||||
|
|
||||||
for ; rows.Next(); sep = ",\n" {
|
|
||||||
var ev archivedEvent
|
|
||||||
|
|
||||||
err = f.tx.ScanRows(rows, &ev)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = io.WriteString(w, sep)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = writeRow(w, &ev, f.period)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sep, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeRow writes an archived row to w as a JSON object keyed by
|
|
||||||
// column name, its body in base64 when it is not valid UTF-8, with
|
|
||||||
// the period of its file beside them unless that is "".
|
|
||||||
func writeRow(w io.Writer, ev *archivedEvent, period string) error {
|
|
||||||
row := map[string]any{
|
|
||||||
"id": ev.ID,
|
|
||||||
"event_id": ev.EventID,
|
|
||||||
"webhook_id": ev.WebhookID,
|
|
||||||
"entrypoint_id": ev.EntrypointID,
|
|
||||||
"method": ev.Method,
|
|
||||||
"headers": ev.Headers,
|
|
||||||
"body": ev.Body,
|
|
||||||
"content_type": ev.ContentType,
|
|
||||||
"archived_at": ev.ArchivedAt.UTC(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if !utf8.ValidString(ev.Body) {
|
|
||||||
row["body"] = base64.StdEncoding.EncodeToString([]byte(ev.Body))
|
|
||||||
row["body_encoding"] = "base64"
|
|
||||||
}
|
|
||||||
|
|
||||||
if period != "" {
|
|
||||||
row["period"] = period
|
|
||||||
}
|
|
||||||
|
|
||||||
line, err := json.Marshal(row)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = w.Write(line)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -1,563 +0,0 @@
|
|||||||
package delivery_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"compress/gzip"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The webhook and the target the export tests' archives belong to.
|
|
||||||
const (
|
|
||||||
exportWebhookID = "wh-export"
|
|
||||||
exportWebhookName = "Orders (EU)"
|
|
||||||
exportTargetID = "tgt-export"
|
|
||||||
exportTargetName = "Long-term archive"
|
|
||||||
)
|
|
||||||
|
|
||||||
// binaryBody is a body that is not valid UTF-8.
|
|
||||||
const binaryBody = "\xff\xfe\x00\x01binary\x80"
|
|
||||||
|
|
||||||
// writeExportTo writes export to w as the archive of the export tests'
|
|
||||||
// webhook and target, exported at 2026-10-02T12:03:04Z.
|
|
||||||
func writeExportTo(
|
|
||||||
t *testing.T, export *delivery.ArchiveExport, w io.Writer,
|
|
||||||
) error {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
return export.WriteGzipJSON(
|
|
||||||
t.Context(), w,
|
|
||||||
&database.Webhook{
|
|
||||||
BaseModel: database.BaseModel{ID: exportWebhookID},
|
|
||||||
Name: exportWebhookName,
|
|
||||||
},
|
|
||||||
&database.Target{
|
|
||||||
BaseModel: database.BaseModel{ID: exportTargetID},
|
|
||||||
Name: exportTargetName,
|
|
||||||
},
|
|
||||||
time.Date(2026, 10, 2, 12, 3, 4, 0, time.UTC),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// listExport lists the archive at path for export, as the archive of a
|
|
||||||
// target whose names do not change.
|
|
||||||
func listExport(t *testing.T, path string) *delivery.ArchiveExport {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
return newExport(t, path, &sync.Mutex{}, func() (string, error) {
|
|
||||||
return path, nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// newExport lists the archive at path for export, to find each file
|
|
||||||
// again under the path currentPath gives, holding lock while it does.
|
|
||||||
// Nothing else takes lock while it lists, so it does not hold lock.
|
|
||||||
func newExport(
|
|
||||||
t *testing.T,
|
|
||||||
path string,
|
|
||||||
lock sync.Locker,
|
|
||||||
currentPath func() (string, error),
|
|
||||||
) *delivery.ArchiveExport {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
export, err := delivery.NewArchiveExport(
|
|
||||||
path, lock, currentPath, archiveTestLogger(),
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
return export
|
|
||||||
}
|
|
||||||
|
|
||||||
// exportArchive runs a whole export of the archive at path and returns
|
|
||||||
// its JSON, decompressed and parsed.
|
|
||||||
func exportArchive(t *testing.T, path string) map[string]any {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
return writeExport(t, listExport(t, path))
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeExport writes an opened export and returns its JSON,
|
|
||||||
// decompressed and parsed. Reading to the end makes the gzip reader
|
|
||||||
// check that the stream was finished.
|
|
||||||
func writeExport(
|
|
||||||
t *testing.T, export *delivery.ArchiveExport,
|
|
||||||
) map[string]any {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
require.NoError(t, writeExportTo(t, export, &buf))
|
|
||||||
|
|
||||||
zr, err := gzip.NewReader(&buf)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
raw, err := io.ReadAll(zr)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var got map[string]any
|
|
||||||
|
|
||||||
require.NoError(t, json.Unmarshal(raw, &got))
|
|
||||||
|
|
||||||
return got
|
|
||||||
}
|
|
||||||
|
|
||||||
// exportedEvents returns an export's archived_events.
|
|
||||||
func exportedEvents(t *testing.T, got map[string]any) []map[string]any {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
list, ok := got["archived_events"].([]any)
|
|
||||||
require.True(t, ok, "archived_events must be an array: %v", got)
|
|
||||||
|
|
||||||
events := make([]map[string]any, len(list))
|
|
||||||
|
|
||||||
for i, v := range list {
|
|
||||||
events[i], ok = v.(map[string]any)
|
|
||||||
require.True(t, ok, "an archived event must be an object: %v", v)
|
|
||||||
}
|
|
||||||
|
|
||||||
return events
|
|
||||||
}
|
|
||||||
|
|
||||||
// exportedEventIDs returns the event_id of each of an export's
|
|
||||||
// archived_events.
|
|
||||||
func exportedEventIDs(t *testing.T, got map[string]any) []string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
events := exportedEvents(t, got)
|
|
||||||
ids := make([]string, 0, len(events))
|
|
||||||
|
|
||||||
for _, ev := range events {
|
|
||||||
ids = append(ids, fmt.Sprint(ev["event_id"]))
|
|
||||||
}
|
|
||||||
|
|
||||||
return ids
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchiveExport_MatchesStoredRows proves an export holds the
|
|
||||||
// webhook, the target, the time, and every column of every stored
|
|
||||||
// row: a body that is valid UTF-8 as a string, and one that is not in
|
|
||||||
// base64, marked as such.
|
|
||||||
func TestArchiveExport_MatchesStoredRows(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "archive.db")
|
|
||||||
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
|
||||||
bodies := []string{`{"order":1}`, "plain text", "", binaryBody}
|
|
||||||
|
|
||||||
for i, body := range bodies {
|
|
||||||
require.NoError(t, w.Write(delivery.ExportArchivedEvent{
|
|
||||||
EventID: fmt.Sprintf("ev-%d", i),
|
|
||||||
WebhookID: exportWebhookID,
|
|
||||||
EntrypointID: "ep-1",
|
|
||||||
Method: "POST",
|
|
||||||
Headers: `{"X-Test":["yes"]}`,
|
|
||||||
Body: body,
|
|
||||||
ContentType: testContentType,
|
|
||||||
}, 0))
|
|
||||||
}
|
|
||||||
|
|
||||||
var stored []delivery.ExportArchivedEvent
|
|
||||||
|
|
||||||
require.NoError(t, openArchiveDBForRead(t, path).
|
|
||||||
Order("id").Find(&stored).Error)
|
|
||||||
|
|
||||||
got := exportArchive(t, path)
|
|
||||||
|
|
||||||
assert.Equal(t,
|
|
||||||
map[string]any{"id": exportWebhookID, "name": exportWebhookName},
|
|
||||||
got["webhook"],
|
|
||||||
)
|
|
||||||
assert.Equal(t,
|
|
||||||
map[string]any{"id": exportTargetID, "name": exportTargetName},
|
|
||||||
got["target"],
|
|
||||||
)
|
|
||||||
assert.Equal(t, "2026-10-02T12:03:04Z", got["exported_at"])
|
|
||||||
|
|
||||||
events := exportedEvents(t, got)
|
|
||||||
require.Len(t, events, len(bodies))
|
|
||||||
|
|
||||||
for i, row := range stored {
|
|
||||||
assertExportedRow(t, row, events[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertExportedRow checks that ev, from an export, holds every column
|
|
||||||
// of the stored row.
|
|
||||||
func assertExportedRow(
|
|
||||||
t *testing.T, row delivery.ExportArchivedEvent, ev map[string]any,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
archivedAt, err := time.Parse(
|
|
||||||
time.RFC3339Nano, fmt.Sprint(ev["archived_at"]),
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, archivedAt.Equal(row.ArchivedAt))
|
|
||||||
|
|
||||||
assert.EqualValues(t, row.ID, ev["id"])
|
|
||||||
assert.Equal(t, row.EventID, ev["event_id"])
|
|
||||||
assert.Equal(t, row.WebhookID, ev["webhook_id"])
|
|
||||||
assert.Equal(t, row.EntrypointID, ev["entrypoint_id"])
|
|
||||||
assert.Equal(t, row.Method, ev["method"])
|
|
||||||
assert.Equal(t, row.Headers, ev["headers"])
|
|
||||||
assert.Equal(t, row.ContentType, ev["content_type"])
|
|
||||||
|
|
||||||
if row.Body != binaryBody {
|
|
||||||
assert.Equal(t, row.Body, ev["body"])
|
|
||||||
assert.Len(t, ev, 9, "the nine columns and nothing else: %v", ev)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := base64.StdEncoding.DecodeString(fmt.Sprint(ev["body"]))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, binaryBody, string(body))
|
|
||||||
assert.Equal(t, "base64", ev["body_encoding"])
|
|
||||||
assert.Len(t, ev, 10, "the nine columns and body_encoding: %v", ev)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchiveExport_Empty proves an archive with nothing in it exports
|
|
||||||
// as an empty archived_events: no file, which the export must not
|
|
||||||
// create; a file the archive writer has not yet put its table in; and
|
|
||||||
// a table with no rows.
|
|
||||||
func TestArchiveExport_Empty(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
missing := filepath.Join(dir, "missing.db")
|
|
||||||
noTable := filepath.Join(dir, "no-table.db")
|
|
||||||
noRows := filepath.Join(dir, "no-rows.db")
|
|
||||||
|
|
||||||
require.NoError(t, os.WriteFile(noTable, nil, 0o600))
|
|
||||||
require.NoError(t,
|
|
||||||
delivery.NewExportArchiveWriter(noRows, archiveTestLogger(), 0).
|
|
||||||
Open(0),
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, path := range []string{missing, noTable, noRows} {
|
|
||||||
assert.Empty(t, exportedEvents(t, exportArchive(t, path)), path)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, suffix := range archiveFileSuffixes() {
|
|
||||||
assert.NoFileExists(t, missing+suffix)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// unlockHook is a sync.Locker that runs fn each time it is unlocked. An
|
|
||||||
// export unlocks its lock right after it opens a file.
|
|
||||||
type unlockHook struct {
|
|
||||||
sync.Mutex
|
|
||||||
|
|
||||||
fn func()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *unlockHook) Unlock() {
|
|
||||||
u.Mutex.Unlock()
|
|
||||||
u.fn()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchiveExport_ReadsOneSnapshot proves an export writes a file
|
|
||||||
// out as it was when the export opened it, and holds up no archive
|
|
||||||
// write: a row written after the export was listed but before the file
|
|
||||||
// was opened is in the export, and one written while the file is open
|
|
||||||
// is stored, and is not. A write held up for the whole busy timeout
|
|
||||||
// would fail.
|
|
||||||
func TestArchiveExport_ReadsOneSnapshot(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "archive.db")
|
|
||||||
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
|
||||||
|
|
||||||
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "listed"}, 0))
|
|
||||||
|
|
||||||
opened := &unlockHook{fn: func() {
|
|
||||||
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "during"}, 0))
|
|
||||||
}}
|
|
||||||
export := newExport(t, path, opened, func() (string, error) {
|
|
||||||
return path, nil
|
|
||||||
})
|
|
||||||
|
|
||||||
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "before-open"}, 0))
|
|
||||||
|
|
||||||
assert.Equal(t,
|
|
||||||
[]string{"listed", "before-open"},
|
|
||||||
exportedEventIDs(t, writeExport(t, export)),
|
|
||||||
)
|
|
||||||
|
|
||||||
var stored int64
|
|
||||||
|
|
||||||
require.NoError(t, openArchiveDBForRead(t, path).
|
|
||||||
Model(&delivery.ExportArchivedEvent{}).Count(&stored).Error)
|
|
||||||
assert.Equal(t, int64(3), stored)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchiveExport_FindsFilesAfterRename proves that renaming the
|
|
||||||
// archive after an export has listed it, as renaming its webhook or
|
|
||||||
// target does, loses no file: the export finds each file again by its
|
|
||||||
// period under the new name. A file moved away by then is skipped.
|
|
||||||
func TestArchiveExport_FindsFilesAfterRename(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "archive-old.db")
|
|
||||||
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
|
||||||
|
|
||||||
for _, period := range []string{"", dayPeriod, hourPeriod} {
|
|
||||||
require.NoError(t, w.WritePeriod(
|
|
||||||
delivery.ExportArchivedEvent{EventID: "in-" + period}, 0, period,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
current := path
|
|
||||||
export := newExport(t, path, &sync.Mutex{}, func() (string, error) {
|
|
||||||
return current, nil
|
|
||||||
})
|
|
||||||
|
|
||||||
require.NoError(t, w.Rename("archive-new.db"))
|
|
||||||
|
|
||||||
current = filepath.Join(dir, "archive-new.db")
|
|
||||||
|
|
||||||
removeArchiveFiles(t, periodPath(current, dayPeriod))
|
|
||||||
|
|
||||||
assert.Equal(t,
|
|
||||||
[]string{"in-", "in-" + hourPeriod},
|
|
||||||
exportedEventIDs(t, writeExport(t, export)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchiveExport_EveryFileOldestFirst writes a row to a target's
|
|
||||||
// file without a period and to its files for a month, an hour and a
|
|
||||||
// day, and proves the export holds every row: the file without a
|
|
||||||
// period first, then the others oldest period first, each row from a
|
|
||||||
// file named for a period carrying that period. A file made after the
|
|
||||||
// export was listed is not in it.
|
|
||||||
func TestArchiveExport_EveryFileOldestFirst(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
|
||||||
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
|
||||||
|
|
||||||
// Written in an order that is not the export's.
|
|
||||||
for _, period := range []string{nextDayPeriod, "", hourPeriod, "2026-03"} {
|
|
||||||
require.NoError(t, w.WritePeriod(
|
|
||||||
delivery.ExportArchivedEvent{EventID: "in-" + period}, 0, period,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
export := listExport(t, path)
|
|
||||||
|
|
||||||
require.NoError(t, w.WritePeriod(
|
|
||||||
delivery.ExportArchivedEvent{EventID: "later"}, 0, "2026-03-06",
|
|
||||||
))
|
|
||||||
|
|
||||||
events := exportedEvents(t, writeExport(t, export))
|
|
||||||
ids := make([]string, 0, len(events))
|
|
||||||
periods := make([]any, 0, len(events))
|
|
||||||
|
|
||||||
for _, ev := range events {
|
|
||||||
ids = append(ids, fmt.Sprint(ev["event_id"]))
|
|
||||||
periods = append(periods, ev["period"])
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t,
|
|
||||||
[]string{"in-", "in-2026-03", "in-" + hourPeriod, "in-" + nextDayPeriod},
|
|
||||||
ids,
|
|
||||||
)
|
|
||||||
assert.Equal(t,
|
|
||||||
[]any{nil, "2026-03", hourPeriod, nextDayPeriod}, periods,
|
|
||||||
)
|
|
||||||
assert.NotContains(t, events[0], "period",
|
|
||||||
"a row from the file without a period has no period")
|
|
||||||
}
|
|
||||||
|
|
||||||
// openFilesPeak is an io.Writer that discards what it is given and
|
|
||||||
// records the most archive files in dir the process had open at any
|
|
||||||
// write, as /proc/self/fd lists the files a process has open.
|
|
||||||
type openFilesPeak struct {
|
|
||||||
dir string
|
|
||||||
max int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *openFilesPeak) Write(b []byte) (int, error) {
|
|
||||||
fds, err := os.ReadDir("/proc/self/fd")
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
open := map[string]bool{}
|
|
||||||
|
|
||||||
for _, fd := range fds {
|
|
||||||
file, err := os.Readlink(filepath.Join("/proc/self/fd", fd.Name()))
|
|
||||||
if err == nil && filepath.Dir(file) == p.dir &&
|
|
||||||
strings.HasSuffix(file, ".db") {
|
|
||||||
open[file] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p.max = max(p.max, len(open))
|
|
||||||
|
|
||||||
return len(b), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchiveExport_OneFileOpenAtATime exports a target with a file for
|
|
||||||
// each of 24 hours and proves the export never had more than one of
|
|
||||||
// them open, and had one open while it wrote. Each file holds a row of
|
|
||||||
// 48 KiB of random base64, which gzip shrinks little, so the export
|
|
||||||
// writes output while it reads each file.
|
|
||||||
func TestArchiveExport_OneFileOpenAtATime(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
if runtime.GOOS != "linux" {
|
|
||||||
t.Skip("only Linux lists a process's open files in /proc/self/fd")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Readlink gives each open file's path with no symbolic link in it.
|
|
||||||
dir, err := filepath.EvalSymlinks(t.TempDir())
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
path := filepath.Join(dir, "archive-wh.db")
|
|
||||||
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
|
||||||
random := make([]byte, 36<<10)
|
|
||||||
|
|
||||||
for hour := range 24 {
|
|
||||||
_, _ = rand.Read(random)
|
|
||||||
|
|
||||||
require.NoError(t, w.WritePeriod(delivery.ExportArchivedEvent{
|
|
||||||
Body: base64.StdEncoding.EncodeToString(random),
|
|
||||||
}, 0, fmt.Sprintf("2026-10-01-%02d", hour)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// The writer's own handle on the last file is not the export's.
|
|
||||||
w.Evict()
|
|
||||||
|
|
||||||
// Through a buffer, the open files are listed once per 8 KiB of
|
|
||||||
// output, a few times for each file, rather than at each of gzip's
|
|
||||||
// small writes, which takes far longer.
|
|
||||||
peak := &openFilesPeak{dir: dir}
|
|
||||||
buffered := bufio.NewWriterSize(peak, 8<<10)
|
|
||||||
|
|
||||||
require.NoError(t, writeExportTo(t, listExport(t, path), buffered))
|
|
||||||
require.NoError(t, buffered.Flush())
|
|
||||||
assert.Equal(t, 1, peak.max)
|
|
||||||
}
|
|
||||||
|
|
||||||
// heapPeak is an io.Writer that discards what it is given and records
|
|
||||||
// the largest heap it saw at a write. It collects garbage before each
|
|
||||||
// reading, so the heap it reads is what is still held.
|
|
||||||
type heapPeak struct {
|
|
||||||
max uint64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *heapPeak) Write(b []byte) (int, error) {
|
|
||||||
var m runtime.MemStats
|
|
||||||
|
|
||||||
runtime.GC()
|
|
||||||
runtime.ReadMemStats(&m)
|
|
||||||
p.max = max(p.max, m.HeapAlloc)
|
|
||||||
|
|
||||||
return len(b), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// exportHeapGrowth exports an archive of rows random bodies, each
|
|
||||||
// bodySize bytes of base64, and returns how far the heap rose above
|
|
||||||
// where it stood when the export began, at its highest.
|
|
||||||
func exportHeapGrowth(t *testing.T, rows, bodySize int) uint64 {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "archive.db")
|
|
||||||
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
|
||||||
|
|
||||||
// Base64 makes four characters of every three bytes.
|
|
||||||
random := make([]byte, bodySize/4*3)
|
|
||||||
|
|
||||||
for range rows {
|
|
||||||
_, _ = rand.Read(random)
|
|
||||||
|
|
||||||
require.NoError(t, w.Write(delivery.ExportArchivedEvent{
|
|
||||||
Body: base64.StdEncoding.EncodeToString(random),
|
|
||||||
}, 0))
|
|
||||||
}
|
|
||||||
|
|
||||||
export := listExport(t, path)
|
|
||||||
|
|
||||||
runtime.GC()
|
|
||||||
|
|
||||||
var start runtime.MemStats
|
|
||||||
|
|
||||||
runtime.ReadMemStats(&start)
|
|
||||||
|
|
||||||
// Through a buffer, the heap is read once per 8 KiB of output
|
|
||||||
// rather than at each of gzip's small writes, which takes far
|
|
||||||
// longer.
|
|
||||||
peak := &heapPeak{max: start.HeapAlloc}
|
|
||||||
buffered := bufio.NewWriterSize(peak, 8<<10)
|
|
||||||
|
|
||||||
require.NoError(t, writeExportTo(t, export, buffered))
|
|
||||||
require.NoError(t, buffered.Flush())
|
|
||||||
|
|
||||||
return peak.max - start.HeapAlloc
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchiveExport_Streams proves an export holds neither the archive
|
|
||||||
// nor its output in memory whole: exporting 384 KiB more of archive
|
|
||||||
// raises the heap's peak by less than half of that. The export's own
|
|
||||||
// memory, mostly gzip's compressor, is the same for both archives, so
|
|
||||||
// it cancels out. The bodies are random bytes in base64, which gzip
|
|
||||||
// shrinks by only a quarter, so an export that read every row before
|
|
||||||
// writing, or built the JSON or the gzipped file before writing it,
|
|
||||||
// would raise the peak by at least three quarters of the difference.
|
|
||||||
//
|
|
||||||
// The smaller archive has two rows so that its export, too, writes
|
|
||||||
// out more than the 8 KiB buffer in exportHeapGrowth before it ends:
|
|
||||||
// the heap must be read while the export's own memory is held.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // It measures the heap, which tests share.
|
|
||||||
func TestArchiveExport_Streams(t *testing.T) {
|
|
||||||
const (
|
|
||||||
bodySize = 16 << 10
|
|
||||||
smallRows = 2
|
|
||||||
largeRows = smallRows + 24
|
|
||||||
limit = (largeRows - smallRows) * bodySize / 2
|
|
||||||
)
|
|
||||||
|
|
||||||
small := exportHeapGrowth(t, smallRows, bodySize)
|
|
||||||
large := exportHeapGrowth(t, largeRows, bodySize)
|
|
||||||
|
|
||||||
assert.Less(t, large, small+limit,
|
|
||||||
"the heap rose by %d for %d rows and by %d for %d rows",
|
|
||||||
small, smallRows, large, largeRows,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchiveExportFileName proves the download is named for the
|
|
||||||
// webhook and the target, with the names made safe as for the archive
|
|
||||||
// file, and the export time in UTC.
|
|
||||||
func TestArchiveExportFileName(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cest := time.FixedZone("CEST", int((2 * time.Hour).Seconds()))
|
|
||||||
|
|
||||||
assert.Equal(t,
|
|
||||||
"archive-orders-eu-long-term-archive-20261002T120304Z.json.gz",
|
|
||||||
delivery.ArchiveExportFileName(
|
|
||||||
exportWebhookName, exportTargetName,
|
|
||||||
time.Date(2026, 10, 2, 14, 3, 4, 0, cest),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
package delivery
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The archive rotations: how often a database target starts a new
|
|
||||||
// archive file. Every rotation but none puts the period of an event's
|
|
||||||
// receive time, in UTC, in the name of the file the event goes to,
|
|
||||||
// written in the layout beside it.
|
|
||||||
const (
|
|
||||||
archiveRotationNone = "none"
|
|
||||||
archiveRotationMonthly = "monthly"
|
|
||||||
archiveRotationDaily = "daily"
|
|
||||||
archiveRotationHourly = "hourly"
|
|
||||||
|
|
||||||
archiveMonthLayout = "2006-01"
|
|
||||||
archiveDayLayout = "2006-01-02"
|
|
||||||
archiveHourLayout = "2006-01-02-15"
|
|
||||||
)
|
|
||||||
|
|
||||||
// errArchiveRotationUnknown is returned for a rotation that is not one
|
|
||||||
// of the four.
|
|
||||||
var errArchiveRotationUnknown = errors.New(
|
|
||||||
"rotation must be none, monthly, daily or hourly",
|
|
||||||
)
|
|
||||||
|
|
||||||
// archiveFile is one of a database target's archive files, and the
|
|
||||||
// period in its name: "" for the file named without one.
|
|
||||||
type archiveFile struct {
|
|
||||||
path string
|
|
||||||
period string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateArchiveRotation checks a user-supplied archive rotation for
|
|
||||||
// a database target: empty or none (both meaning one file), monthly,
|
|
||||||
// daily or hourly.
|
|
||||||
func ValidateArchiveRotation(rotation string) error {
|
|
||||||
switch rotation {
|
|
||||||
case "", archiveRotationNone, archiveRotationMonthly,
|
|
||||||
archiveRotationDaily, archiveRotationHourly:
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("%w: %q", errArchiveRotationUnknown, rotation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseArchiveRotation reads the rotation from a database target's
|
|
||||||
// config JSON. An empty config or an empty rotation is none.
|
|
||||||
func parseArchiveRotation(configJSON string) (string, error) {
|
|
||||||
if configJSON == "" {
|
|
||||||
return archiveRotationNone, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var cfg databaseTargetConfig
|
|
||||||
|
|
||||||
err := json.Unmarshal([]byte(configJSON), &cfg)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("parsing database target config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = ValidateArchiveRotation(cfg.Rotation)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.Rotation == "" {
|
|
||||||
return archiveRotationNone, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg.Rotation, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// archivePeriod returns the period, in UTC, that a rotation puts an
|
|
||||||
// event received at receivedAt in: "2026-10" for monthly,
|
|
||||||
// "2026-10-01" for daily, "2026-10-01-19" for hourly, and "" for none.
|
|
||||||
func archivePeriod(rotation string, receivedAt time.Time) string {
|
|
||||||
switch rotation {
|
|
||||||
case archiveRotationMonthly:
|
|
||||||
return receivedAt.UTC().Format(archiveMonthLayout)
|
|
||||||
case archiveRotationDaily:
|
|
||||||
return receivedAt.UTC().Format(archiveDayLayout)
|
|
||||||
case archiveRotationHourly:
|
|
||||||
return receivedAt.UTC().Format(archiveHourLayout)
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// archivePeriodPath returns the path of a database target's archive
|
|
||||||
// file for a period: path, as ArchivePath gives it, with "-" and the
|
|
||||||
// period put before its ".db". The period "" gives path itself.
|
|
||||||
func archivePeriodPath(path, period string) string {
|
|
||||||
if period == "" {
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.TrimSuffix(path, ".db") + "-" + period + ".db"
|
|
||||||
}
|
|
||||||
|
|
||||||
// ArchivePathAt returns the archive file a database target writes an
|
|
||||||
// event received at receivedAt to: ArchivePath's file, with the
|
|
||||||
// period in its name when the target rotates.
|
|
||||||
func ArchivePathAt(
|
|
||||||
dbMgr *database.WebhookDBManager,
|
|
||||||
webhook *database.Webhook,
|
|
||||||
target *database.Target,
|
|
||||||
receivedAt time.Time,
|
|
||||||
) (string, error) {
|
|
||||||
rotation, err := parseArchiveRotation(target.Config)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return archivePeriodPath(
|
|
||||||
ArchivePath(dbMgr, webhook, target),
|
|
||||||
archivePeriod(rotation, receivedAt),
|
|
||||||
), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// archiveFiles lists the database target's archive files that exist,
|
|
||||||
// given the path ArchivePath gives it: the file at path, then each
|
|
||||||
// file archivePeriodPath names for path and a period, oldest period
|
|
||||||
// first. Which rotation wrote a file does not matter, so the files of
|
|
||||||
// an earlier rotation setting are listed too.
|
|
||||||
func archiveFiles(path string) ([]archiveFile, error) {
|
|
||||||
dir := filepath.Dir(path)
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(dir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("listing archive files: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stem := strings.TrimSuffix(filepath.Base(path), ".db")
|
|
||||||
|
|
||||||
var files []archiveFile
|
|
||||||
|
|
||||||
for _, entry := range entries {
|
|
||||||
period, ok := archiveFilePeriod(stem, entry.Name())
|
|
||||||
if ok {
|
|
||||||
files = append(files, archiveFile{
|
|
||||||
path: filepath.Join(dir, entry.Name()),
|
|
||||||
period: period,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A month sorts before the days and hours in it.
|
|
||||||
slices.SortFunc(files, func(a, b archiveFile) int {
|
|
||||||
return strings.Compare(a.period, b.period)
|
|
||||||
})
|
|
||||||
|
|
||||||
return files, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// archiveFilePeriod reports whether name is the name of an archive
|
|
||||||
// file of the target whose file name without a period is stem+".db",
|
|
||||||
// and the period in it.
|
|
||||||
func archiveFilePeriod(stem, name string) (string, bool) {
|
|
||||||
rest, ok := strings.CutPrefix(name, stem)
|
|
||||||
if !ok {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
rest, ok = strings.CutSuffix(rest, ".db")
|
|
||||||
if !ok {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
if rest == "" {
|
|
||||||
return "", true
|
|
||||||
}
|
|
||||||
|
|
||||||
period, ok := strings.CutPrefix(rest, "-")
|
|
||||||
if !ok {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, layout := range []string{
|
|
||||||
archiveMonthLayout, archiveDayLayout, archiveHourLayout,
|
|
||||||
} {
|
|
||||||
_, err := time.Parse(layout, period)
|
|
||||||
if err == nil {
|
|
||||||
return period, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
package delivery_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The archive rotations, and the configs of a daily target and of one
|
|
||||||
// whose rotation is not one of the four.
|
|
||||||
const (
|
|
||||||
rotationNone = "none"
|
|
||||||
rotationMonthly = "monthly"
|
|
||||||
rotationDaily = "daily"
|
|
||||||
rotationHourly = "hourly"
|
|
||||||
|
|
||||||
dailyConfig = `{"rotation":"daily"}`
|
|
||||||
weeklyConfig = `{"rotation":"weekly"}`
|
|
||||||
)
|
|
||||||
|
|
||||||
// The periods the tests archive into most: two days, and an hour of
|
|
||||||
// the first.
|
|
||||||
const (
|
|
||||||
dayPeriod = "2026-03-04"
|
|
||||||
nextDayPeriod = "2026-03-05"
|
|
||||||
hourPeriod = "2026-03-04-05"
|
|
||||||
)
|
|
||||||
|
|
||||||
// periodPath returns the archive file for a period of the target whose
|
|
||||||
// file without a period is path.
|
|
||||||
func periodPath(path, period string) string {
|
|
||||||
return strings.TrimSuffix(path, ".db") + "-" + period + ".db"
|
|
||||||
}
|
|
||||||
|
|
||||||
// deliverReceivedAt delivers to a database target an event whose
|
|
||||||
// receive time is receivedAt, and returns the event's id. The receive
|
|
||||||
// time is what decides a rotated archive's file, so setting it is how
|
|
||||||
// these tests move the clock across a period boundary.
|
|
||||||
func (env *archiveEnv) deliverReceivedAt(
|
|
||||||
t *testing.T, tgt *database.Target, receivedAt time.Time,
|
|
||||||
) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
webhookDB := testWebhookDB(t)
|
|
||||||
event := seedEvent(t, webhookDB, `{"n":1}`)
|
|
||||||
event.CreatedAt = receivedAt
|
|
||||||
|
|
||||||
env.eng.ExportDeliverDatabase(
|
|
||||||
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
|
|
||||||
)
|
|
||||||
|
|
||||||
return event.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDeliverDatabase_RotatesAtEachPeriodBoundary delivers, for each
|
|
||||||
// rotation, an event received in the last second of a period and one
|
|
||||||
// received in the first second of the next, and checks each lands in
|
|
||||||
// the file named for its own period, in UTC. Rotation none keeps both
|
|
||||||
// in the one file.
|
|
||||||
func TestDeliverDatabase_RotatesAtEachPeriodBoundary(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
berlin := time.FixedZone("CEST", 2*60*60)
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
rotation string
|
|
||||||
before, after time.Time
|
|
||||||
// periods are the periods of before and after.
|
|
||||||
periods [2]string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
rotationMonthly, rotationMonthly,
|
|
||||||
time.Date(2026, 1, 31, 23, 59, 59, 0, time.UTC),
|
|
||||||
time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC),
|
|
||||||
[2]string{"2026-01", "2026-02"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rotationDaily, rotationDaily,
|
|
||||||
time.Date(2026, 3, 4, 23, 59, 59, 0, time.UTC),
|
|
||||||
time.Date(2026, 3, 5, 0, 0, 0, 0, time.UTC),
|
|
||||||
[2]string{dayPeriod, nextDayPeriod},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// The same instants, received in a zone two hours ahead
|
|
||||||
// of UTC, where they fall on 5 March: the period is UTC's.
|
|
||||||
"daily in another zone", rotationDaily,
|
|
||||||
time.Date(2026, 3, 5, 1, 59, 59, 0, berlin),
|
|
||||||
time.Date(2026, 3, 5, 2, 0, 0, 0, berlin),
|
|
||||||
[2]string{dayPeriod, nextDayPeriod},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rotationHourly, rotationHourly,
|
|
||||||
time.Date(2026, 3, 4, 5, 59, 59, 0, time.UTC),
|
|
||||||
time.Date(2026, 3, 4, 6, 0, 0, 0, time.UTC),
|
|
||||||
[2]string{hourPeriod, "2026-03-04-06"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
|
||||||
tgt := env.seedDatabaseTarget(t, `{"rotation":"`+tc.rotation+`"}`)
|
|
||||||
path := env.archivePath(tgt)
|
|
||||||
|
|
||||||
first := env.deliverReceivedAt(t, tgt, tc.before)
|
|
||||||
second := env.deliverReceivedAt(t, tgt, tc.after)
|
|
||||||
|
|
||||||
assert.Equal(t, []string{first},
|
|
||||||
archivedEventIDs(t, periodPath(path, tc.periods[0])))
|
|
||||||
assert.Equal(t, []string{second},
|
|
||||||
archivedEventIDs(t, periodPath(path, tc.periods[1])))
|
|
||||||
assert.NoFileExists(t, path,
|
|
||||||
"a rotated target never writes the file without a period")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run(rotationNone, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
|
||||||
tgt := env.seedDatabaseTarget(t, `{"rotation":"`+rotationNone+`"}`)
|
|
||||||
|
|
||||||
first := env.deliverReceivedAt(t, tgt, cases[0].before)
|
|
||||||
second := env.deliverReceivedAt(t, tgt, cases[0].after)
|
|
||||||
|
|
||||||
assert.ElementsMatch(t, []string{first, second},
|
|
||||||
archivedEventIDs(t, env.archivePath(tgt)))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDeliverDatabase_EventWithoutReceiveTime proves an event whose
|
|
||||||
// receive time is not known goes to the file for the time it is
|
|
||||||
// archived, rather than to one for the year 1.
|
|
||||||
func TestDeliverDatabase_EventWithoutReceiveTime(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
|
||||||
tgt := env.seedDatabaseTarget(t, `{"rotation":"`+rotationMonthly+`"}`)
|
|
||||||
path := env.archivePath(tgt)
|
|
||||||
|
|
||||||
before := time.Now().UTC().Format("2006-01")
|
|
||||||
id := env.deliverReceivedAt(t, tgt, time.Time{})
|
|
||||||
|
|
||||||
file := periodPath(path, time.Now().UTC().Format("2006-01"))
|
|
||||||
|
|
||||||
_, err := os.Stat(file)
|
|
||||||
if err != nil {
|
|
||||||
// The month turned during the delivery.
|
|
||||||
file = periodPath(path, before)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, []string{id}, archivedEventIDs(t, file))
|
|
||||||
assert.NoFileExists(t, periodPath(path, "0001-01"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDeliverDatabase_RotationChangeKeepsOldFiles changes a target's
|
|
||||||
// rotation from none to daily between two events, and checks the
|
|
||||||
// second goes to the daily file while the first stays where it was.
|
|
||||||
func TestDeliverDatabase_RotationChangeKeepsOldFiles(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
|
||||||
tgt := env.seedDatabaseTarget(t, "")
|
|
||||||
path := env.archivePath(tgt)
|
|
||||||
at := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
first := env.deliverReceivedAt(t, tgt, at)
|
|
||||||
|
|
||||||
tgt.Config = dailyConfig
|
|
||||||
second := env.deliverReceivedAt(t, tgt, at)
|
|
||||||
|
|
||||||
assert.Equal(t, []string{first}, archivedEventIDs(t, path))
|
|
||||||
assert.Equal(t, []string{second},
|
|
||||||
archivedEventIDs(t, periodPath(path, dayPeriod)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchiveSweep_PrunesEveryFile gives a daily target three files:
|
|
||||||
// the file without a period, left from before it rotated, and two
|
|
||||||
// daily files. Each holds a row older than the expiry, and one daily
|
|
||||||
// file also a newer row. The sweep prunes the old row from every file,
|
|
||||||
// deletes the daily file it leaves empty, and keeps the file without a
|
|
||||||
// period although it is empty too.
|
|
||||||
func TestArchiveSweep_PrunesEveryFile(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
|
||||||
tgt := env.seedDatabaseTarget(
|
|
||||||
t, `{"expiry":"1h","rotation":"`+rotationDaily+`"}`,
|
|
||||||
)
|
|
||||||
path := env.archivePath(tgt)
|
|
||||||
emptied := periodPath(path, dayPeriod)
|
|
||||||
kept := periodPath(path, nextDayPeriod)
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
old := now.Add(-48 * time.Hour)
|
|
||||||
|
|
||||||
seedArchiveFile(t, path, tgt.WebhookID, old)
|
|
||||||
seedArchiveFile(t, emptied, tgt.WebhookID, old)
|
|
||||||
seedArchiveFile(t, kept, tgt.WebhookID, old, now.Add(-time.Minute))
|
|
||||||
|
|
||||||
env.sweeper.ExportSweep(t.Context())
|
|
||||||
|
|
||||||
assert.Empty(t, archivedEventIDs(t, path))
|
|
||||||
assert.Equal(t, []string{sweepRowNew}, archivedEventIDs(t, kept))
|
|
||||||
|
|
||||||
for _, suffix := range archiveFileSuffixes() {
|
|
||||||
assert.NoFileExists(t, emptied+suffix)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRename_MovesEveryFile renames a daily target that also has a
|
|
||||||
// file without a period, and checks every file moves to the new name
|
|
||||||
// with its period, rows and all, and that a later write uses the new
|
|
||||||
// name.
|
|
||||||
func TestRename_MovesEveryFile(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
|
||||||
tgt := env.seedDatabaseTarget(t, "")
|
|
||||||
oldPath := env.archivePath(tgt)
|
|
||||||
day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
unrotated := env.deliverReceivedAt(t, tgt, day)
|
|
||||||
|
|
||||||
tgt.Config = dailyConfig
|
|
||||||
first := env.deliverReceivedAt(t, tgt, day)
|
|
||||||
second := env.deliverReceivedAt(t, tgt, day.Add(24*time.Hour))
|
|
||||||
|
|
||||||
require.NoError(t, env.eng.Rename(tgt.ID, "Orders", "Long Term"))
|
|
||||||
|
|
||||||
newPath := filepath.Join(
|
|
||||||
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, old := range []string{
|
|
||||||
oldPath,
|
|
||||||
periodPath(oldPath, dayPeriod),
|
|
||||||
periodPath(oldPath, nextDayPeriod),
|
|
||||||
} {
|
|
||||||
assert.NoFileExists(t, old)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, []string{unrotated}, archivedEventIDs(t, newPath))
|
|
||||||
assert.Equal(t, []string{first},
|
|
||||||
archivedEventIDs(t, periodPath(newPath, dayPeriod)))
|
|
||||||
assert.Equal(t, []string{second},
|
|
||||||
archivedEventIDs(t, periodPath(newPath, nextDayPeriod)))
|
|
||||||
|
|
||||||
third := env.deliverReceivedAt(t, tgt, day.Add(48*time.Hour))
|
|
||||||
assert.Equal(t, []string{third},
|
|
||||||
archivedEventIDs(t, periodPath(newPath, "2026-03-06")))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRename_NeverReplacesARotatedFile plants a file at the new name
|
|
||||||
// of a target's daily file, and proves the rename is refused and moves
|
|
||||||
// none of the target's files.
|
|
||||||
func TestRename_NeverReplacesARotatedFile(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
|
||||||
tgt := env.seedDatabaseTarget(t, dailyConfig)
|
|
||||||
oldPath := env.archivePath(tgt)
|
|
||||||
day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
env.deliverReceivedAt(t, tgt, day)
|
|
||||||
env.deliverReceivedAt(t, tgt, day.Add(24*time.Hour))
|
|
||||||
|
|
||||||
newPath := filepath.Join(
|
|
||||||
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
|
|
||||||
)
|
|
||||||
planted := periodPath(newPath, nextDayPeriod)
|
|
||||||
require.NoError(t, os.WriteFile(planted, []byte("planted"), 0o600))
|
|
||||||
|
|
||||||
require.ErrorIs(
|
|
||||||
t, env.eng.Rename(tgt.ID, "Orders", "Long Term"),
|
|
||||||
delivery.ErrArchiveNameTaken,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.FileExists(t, periodPath(oldPath, dayPeriod))
|
|
||||||
assert.FileExists(t, periodPath(oldPath, nextDayPeriod))
|
|
||||||
assert.NoFileExists(t, periodPath(newPath, dayPeriod))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStatArchive_EveryFile proves StatArchive counts and adds up every
|
|
||||||
// one of a target's files, takes the latest write of any of them, and
|
|
||||||
// leaves out files whose names only look like the target's.
|
|
||||||
func TestStatArchive_EveryFile(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "archive-wh.db")
|
|
||||||
files := []string{
|
|
||||||
path, periodPath(path, "2026-03"), periodPath(path, hourPeriod),
|
|
||||||
}
|
|
||||||
written := time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC)
|
|
||||||
|
|
||||||
var size int64
|
|
||||||
|
|
||||||
for i, file := range files {
|
|
||||||
require.NoError(t, os.WriteFile(file, make([]byte, 100*(i+1)), 0o600))
|
|
||||||
|
|
||||||
size += int64(100 * (i + 1))
|
|
||||||
at := written.Add(-time.Duration(i) * time.Hour)
|
|
||||||
require.NoError(t, os.Chtimes(file, at, at))
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, other := range []string{
|
|
||||||
"archive-wh-2026-13.db", "archive-wh-2026-3.db",
|
|
||||||
"archive-wh-other.db", "archive-wh-2026-03.json",
|
|
||||||
"archive-whx.db",
|
|
||||||
} {
|
|
||||||
require.NoError(t,
|
|
||||||
os.WriteFile(filepath.Join(dir, other), []byte("x"), 0o600))
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := delivery.StatArchive(path)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, len(files), got.Files)
|
|
||||||
assert.Equal(t, size, got.Size)
|
|
||||||
assert.True(t, written.Equal(got.Written), got.Written)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchivePathAt names the file each rotation writes an event to.
|
|
||||||
func TestArchivePathAt(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dataDir := t.TempDir()
|
|
||||||
dbMgr := database.NewTestWebhookDBManager(dataDir)
|
|
||||||
webhook := &database.Webhook{
|
|
||||||
BaseModel: database.BaseModel{ID: "wh-id"}, Name: "Orders",
|
|
||||||
}
|
|
||||||
at := time.Date(2026, 10, 1, 19, 30, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
cases := map[string]string{
|
|
||||||
"": "",
|
|
||||||
`{"rotation":"` + rotationNone + `"}`: "",
|
|
||||||
`{"rotation":"` + rotationMonthly + `"}`: "-2026-10",
|
|
||||||
dailyConfig: "-2026-10-01",
|
|
||||||
`{"rotation":"` + rotationHourly + `"}`: "-2026-10-01-19",
|
|
||||||
}
|
|
||||||
|
|
||||||
for config, period := range cases {
|
|
||||||
target := &database.Target{
|
|
||||||
BaseModel: database.BaseModel{ID: "tgt-id"},
|
|
||||||
Name: "Archive",
|
|
||||||
Config: config,
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := delivery.ArchivePathAt(dbMgr, webhook, target, at)
|
|
||||||
require.NoError(t, err, config)
|
|
||||||
assert.Equal(t,
|
|
||||||
filepath.Join(
|
|
||||||
dataDir, "archive-orders-archive-tgt-id"+period+".db",
|
|
||||||
),
|
|
||||||
got, config,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := delivery.ArchivePathAt(dbMgr, webhook, &database.Target{
|
|
||||||
Config: weeklyConfig,
|
|
||||||
}, at)
|
|
||||||
require.Error(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateArchiveRotation accepts the four rotations, and empty,
|
|
||||||
// and refuses anything else.
|
|
||||||
func TestValidateArchiveRotation(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, ok := range []string{
|
|
||||||
"", rotationNone, rotationMonthly, rotationDaily, rotationHourly,
|
|
||||||
} {
|
|
||||||
require.NoError(t, delivery.ValidateArchiveRotation(ok), ok)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, bad := range []string{"weekly", "Daily", "hourly "} {
|
|
||||||
require.Error(t, delivery.ValidateArchiveRotation(bad), bad)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,14 +3,14 @@ package delivery_test
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
@@ -18,7 +18,6 @@ import (
|
|||||||
_ "modernc.org/sqlite" // Pure Go SQLite driver.
|
_ "modernc.org/sqlite" // Pure Go SQLite driver.
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func archiveTestLogger() *slog.Logger {
|
func archiveTestLogger() *slog.Logger {
|
||||||
@@ -44,8 +43,7 @@ func openArchiveDBForRead(
|
|||||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||||
|
|
||||||
gdb, err := gorm.Open(
|
gdb, err := gorm.Open(
|
||||||
sqlite.Dialector{Conn: sqlDB},
|
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
||||||
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -76,18 +74,25 @@ func removeArchiveFiles(t *testing.T, path string) {
|
|||||||
|
|
||||||
// TestDeliverDatabase_ArchivesEvent verifies that delivering to
|
// TestDeliverDatabase_ArchivesEvent verifies that delivering to
|
||||||
// a database target marks the delivery delivered and archives
|
// a database target marks the delivery delivered and archives
|
||||||
// the full event into the target's own archive file.
|
// the full event into a separate per-webhook archive file.
|
||||||
func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
|
func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
dataDir := t.TempDir()
|
||||||
tgt := env.seedDatabaseTarget(t, "")
|
dbMgr := database.NewTestWebhookDBManager(dataDir)
|
||||||
|
|
||||||
|
e := delivery.NewTestEngineWithDB(
|
||||||
|
nil, dbMgr,
|
||||||
|
archiveTestLogger(),
|
||||||
|
&http.Client{Timeout: 5 * time.Second},
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
webhookDB := testWebhookDB(t)
|
webhookDB := testWebhookDB(t)
|
||||||
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
||||||
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
|
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
|
||||||
|
|
||||||
env.eng.ExportDeliverDatabase(webhookDB, d)
|
e.ExportDeliverDatabase(webhookDB, d)
|
||||||
|
|
||||||
var updated database.Delivery
|
var updated database.Delivery
|
||||||
|
|
||||||
@@ -100,7 +105,8 @@ func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
archivePath := filepath.Join(
|
archivePath := filepath.Join(
|
||||||
env.dataDir, "archive-sweep-test-archive-"+tgt.ID+".db",
|
dataDir,
|
||||||
|
fmt.Sprintf("archive-%s.db", event.WebhookID),
|
||||||
)
|
)
|
||||||
assert.FileExists(t, archivePath)
|
assert.FileExists(t, archivePath)
|
||||||
|
|
||||||
@@ -184,67 +190,16 @@ func TestArchiveWriter_RecreatesAfterRemoval(
|
|||||||
assert.Equal(t, "b", got[0].EventID)
|
assert.Equal(t, "b", got[0].EventID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStatArchive proves StatArchive finds no file before the first
|
|
||||||
// write; after a write still held in the -wal, counts the -wal in the
|
|
||||||
// size and takes its later time as the last write; and finds no file
|
|
||||||
// again once the file has been moved away.
|
|
||||||
func TestStatArchive(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
|
||||||
|
|
||||||
_, err := delivery.StatArchive(path)
|
|
||||||
require.ErrorIs(t, err, fs.ErrNotExist)
|
|
||||||
|
|
||||||
// With the clock stopped, the reopen debounce never passes, so
|
|
||||||
// the handle stays open after the write.
|
|
||||||
stopped := time.Now()
|
|
||||||
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
|
||||||
w.SetNow(func() time.Time { return stopped })
|
|
||||||
|
|
||||||
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "a"}, 0))
|
|
||||||
|
|
||||||
written := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
||||||
earlier := written.Add(-time.Hour)
|
|
||||||
require.NoError(t, os.Chtimes(path, earlier, earlier))
|
|
||||||
require.NoError(t, os.Chtimes(path+"-wal", written, written))
|
|
||||||
|
|
||||||
file, err := os.Stat(path)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
wal, err := os.Stat(path + "-wal")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Positive(t, wal.Size())
|
|
||||||
|
|
||||||
got, err := delivery.StatArchive(path)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, 1, got.Files)
|
|
||||||
assert.Equal(t, file.Size()+wal.Size(), got.Size)
|
|
||||||
assert.True(t, written.Equal(got.Written), got.Written)
|
|
||||||
|
|
||||||
removeArchiveFiles(t, path)
|
|
||||||
|
|
||||||
_, err = delivery.StatArchive(path)
|
|
||||||
require.ErrorIs(t, err, fs.ErrNotExist)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestArchiveWriter_ReopenDebounce(t *testing.T) {
|
func TestArchiveWriter_ReopenDebounce(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const debounce = 2 * time.Second
|
// A generous debounce keeps the two rapid writes inside
|
||||||
|
// the window even on a heavily loaded test machine.
|
||||||
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
||||||
w := delivery.NewExportArchiveWriter(
|
w := delivery.NewExportArchiveWriter(
|
||||||
path, archiveTestLogger(), debounce,
|
path, archiveTestLogger(), 2*time.Second,
|
||||||
)
|
)
|
||||||
|
|
||||||
// The writer measures its reopen debounce on this clock, which
|
|
||||||
// only the test moves, so how long the host takes between
|
|
||||||
// writes cannot change the result.
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
w.SetNow(func() time.Time { return now })
|
|
||||||
|
|
||||||
require.NoError(t, w.Write(
|
require.NoError(t, w.Write(
|
||||||
delivery.ExportArchivedEvent{EventID: "a"}, 0,
|
delivery.ExportArchivedEvent{EventID: "a"}, 0,
|
||||||
))
|
))
|
||||||
@@ -256,7 +211,7 @@ func TestArchiveWriter_ReopenDebounce(t *testing.T) {
|
|||||||
// initial open — no extra close/reopen.
|
// initial open — no extra close/reopen.
|
||||||
assert.Equal(t, 1, w.Reopens())
|
assert.Equal(t, 1, w.Reopens())
|
||||||
|
|
||||||
now = now.Add(debounce)
|
time.Sleep(2100 * time.Millisecond)
|
||||||
|
|
||||||
require.NoError(t, w.Write(
|
require.NoError(t, w.Write(
|
||||||
delivery.ExportArchivedEvent{EventID: "c"}, 0,
|
delivery.ExportArchivedEvent{EventID: "c"}, 0,
|
||||||
@@ -333,31 +288,31 @@ func TestParseArchiveExpiry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedDatabaseTargetDelivery seeds a pending delivery of an event
|
// seedDatabaseTargetDelivery seeds a pending delivery for a
|
||||||
// to a database target and returns the in-memory delivery the
|
// database target with the given config JSON and returns the
|
||||||
// target handler is invoked with.
|
// in-memory delivery the target handler is invoked with.
|
||||||
func seedDatabaseTargetDelivery(
|
func seedDatabaseTargetDelivery(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
webhookDB *gorm.DB,
|
webhookDB *gorm.DB,
|
||||||
event database.Event,
|
event database.Event,
|
||||||
tgt *database.Target,
|
config string,
|
||||||
) *database.Delivery {
|
) *database.Delivery {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
dlv := seedDelivery(
|
dlv := seedDelivery(
|
||||||
t, webhookDB, event.ID, tgt.ID,
|
t, webhookDB, event.ID, uuid.New().String(),
|
||||||
database.DeliveryStatusPending,
|
database.DeliveryStatusPending,
|
||||||
)
|
)
|
||||||
|
|
||||||
d := &database.Delivery{
|
d := &database.Delivery{
|
||||||
EventID: event.ID,
|
EventID: event.ID,
|
||||||
TargetID: tgt.ID,
|
TargetID: dlv.TargetID,
|
||||||
Status: database.DeliveryStatusPending,
|
Status: database.DeliveryStatusPending,
|
||||||
Event: event,
|
Event: event,
|
||||||
Target: database.Target{
|
Target: database.Target{
|
||||||
Name: tgt.Name,
|
Name: "test-db",
|
||||||
Type: database.TargetTypeDatabase,
|
Type: database.TargetTypeDatabase,
|
||||||
Config: tgt.Config,
|
Config: config,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
d.ID = dlv.ID
|
d.ID = dlv.ID
|
||||||
@@ -375,14 +330,22 @@ func TestDeliverDatabase_ArchiveFailureFailsDelivery(
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
dataDir := t.TempDir()
|
||||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"nonsense"}`)
|
|
||||||
|
e := delivery.NewTestEngineWithDB(
|
||||||
|
nil, database.NewTestWebhookDBManager(dataDir),
|
||||||
|
archiveTestLogger(),
|
||||||
|
&http.Client{Timeout: 5 * time.Second},
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
webhookDB := testWebhookDB(t)
|
webhookDB := testWebhookDB(t)
|
||||||
event := seedEvent(t, webhookDB, `{"archived":false}`)
|
event := seedEvent(t, webhookDB, `{"archived":false}`)
|
||||||
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
|
d := seedDatabaseTargetDelivery(
|
||||||
|
t, webhookDB, event, `{"expiry":"nonsense"}`,
|
||||||
|
)
|
||||||
|
|
||||||
env.eng.ExportDeliverDatabase(webhookDB, d)
|
e.ExportDeliverDatabase(webhookDB, d)
|
||||||
|
|
||||||
var updated database.Delivery
|
var updated database.Delivery
|
||||||
|
|
||||||
@@ -410,7 +373,10 @@ func TestDeliverDatabase_ArchiveFailureFailsDelivery(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert.NoFileExists(t,
|
assert.NoFileExists(t,
|
||||||
env.archivePath(tgt),
|
filepath.Join(
|
||||||
|
dataDir,
|
||||||
|
fmt.Sprintf("archive-%s.db", event.WebhookID),
|
||||||
|
),
|
||||||
"no archive file should exist for a failed config",
|
"no archive file should exist for a failed config",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -434,328 +400,3 @@ func TestValidateArchiveExpiry(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestArchiveFileName pins the archive file name and the rules
|
|
||||||
// that make a webhook or target name safe to put in it.
|
|
||||||
func TestArchiveFileName(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const id = "3f2a1c9e-8d4b-4c1a-9e2f-0a1b2c3d4e5f"
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
webhook string
|
|
||||||
target string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
"plain names", "orders", "archive",
|
|
||||||
"archive-orders-archive-" + id + ".db",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"lowercased", "Orders", "Main Archive",
|
|
||||||
"archive-orders-main-archive-" + id + ".db",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"a run of other characters is one dash",
|
|
||||||
`a /\..b`, "c__--d",
|
|
||||||
"archive-a-b-c-d-" + id + ".db",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"no dash at either end", " --orders!! ", "(archive)",
|
|
||||||
"archive-orders-archive-" + id + ".db",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path separators", "../../etc/passwd", "a/b",
|
|
||||||
"archive-etc-passwd-a-b-" + id + ".db",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"letters outside ASCII are dropped",
|
|
||||||
"Bestellungen Größe", "café",
|
|
||||||
"archive-bestellungen-gr-e-caf-" + id + ".db",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nothing left is unnamed", "", "!!!",
|
|
||||||
"archive-unnamed-unnamed-" + id + ".db",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cut to 40 characters", strings.Repeat("a", 50), "x",
|
|
||||||
"archive-" + strings.Repeat("a", 40) + "-x-" + id + ".db",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"no dash left by the cut",
|
|
||||||
strings.Repeat("a", 39) + " b", "x",
|
|
||||||
"archive-" + strings.Repeat("a", 39) + "-x-" + id + ".db",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t, tc.want,
|
|
||||||
delivery.ArchiveFileName(tc.webhook, tc.target, id),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDeliverDatabase_EachTargetHasItsOwnArchive proves two
|
|
||||||
// database targets of one webhook archive into separate files.
|
|
||||||
func TestDeliverDatabase_EachTargetHasItsOwnArchive(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
|
||||||
first := env.seedDatabaseTarget(t, "")
|
|
||||||
second := env.addDatabaseTarget(t, first.WebhookID, "")
|
|
||||||
|
|
||||||
webhookDB := testWebhookDB(t)
|
|
||||||
event := seedEvent(t, webhookDB, `{"n":1}`)
|
|
||||||
|
|
||||||
for _, tgt := range []*database.Target{first, second} {
|
|
||||||
env.eng.ExportDeliverDatabase(
|
|
||||||
webhookDB,
|
|
||||||
seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NotEqual(
|
|
||||||
t, env.archivePath(first), env.archivePath(second),
|
|
||||||
)
|
|
||||||
assert.Equal(
|
|
||||||
t, []string{event.ID},
|
|
||||||
archivedEventIDs(t, env.archivePath(first)),
|
|
||||||
)
|
|
||||||
assert.Equal(
|
|
||||||
t, []string{event.ID},
|
|
||||||
archivedEventIDs(t, env.archivePath(second)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRename_MovesTheFile proves a rename moves the archive, rows
|
|
||||||
// and all, and that later writes go to the new name.
|
|
||||||
func TestRename_MovesTheFile(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
|
||||||
tgt := env.seedDatabaseTarget(t, "")
|
|
||||||
oldPath := env.archivePath(tgt)
|
|
||||||
|
|
||||||
webhookDB := testWebhookDB(t)
|
|
||||||
first := seedEvent(t, webhookDB, `{"n":1}`)
|
|
||||||
env.eng.ExportDeliverDatabase(
|
|
||||||
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, first, tgt),
|
|
||||||
)
|
|
||||||
require.FileExists(t, oldPath)
|
|
||||||
|
|
||||||
require.NoError(
|
|
||||||
t, env.eng.Rename(tgt.ID, "Orders", "Long Term"),
|
|
||||||
)
|
|
||||||
|
|
||||||
newPath := filepath.Join(
|
|
||||||
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.NoFileExists(t, oldPath)
|
|
||||||
assert.Equal(t, []string{first.ID}, archivedEventIDs(t, newPath))
|
|
||||||
|
|
||||||
second := seedEvent(t, webhookDB, `{"n":2}`)
|
|
||||||
env.eng.ExportDeliverDatabase(
|
|
||||||
webhookDB,
|
|
||||||
seedDatabaseTargetDelivery(t, webhookDB, second, tgt),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.ElementsMatch(
|
|
||||||
t, []string{first.ID, second.ID},
|
|
||||||
archivedEventIDs(t, newPath),
|
|
||||||
)
|
|
||||||
assert.NoFileExists(
|
|
||||||
t, oldPath, "a write after the rename must use the new name",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRename_NeverReplacesAFile plants a file at the new name, once
|
|
||||||
// the .db alone, once a lone -wal and once a lone -shm, and proves
|
|
||||||
// each time that the rename is refused, the planted file survives,
|
|
||||||
// and the archive keeps its name and its rows.
|
|
||||||
func TestRename_NeverReplacesAFile(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, suffix := range archiveFileSuffixes() {
|
|
||||||
t.Run("planted .db"+suffix, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
|
||||||
tgt := env.seedDatabaseTarget(t, "")
|
|
||||||
oldPath := env.archivePath(tgt)
|
|
||||||
|
|
||||||
webhookDB := testWebhookDB(t)
|
|
||||||
first := seedEvent(t, webhookDB, `{"n":1}`)
|
|
||||||
env.eng.ExportDeliverDatabase(
|
|
||||||
webhookDB,
|
|
||||||
seedDatabaseTargetDelivery(t, webhookDB, first, tgt),
|
|
||||||
)
|
|
||||||
|
|
||||||
newPath := filepath.Join(
|
|
||||||
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
|
|
||||||
)
|
|
||||||
plantedPath := newPath + suffix
|
|
||||||
require.NoError(
|
|
||||||
t, os.WriteFile(plantedPath, []byte("planted"), 0o600),
|
|
||||||
)
|
|
||||||
|
|
||||||
require.ErrorIs(
|
|
||||||
t, env.eng.Rename(tgt.ID, "Orders", "Long Term"),
|
|
||||||
delivery.ErrArchiveNameTaken,
|
|
||||||
)
|
|
||||||
|
|
||||||
//nolint:gosec // reads the file the test planted under t.TempDir()
|
|
||||||
planted, err := os.ReadFile(plantedPath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "planted", string(planted))
|
|
||||||
|
|
||||||
second := seedEvent(t, webhookDB, `{"n":2}`)
|
|
||||||
env.eng.ExportDeliverDatabase(
|
|
||||||
webhookDB,
|
|
||||||
seedDatabaseTargetDelivery(t, webhookDB, second, tgt),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.ElementsMatch(
|
|
||||||
t, []string{first.ID, second.ID},
|
|
||||||
archivedEventIDs(t, oldPath),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRename_BeforeTheNameIsSaved covers the order the handlers
|
|
||||||
// use: they rename before they save the new name, so a delivery in
|
|
||||||
// between must write under the new name although the main database
|
|
||||||
// still has the old one. It also shows that renaming an archive that
|
|
||||||
// does not exist yet is not an error.
|
|
||||||
func TestRename_BeforeTheNameIsSaved(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
|
||||||
tgt := env.seedDatabaseTarget(t, "")
|
|
||||||
|
|
||||||
require.NoError(
|
|
||||||
t, env.eng.Rename(tgt.ID, "Orders", "Archive"),
|
|
||||||
)
|
|
||||||
|
|
||||||
webhookDB := testWebhookDB(t)
|
|
||||||
event := seedEvent(t, webhookDB, `{"n":1}`)
|
|
||||||
env.eng.ExportDeliverDatabase(
|
|
||||||
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.FileExists(
|
|
||||||
t,
|
|
||||||
filepath.Join(
|
|
||||||
env.dataDir, "archive-orders-archive-"+tgt.ID+".db",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assert.NoFileExists(t, env.archivePath(tgt))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchiveWriter_RenameMovesSidecars proves a rename carries
|
|
||||||
// the -wal and -shm a crash can leave beside an archive no handle
|
|
||||||
// has opened since. SQLite finds them by name, so a -wal left
|
|
||||||
// behind would lose the transactions it holds.
|
|
||||||
func TestArchiveWriter_RenameMovesSidecars(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
oldPath := filepath.Join(dir, "archive-old.db")
|
|
||||||
newPath := filepath.Join(dir, "archive-new.db")
|
|
||||||
|
|
||||||
for _, suffix := range archiveFileSuffixes() {
|
|
||||||
require.NoError(
|
|
||||||
t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
w := delivery.NewExportArchiveWriter(
|
|
||||||
oldPath, archiveTestLogger(), 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
require.NoError(t, w.Rename("archive-new.db"))
|
|
||||||
|
|
||||||
for _, suffix := range archiveFileSuffixes() {
|
|
||||||
assert.NoFileExists(t, oldPath+suffix)
|
|
||||||
assert.FileExists(t, newPath+suffix)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, newPath, w.Path())
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchiveWriter_RenameMovesBackOnFailure makes the -wal fail to
|
|
||||||
// move after the .db has moved, and proves the .db is moved back, so
|
|
||||||
// the archive is never split across two names. The new name is 255
|
|
||||||
// bytes, the longest a file name may be, so the .db can take it but
|
|
||||||
// the -wal, four bytes longer, cannot.
|
|
||||||
func TestArchiveWriter_RenameMovesBackOnFailure(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
oldPath := filepath.Join(dir, "archive-old.db")
|
|
||||||
newName := strings.Repeat("a", 252) + ".db"
|
|
||||||
|
|
||||||
for _, suffix := range archiveFileSuffixes() {
|
|
||||||
require.NoError(
|
|
||||||
t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
w := delivery.NewExportArchiveWriter(
|
|
||||||
oldPath, archiveTestLogger(), 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
require.Error(t, w.Rename(newName))
|
|
||||||
|
|
||||||
for _, suffix := range archiveFileSuffixes() {
|
|
||||||
assert.FileExists(t, oldPath+suffix)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.NoFileExists(t, filepath.Join(dir, newName))
|
|
||||||
assert.Equal(t, oldPath, w.Path())
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchiveWriter_RenameMovesBackEveryFile renames a target with a
|
|
||||||
// file without a period and a file for a month, and proves that when
|
|
||||||
// the month's file fails to move, the file already moved is moved back:
|
|
||||||
// both files are under the old name with their rows, and nothing is
|
|
||||||
// under the new name. The new name is 251 bytes, so the file without a
|
|
||||||
// period, with its -wal and -shm, can take it, but the month's file,
|
|
||||||
// eight bytes longer, cannot.
|
|
||||||
func TestArchiveWriter_RenameMovesBackEveryFile(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
oldPath := filepath.Join(dir, "archive-old.db")
|
|
||||||
monthPath := filepath.Join(dir, "archive-old-2026-03.db")
|
|
||||||
w := delivery.NewExportArchiveWriter(oldPath, archiveTestLogger(), 0)
|
|
||||||
|
|
||||||
require.NoError(t, w.WritePeriod(
|
|
||||||
delivery.ExportArchivedEvent{EventID: "in-none"}, 0, "",
|
|
||||||
))
|
|
||||||
require.NoError(t, w.WritePeriod(
|
|
||||||
delivery.ExportArchivedEvent{EventID: "in-month"}, 0, "2026-03",
|
|
||||||
))
|
|
||||||
|
|
||||||
require.Error(t, w.Rename(strings.Repeat("a", 248)+".db"))
|
|
||||||
|
|
||||||
assert.Equal(t, []string{"in-none"}, archivedEventIDs(t, oldPath))
|
|
||||||
assert.Equal(t, []string{"in-month"}, archivedEventIDs(t, monthPath))
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
for _, entry := range entries {
|
|
||||||
assert.True(t, strings.HasPrefix(entry.Name(), "archive-old"),
|
|
||||||
"%s is not under the old name", entry.Name())
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, oldPath, w.Path())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -204,11 +204,10 @@ func TestNewTargetConfigForm(t *testing.T) {
|
|||||||
|
|
||||||
form, err = delivery.NewTargetConfigForm(&database.Target{
|
form, err = delivery.NewTargetConfigForm(&database.Target{
|
||||||
Type: database.TargetTypeDatabase,
|
Type: database.TargetTypeDatabase,
|
||||||
Config: `{"expiry":"720h","rotation":"daily"}`,
|
Config: `{"expiry":"720h"}`,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "720h", form.Expiry)
|
assert.Equal(t, "720h", form.Expiry)
|
||||||
assert.Equal(t, rotationDaily, form.Rotation)
|
|
||||||
|
|
||||||
form, err = delivery.NewTargetConfigForm(&database.Target{
|
form, err = delivery.NewTargetConfigForm(&database.Target{
|
||||||
Type: database.TargetTypeLog,
|
Type: database.TargetTypeLog,
|
||||||
@@ -217,9 +216,8 @@ func TestNewTargetConfigForm(t *testing.T) {
|
|||||||
assert.Empty(t, form.URL)
|
assert.Empty(t, form.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A keep-forever archive target yields an empty expiry, so the edit
|
// A keep-forever archive target must pre-fill as an empty field, so
|
||||||
// form starts on never; saving it unchanged stores never, which means
|
// saving the form back unchanged stores the same empty config.
|
||||||
// the same as an empty expiry.
|
|
||||||
func TestNewTargetConfigForm_DatabaseNeverIsBlank(t *testing.T) {
|
func TestNewTargetConfigForm_DatabaseNeverIsBlank(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -249,10 +247,6 @@ func TestNewTargetConfigForm_UnreadableConfigErrors(t *testing.T) {
|
|||||||
Type: database.TargetTypeDatabase,
|
Type: database.TargetTypeDatabase,
|
||||||
Config: `{"expiry":"soon"}`,
|
Config: `{"expiry":"soon"}`,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
Type: database.TargetTypeDatabase,
|
|
||||||
Config: weeklyConfig,
|
|
||||||
},
|
|
||||||
{Type: database.TargetType("nope")},
|
{Type: database.TargetType("nope")},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ func (c *httpCore) handleRetry(
|
|||||||
database.DeliveryStatusRetrying,
|
database.DeliveryStatusRetrying,
|
||||||
)
|
)
|
||||||
|
|
||||||
backoff := Backoff(attemptNum)
|
backoff := calcBackoff(attemptNum)
|
||||||
|
|
||||||
retryTask := *task
|
retryTask := *task
|
||||||
retryTask.AttemptNum = attemptNum + 1
|
retryTask.AttemptNum = attemptNum + 1
|
||||||
@@ -301,7 +301,7 @@ func (c *httpCore) remainingBackoff(
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
backoff := Backoff(attemptNum)
|
backoff := calcBackoff(attemptNum)
|
||||||
elapsed := time.Since(lastResult.CreatedAt)
|
elapsed := time.Since(lastResult.CreatedAt)
|
||||||
remaining := backoff - elapsed
|
remaining := backoff - elapsed
|
||||||
|
|
||||||
@@ -326,14 +326,12 @@ func (c *httpCore) backoffElapsed(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
backoff := Backoff(attemptNum)
|
backoff := calcBackoff(attemptNum)
|
||||||
|
|
||||||
return time.Since(lastResult.CreatedAt) >= backoff
|
return time.Since(lastResult.CreatedAt) >= backoff
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backoff is how long an http or slack target with retries waits after
|
func calcBackoff(attemptNum int) time.Duration {
|
||||||
// a delivery's failed attempt attemptNum before trying it again.
|
|
||||||
func Backoff(attemptNum int) time.Duration {
|
|
||||||
shift := max(attemptNum-1, 0)
|
shift := max(attemptNum-1, 0)
|
||||||
shift = min(shift, maxBackoffShift)
|
shift = min(shift, maxBackoffShift)
|
||||||
|
|
||||||
@@ -444,9 +442,7 @@ func (t *httpTarget) doHTTPRequest(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
originScoped := applyRequestHeaders(
|
originScoped := applyRequestHeaders(req, event, cfg)
|
||||||
req, event, cfg, t.eng.userAgent(),
|
|
||||||
)
|
|
||||||
|
|
||||||
client := t.clientForRequest(cfg, originScoped)
|
client := t.clientForRequest(cfg, originScoped)
|
||||||
|
|
||||||
@@ -566,13 +562,10 @@ func isForwardableHeader(name string) bool {
|
|||||||
// Content-Type goes out once: a Content-Type configured on the target
|
// Content-Type goes out once: a Content-Type configured on the target
|
||||||
// wins, otherwise the event's ContentType, otherwise none. The inbound
|
// wins, otherwise the event's ContentType, otherwise none. The inbound
|
||||||
// Content-Type in the event's headers is never forwarded.
|
// Content-Type in the event's headers is never forwarded.
|
||||||
//
|
|
||||||
// userAgent is set last, over any configured or inbound User-Agent.
|
|
||||||
func applyRequestHeaders(
|
func applyRequestHeaders(
|
||||||
req *http.Request,
|
req *http.Request,
|
||||||
event *database.Event,
|
event *database.Event,
|
||||||
cfg *HTTPTargetConfig,
|
cfg *HTTPTargetConfig,
|
||||||
userAgent string,
|
|
||||||
) []string {
|
) []string {
|
||||||
if event.ContentType != "" {
|
if event.ContentType != "" {
|
||||||
req.Header.Set(
|
req.Header.Set(
|
||||||
@@ -587,7 +580,7 @@ func applyRequestHeaders(
|
|||||||
originScoped[http.CanonicalHeaderKey(k)] = struct{}{}
|
originScoped[http.CanonicalHeaderKey(k)] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", userAgent)
|
req.Header.Set("User-Agent", "webhooker/1.0")
|
||||||
|
|
||||||
// A Content-Type configured on the target describes the body
|
// A Content-Type configured on the target describes the body
|
||||||
// being sent rather than the sender. A 307/308 preserves the
|
// being sent rather than the sender. A 307/308 preserves the
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ func (t *slackTarget) attempt(
|
|||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("User-Agent", t.eng.userAgent())
|
req.Header.Set("User-Agent", "webhooker/1.0")
|
||||||
|
|
||||||
resp, doErr := executeHTTPRequest(t.client, req)
|
resp, doErr := executeHTTPRequest(t.client, req)
|
||||||
durationMs := time.Since(start).Milliseconds()
|
durationMs := time.Since(start).Milliseconds()
|
||||||
|
|||||||
@@ -573,8 +573,6 @@ func TestRecoverPending_TargetDeleted(t *testing.T) {
|
|||||||
|
|
||||||
s := newISetup(t)
|
s := newISetup(t)
|
||||||
|
|
||||||
iCreateWebhook(t, s.MainDB, s.WebhookID, "pending-recovery")
|
|
||||||
|
|
||||||
deliveryID := tSeedDeletedTarget(
|
deliveryID := tSeedDeletedTarget(
|
||||||
t, s, "gone-while-pending", "http://example.com/hook",
|
t, s, "gone-while-pending", "http://example.com/hook",
|
||||||
database.DeliveryStatusPending,
|
database.DeliveryStatusPending,
|
||||||
@@ -614,8 +612,6 @@ func TestRecoverPending_TargetDeleted_LeavesAnOwnedDeliveryAlone(
|
|||||||
|
|
||||||
s := newISetup(t)
|
s := newISetup(t)
|
||||||
|
|
||||||
iCreateWebhook(t, s.MainDB, s.WebhookID, "owned-recovery")
|
|
||||||
|
|
||||||
deliveryID := tSeedDeletedTarget(
|
deliveryID := tSeedDeletedTarget(
|
||||||
t, s, "gone-but-owned", "http://example.com/hook",
|
t, s, "gone-but-owned", "http://example.com/hook",
|
||||||
database.DeliveryStatusPending,
|
database.DeliveryStatusPending,
|
||||||
|
|||||||
@@ -179,27 +179,6 @@ func TestDoHTTPRequest_TransportErrorMasksURL(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDoHTTPRequest_UnparsableURLIsMasked is the same for an HTTP
|
|
||||||
// target URL that no request can be built from.
|
|
||||||
func TestDoHTTPRequest_UnparsableURLIsMasked(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
e := testEngine(t, 1)
|
|
||||||
|
|
||||||
statusCode, _, _, reqErr := e.ExportDoHTTPRequest(
|
|
||||||
context.TODO(),
|
|
||||||
&delivery.HTTPTargetConfig{
|
|
||||||
URL: "https://hooks.example.com" + maskSecretPath + "\n",
|
|
||||||
},
|
|
||||||
&database.Event{},
|
|
||||||
)
|
|
||||||
require.Error(t, reqErr)
|
|
||||||
assert.Zero(t, statusCode)
|
|
||||||
|
|
||||||
assertNoCredential(t, reqErr.Error())
|
|
||||||
assert.Contains(t, reqErr.Error(), "invalid control character")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateTargetURL_UnparsableURLIsMasked proves the SSRF
|
// TestValidateTargetURL_UnparsableURLIsMasked proves the SSRF
|
||||||
// validator's error does not carry the submitted URL, which
|
// validator's error does not carry the submitted URL, which
|
||||||
// the handler both logs and shows.
|
// the handler both logs and shows.
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
package delivery_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/netip"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"go.uber.org/fx/fxtest"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
|
||||||
"sneak.berlin/go/webhooker/internal/metrics"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Both the http and the slack target send webhooker/ and the version
|
|
||||||
// in Globals, the value the web UI footer shows. A User-Agent
|
|
||||||
// configured on the target or carried in by the sender does not
|
|
||||||
// replace it.
|
|
||||||
func TestUserAgent_IsTheBuildVersion(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const want = "webhooker/1.2.3-test"
|
|
||||||
|
|
||||||
userAgents := make(chan string, 1)
|
|
||||||
|
|
||||||
ts := httptest.NewServer(http.HandlerFunc(
|
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userAgents <- r.Header.Get("User-Agent")
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
},
|
|
||||||
))
|
|
||||||
defer ts.Close()
|
|
||||||
|
|
||||||
g := &globals.Globals{Version: "1.2.3-test"}
|
|
||||||
lc := fxtest.NewLifecycle(t)
|
|
||||||
|
|
||||||
log, err := logger.New(lc, logger.LoggerParams{Globals: g})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
e := delivery.New(lc, delivery.EngineParams{
|
|
||||||
Globals: g,
|
|
||||||
Logger: log,
|
|
||||||
// httptest listens on loopback, which the default guard
|
|
||||||
// refuses.
|
|
||||||
SSRFGuard: delivery.NewTestGuard(
|
|
||||||
netip.MustParsePrefix("127.0.0.0/8"),
|
|
||||||
),
|
|
||||||
Metrics: metrics.New(prometheus.NewRegistry()),
|
|
||||||
})
|
|
||||||
|
|
||||||
statusCode, _, _, err := e.ExportDoHTTPRequest(
|
|
||||||
context.Background(),
|
|
||||||
&delivery.HTTPTargetConfig{
|
|
||||||
URL: ts.URL,
|
|
||||||
Headers: map[string]string{"User-Agent": "configured/1"},
|
|
||||||
},
|
|
||||||
&database.Event{Headers: `{"User-Agent":["curl/8"]}`},
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, http.StatusOK, statusCode)
|
|
||||||
require.Len(t, userAgents, 1, "the http target sent no request")
|
|
||||||
assert.Equal(t, want, <-userAgents, "http target")
|
|
||||||
|
|
||||||
db := testWebhookDB(t)
|
|
||||||
targetID := uuid.New().String()
|
|
||||||
|
|
||||||
slackCfg, err := json.Marshal(
|
|
||||||
delivery.SlackTargetConfig{WebhookURL: ts.URL},
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
event := seedEvent(t, db, `{"action":"test"}`)
|
|
||||||
dlv := seedDelivery(
|
|
||||||
t, db, event.ID, targetID, database.DeliveryStatusPending,
|
|
||||||
)
|
|
||||||
|
|
||||||
e.ExportDeliverSlack(context.Background(), db, buildSlackDelivery(
|
|
||||||
dlv, event, targetID, "test-slack", string(slackCfg),
|
|
||||||
))
|
|
||||||
require.Len(t, userAgents, 1, "the slack target sent no request")
|
|
||||||
assert.Equal(t, want, <-userAgents, "slack target")
|
|
||||||
}
|
|
||||||
@@ -117,8 +117,8 @@ func readFirstBootSecrets(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// bootAtDebug starts and stops the real application graph against
|
// bootAtDebug starts and stops the real application graph against
|
||||||
// dataDir with DEBUG=true and nothing else set, and returns everything
|
// dataDir with DEBUG=true, and returns everything it wrote to standard
|
||||||
// it wrote to standard output.
|
// output.
|
||||||
//
|
//
|
||||||
// config.New reads DEBUG from the environment exactly as the binary
|
// config.New reads DEBUG from the environment exactly as the binary
|
||||||
// does, internal/logger builds the handler it builds in production,
|
// does, internal/logger builds the handler it builds in production,
|
||||||
@@ -128,7 +128,6 @@ func readFirstBootSecrets(
|
|||||||
func bootAtDebug(t *testing.T, dataDir string) string {
|
func bootAtDebug(t *testing.T, dataDir string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
t.Setenv("DEBUG", "true")
|
t.Setenv("DEBUG", "true")
|
||||||
t.Setenv("DATA_DIR", dataDir)
|
t.Setenv("DATA_DIR", dataDir)
|
||||||
|
|
||||||
@@ -138,10 +137,6 @@ func bootAtDebug(t *testing.T, dataDir string) string {
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
// fx's own log is discarded, not sent to t.Logf: a hook still
|
|
||||||
// running after a start or stop timeout would write there after
|
|
||||||
// the test has returned.
|
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
|
|||||||
@@ -111,9 +111,9 @@ func (l *Logger) LogMode(gormlogger.LogLevel) gormlogger.Interface {
|
|||||||
//
|
//
|
||||||
// One GORM path does not consult this: (*gorm.DB).Scan records the
|
// One GORM path does not consult this: (*gorm.DB).Scan records the
|
||||||
// statement through gorm's own traceRecorder, which does not implement
|
// statement through gorm's own traceRecorder, which does not implement
|
||||||
// this interface. No production code path calls it; only tests do, and
|
// this interface. No production code path calls it; its one caller is
|
||||||
// what a test binds is fixture data. scan_guard_test.go fails if a
|
// internal/database/database_test.go:91, whose SELECT 1 binds nothing.
|
||||||
// non-test file calls it.
|
// scan_guard_test.go fails if a non-test file calls it.
|
||||||
// (*gorm.DB).Pluck, Row and Raw all run through the normal callback
|
// (*gorm.DB).Pluck, Row and Raw all run through the normal callback
|
||||||
// processor and are filtered.
|
// processor and are filtered.
|
||||||
func (l *Logger) ParamsFilter(
|
func (l *Logger) ParamsFilter(
|
||||||
|
|||||||
@@ -14,16 +14,18 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// isRowProducer reports whether name is GORM's Row or database/sql's
|
// minNonTestFiles guards the walk below against passing because it
|
||||||
// QueryRow or QueryRowContext, which return a *sql.Row whose Scan is
|
// found nothing to look at. The tree held 60 non-test .go files when
|
||||||
// database/sql's and not (*gorm.DB).Scan. GORM's Rows is not listed:
|
// this was written.
|
||||||
// it also returns an error, so Scan is never called on its result
|
const minNonTestFiles = 40
|
||||||
// directly. It matches the method name only and resolves no types, so
|
|
||||||
// a repo-local method with one of these names that returns *gorm.DB
|
// isRowProducer reports whether name is a method that returns a
|
||||||
// gets past it: Scan on that method's result is not reported.
|
// database/sql row handle. GORM's Row and Rows return *sql.Row and
|
||||||
|
// *sql.Rows, so Scan on the result of one of them is database/sql's
|
||||||
|
// Scan and never (*gorm.DB).Scan.
|
||||||
func isRowProducer(name string) bool {
|
func isRowProducer(name string) bool {
|
||||||
switch name {
|
switch name {
|
||||||
case "Row", "QueryRow", "QueryRowContext":
|
case "Row", "Rows", "QueryRow", "QueryRowContext":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -48,14 +50,9 @@ func receiverIsRowHandle(x ast.Expr) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// unguardedScans returns the position of every Scan call in file whose
|
// unguardedScans returns the position of every Scan call in file whose
|
||||||
// receiver is not a call to a row producer. It fails closed: any other
|
// receiver is not a row handle. It fails closed: a receiver it cannot
|
||||||
// receiver — a local variable, a struct field, a call to any other
|
// resolve syntactically — a local variable, a struct field — is
|
||||||
// method — is reported rather than assumed safe.
|
// reported rather than assumed safe.
|
||||||
//
|
|
||||||
// It sees only calls written x.Scan(...). A method value, f := db.Scan
|
|
||||||
// followed by f(&v), is out of scope: Scan is never the called
|
|
||||||
// expression there, and nobody writes a query that way by accident,
|
|
||||||
// which is the mistake this check exists to catch.
|
|
||||||
func unguardedScans(
|
func unguardedScans(
|
||||||
fset *token.FileSet, file *ast.File,
|
fset *token.FileSet, file *ast.File,
|
||||||
) []token.Position {
|
) []token.Position {
|
||||||
@@ -114,15 +111,15 @@ func skipDir(name string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// walkNonTestGo parses every non-test .go file under root. It returns
|
// walkNonTestGo parses every non-test .go file under root and returns
|
||||||
// the directories, relative to root, it parsed a file in, along with
|
// how many it parsed along with every unguarded Scan it found.
|
||||||
// every unguarded Scan it found.
|
func walkNonTestGo(t *testing.T, root string) (int, []string) {
|
||||||
func walkNonTestGo(t *testing.T, root string) (map[string]bool, []string) {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
walked := map[string]bool{}
|
var (
|
||||||
|
parsed int
|
||||||
var hits []string
|
hits []string
|
||||||
|
)
|
||||||
|
|
||||||
fset := token.NewFileSet()
|
fset := token.NewFileSet()
|
||||||
|
|
||||||
@@ -150,12 +147,7 @@ func walkNonTestGo(t *testing.T, root string) (map[string]bool, []string) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
dir, err := filepath.Rel(root, filepath.Dir(path))
|
parsed++
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
walked[dir] = true
|
|
||||||
|
|
||||||
for _, pos := range unguardedScans(fset, file) {
|
for _, pos := range unguardedScans(fset, file) {
|
||||||
hits = append(hits, relPosition(root, pos))
|
hits = append(hits, relPosition(root, pos))
|
||||||
@@ -165,7 +157,7 @@ func walkNonTestGo(t *testing.T, root string) (map[string]bool, []string) {
|
|||||||
},
|
},
|
||||||
))
|
))
|
||||||
|
|
||||||
return walked, hits
|
return parsed, hits
|
||||||
}
|
}
|
||||||
|
|
||||||
// isNonTestGo reports whether a file name is Go source this check
|
// isNonTestGo reports whether a file name is Go source this check
|
||||||
@@ -197,39 +189,19 @@ func relPosition(root string, pos token.Position) string {
|
|||||||
// logged with its values interpolated. The package comment states the
|
// logged with its values interpolated. The package comment states the
|
||||||
// limit; this fails when someone adds a call site anyway.
|
// limit; this fails when someone adds a call site anyway.
|
||||||
//
|
//
|
||||||
// Test files are not governed: what a test binds is fixture data.
|
// The current tree has one caller, internal/database/database_test.go,
|
||||||
|
// which this check does not govern: it is test-only and its SELECT 1
|
||||||
|
// binds nothing.
|
||||||
func TestGormScanIsNeverCalledOutsideTests(t *testing.T) {
|
func TestGormScanIsNeverCalledOutsideTests(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
root := moduleRoot(t)
|
parsed, offenders := walkNonTestGo(t, moduleRoot(t))
|
||||||
walked, offenders := walkNonTestGo(t, root)
|
|
||||||
|
|
||||||
// The module's packages are static, templates, and every directory
|
require.GreaterOrEqual(
|
||||||
// directly under cmd and internal. Each holds non-test code, so one
|
t, parsed, minNonTestFiles,
|
||||||
// the walk parsed nothing in was skipped, and a Scan there would
|
"parsed %d non-test .go files, so this check found "+
|
||||||
// pass unseen.
|
"nothing to look at", parsed,
|
||||||
packages := []string{"static", "templates"}
|
|
||||||
|
|
||||||
for _, parent := range []string{"cmd", "internal"} {
|
|
||||||
entries, err := os.ReadDir(filepath.Join(root, parent))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
for _, entry := range entries {
|
|
||||||
if !entry.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
packages = append(packages, filepath.Join(parent, entry.Name()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, dir := range packages {
|
|
||||||
require.True(
|
|
||||||
t, walked[dir],
|
|
||||||
"the walk parsed no non-test .go file in %s", dir,
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
require.Empty(
|
require.Empty(
|
||||||
t, offenders,
|
t, offenders,
|
||||||
"Scan called on a receiver this check cannot show is a "+
|
"Scan called on a receiver this check cannot show is a "+
|
||||||
@@ -250,51 +222,18 @@ type scanGuardCase struct {
|
|||||||
want int
|
want int
|
||||||
}
|
}
|
||||||
|
|
||||||
// scanGuardCases covers each receiver form unguardedScans names, plus
|
|
||||||
// each row producer isRowProducer lets through. Each body is valid Go
|
|
||||||
// inside plantedFile.
|
|
||||||
func scanGuardCases() []scanGuardCase {
|
func scanGuardCases() []scanGuardCase {
|
||||||
return []scanGuardCase{
|
return []scanGuardCase{
|
||||||
{"local variable", "q := gdb.Raw(\"SELECT 1\")\n\tq.Scan(&v)", 1},
|
{"gorm chain", `db.DB().Raw("SELECT 1").Scan(&v)`, 1},
|
||||||
{"struct field", `s.db.Scan(&v)`, 1},
|
{"gorm receiver", `gdb.Scan(&v)`, 1},
|
||||||
{"gorm chain", `gdb.Raw("SELECT 1").Scan(&v)`, 1},
|
{"gorm via variable", "q := gdb.Raw(\"x\")\nq.Scan(&v)", 1},
|
||||||
{
|
{"gorm model chain", `gdb.Model(&x).Scan(&v)`, 1},
|
||||||
"sql rows in a variable",
|
{"sql row", `gdb.Raw("SELECT 1").Row().Scan(&v)`, 0},
|
||||||
"rows, _ := gdb.Raw(\"SELECT 1\").Rows()\n\trows.Scan(&v)",
|
{"sql rows", `gdb.Raw("SELECT 1").Rows().Scan(&v)`, 0},
|
||||||
1,
|
|
||||||
},
|
|
||||||
{"gorm Row", `gdb.Raw("SELECT 1").Row().Scan(&v)`, 0},
|
|
||||||
{"sql QueryRow", `sqlDB.QueryRow("SELECT 1").Scan(&v)`, 0},
|
|
||||||
{
|
|
||||||
"sql QueryRowContext",
|
|
||||||
`sqlDB.QueryRowContext(ctx, "SELECT 1").Scan(&v)`,
|
|
||||||
0,
|
|
||||||
},
|
|
||||||
{"unrelated call", `gdb.Find(&v)`, 0},
|
{"unrelated call", `gdb.Find(&v)`, 0},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// plantedFile wraps one case body in a function that declares every
|
|
||||||
// name the bodies use, so each body is the Go it stands for. The result
|
|
||||||
// is parsed, never compiled.
|
|
||||||
const plantedFile = `package p
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type store struct{ db *gorm.DB }
|
|
||||||
|
|
||||||
func f(ctx context.Context, gdb *gorm.DB, sqlDB *sql.DB, s store) {
|
|
||||||
var v int
|
|
||||||
|
|
||||||
%s
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
// TestScanGuard_ReportsPlantedCalls proves the check fires. Without it
|
// TestScanGuard_ReportsPlantedCalls proves the check fires. Without it
|
||||||
// a detector that matched nothing would satisfy the walk above no
|
// a detector that matched nothing would satisfy the walk above no
|
||||||
// matter what the tree contained.
|
// matter what the tree contained.
|
||||||
@@ -306,7 +245,9 @@ func TestScanGuard_ReportsPlantedCalls(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
fset := token.NewFileSet()
|
fset := token.NewFileSet()
|
||||||
src := fmt.Sprintf(plantedFile, tc.body)
|
src := fmt.Sprintf(
|
||||||
|
"package p\n\nfunc f() {\n\t%s\n}\n", tc.body,
|
||||||
|
)
|
||||||
|
|
||||||
file, err := parser.ParseFile(
|
file, err := parser.ParseFile(
|
||||||
fset, tc.name+".go", src, 0,
|
fset, tc.name+".go", src, 0,
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
const (
|
|
||||||
// archiveExpiryNever is the archive expiry that keeps archived
|
|
||||||
// events forever. A stored empty expiry means the same.
|
|
||||||
archiveExpiryNever = "never"
|
|
||||||
|
|
||||||
// tmplKeyArchiveExpiryChoices is the template data key for the
|
|
||||||
// entries of a page's archive expiry select.
|
|
||||||
tmplKeyArchiveExpiryChoices = "ArchiveExpiryChoices"
|
|
||||||
)
|
|
||||||
|
|
||||||
// archiveChoice is one entry of a database target's archive expiry
|
|
||||||
// or archive rotation select: the value stored, the label shown, and
|
|
||||||
// whether the select starts on it.
|
|
||||||
type archiveChoice struct {
|
|
||||||
Value string
|
|
||||||
Label string
|
|
||||||
Selected bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// archiveExpiryChoices lists the archive expiries offered by the new
|
|
||||||
// webhook page, the add target form and the target edit form.
|
|
||||||
func archiveExpiryChoices() []archiveChoice {
|
|
||||||
return []archiveChoice{
|
|
||||||
{Value: archiveExpiryNever, Label: archiveExpiryNever},
|
|
||||||
{Value: "1h", Label: "1h"},
|
|
||||||
{Value: "12h", Label: "12h"},
|
|
||||||
{Value: "24h", Label: "24h"},
|
|
||||||
{Value: "720h", Label: "30d"},
|
|
||||||
{Value: "2160h", Label: "90d"},
|
|
||||||
{Value: "8760h", Label: "365d"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// archiveExpiryOptions returns the choices with expiry selected; an
|
|
||||||
// empty expiry selects never. An expiry that is not one of the
|
|
||||||
// choices comes first as its own selected entry, so saving the form
|
|
||||||
// unchanged keeps it.
|
|
||||||
func archiveExpiryOptions(expiry string) []archiveChoice {
|
|
||||||
if expiry == "" {
|
|
||||||
expiry = archiveExpiryNever
|
|
||||||
}
|
|
||||||
|
|
||||||
options := archiveExpiryChoices()
|
|
||||||
|
|
||||||
for i := range options {
|
|
||||||
if options[i].Value == expiry {
|
|
||||||
options[i].Selected = true
|
|
||||||
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
own := archiveChoice{Value: expiry, Label: expiry, Selected: true}
|
|
||||||
|
|
||||||
return append([]archiveChoice{own}, options...)
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
// expiryNever is the archive expiry that keeps archived events
|
|
||||||
// forever.
|
|
||||||
const expiryNever = "never"
|
|
||||||
|
|
||||||
// matched returns what the one group of pattern matched in page, at
|
|
||||||
// each match.
|
|
||||||
func matched(pattern, page string) []string {
|
|
||||||
matches := regexp.MustCompile(pattern).FindAllStringSubmatch(page, -1)
|
|
||||||
groups := make([]string, 0, len(matches))
|
|
||||||
|
|
||||||
for _, m := range matches {
|
|
||||||
groups = append(groups, m[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
return groups
|
|
||||||
}
|
|
||||||
|
|
||||||
// expiryShown returns the archive expiries the webhook page's target
|
|
||||||
// list shows.
|
|
||||||
func expiryShown(
|
|
||||||
t *testing.T, env *sourceTestEnv, webhookID string,
|
|
||||||
) []string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
env.handlers.HandleSourceDetail().ServeHTTP(w, getRequest(
|
|
||||||
t, "/hook/"+webhookID, env.cookies,
|
|
||||||
map[string]string{sourceIDParam: webhookID},
|
|
||||||
))
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
return matched(
|
|
||||||
`Archive expiry:</span>\s*<span>([^<]*)</span>`, w.Body.String(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// expirySelected returns the target edit page and the expiries its
|
|
||||||
// expiry select starts on.
|
|
||||||
func expirySelected(
|
|
||||||
t *testing.T, env *sourceTestEnv, webhookID, targetID string,
|
|
||||||
) (string, []string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
page := targetEditPage(t, env, webhookID, targetID)
|
|
||||||
|
|
||||||
return page, selectedIn(page, "expiry")
|
|
||||||
}
|
|
||||||
|
|
||||||
// targetEditPage returns a target's edit page.
|
|
||||||
func targetEditPage(
|
|
||||||
t *testing.T, env *sourceTestEnv, webhookID, targetID string,
|
|
||||||
) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
w := serveTarget(
|
|
||||||
env, http.MethodGet,
|
|
||||||
"/hook/"+webhookID+"/targets/"+targetID+"/edit", nil,
|
|
||||||
)
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
return w.Body.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// selectedIn returns the values the select named name on page starts
|
|
||||||
// on.
|
|
||||||
func selectedIn(page, name string) []string {
|
|
||||||
_, rest, _ := strings.Cut(page, `<select id="`+name+`" name="`+name+`"`)
|
|
||||||
options, _, _ := strings.Cut(rest, "</select>")
|
|
||||||
|
|
||||||
return matched(`<option value="([^"]*)" selected>`, options)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchiveExpiryChoices adds a database target with each archive
|
|
||||||
// expiry the forms offer, and checks that it is stored as chosen,
|
|
||||||
// shown in plain units in the target list, and that the target edit
|
|
||||||
// form starts on it.
|
|
||||||
func TestArchiveExpiryChoices(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
|
|
||||||
choices := []struct{ value, shown string }{
|
|
||||||
{expiryNever, expiryNever},
|
|
||||||
{"1h", "1 hour"},
|
|
||||||
{"12h", "12 hours"},
|
|
||||||
{"24h", "1 day"},
|
|
||||||
{"720h", "30 days"},
|
|
||||||
{"2160h", "90 days"},
|
|
||||||
{"8760h", "365 days"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, choice := range choices {
|
|
||||||
t.Run(choice.value, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
webhook := seedWebhookWithRetention(t, env.db, 30)
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("name", "archive")
|
|
||||||
form.Set("type", string(database.TargetTypeDatabase))
|
|
||||||
form.Set("expiry", choice.value)
|
|
||||||
|
|
||||||
w := serveTarget(
|
|
||||||
env, http.MethodPost, "/hook/"+webhook.ID+"/targets", form,
|
|
||||||
)
|
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
|
||||||
|
|
||||||
targets := targetsForWebhook(t, env.db, webhook.ID)
|
|
||||||
require.Len(t, targets, 1)
|
|
||||||
assert.JSONEq(
|
|
||||||
t, `{"expiry":"`+choice.value+`"}`, targets[0].Config,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t, []string{choice.shown},
|
|
||||||
expiryShown(t, env, webhook.ID),
|
|
||||||
)
|
|
||||||
|
|
||||||
_, selected := expirySelected(t, env, webhook.ID, targets[0].ID)
|
|
||||||
assert.Equal(t, []string{choice.value}, selected)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchiveExpiryEditStartsOnStoredValue checks the edit form of a
|
|
||||||
// database target whose stored expiry is empty, which selects never,
|
|
||||||
// and of one whose expiry is not one of the choices, which is listed
|
|
||||||
// first as its own selected entry and saved unchanged.
|
|
||||||
func TestArchiveExpiryEditStartsOnStoredValue(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
|
|
||||||
webhook := seedWebhookWithRetention(t, env.db, 30)
|
|
||||||
empty := seedConfiguredTarget(
|
|
||||||
t, env.db, webhook.ID, database.TargetTypeDatabase, "",
|
|
||||||
)
|
|
||||||
|
|
||||||
_, selected := expirySelected(t, env, webhook.ID, empty.ID)
|
|
||||||
assert.Equal(t, []string{expiryNever}, selected)
|
|
||||||
|
|
||||||
webhook = seedWebhookWithRetention(t, env.db, 30)
|
|
||||||
unlisted := seedConfiguredTarget(
|
|
||||||
t, env.db, webhook.ID, database.TargetTypeDatabase,
|
|
||||||
`{"expiry":"36h"}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t, []string{"36 hours"}, expiryShown(t, env, webhook.ID))
|
|
||||||
|
|
||||||
page, selected := expirySelected(t, env, webhook.ID, unlisted.ID)
|
|
||||||
assert.Equal(t, []string{"36h"}, selected)
|
|
||||||
assert.Regexp(
|
|
||||||
t,
|
|
||||||
`<select id="expiry" name="expiry" class="input">\s*`+
|
|
||||||
`<option value="36h" selected>36h</option>\s*`+
|
|
||||||
`<option value="never">never</option>`,
|
|
||||||
page,
|
|
||||||
)
|
|
||||||
assert.Contains(t, page, `<option value="8760h">365d</option>`)
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("name", unlisted.Name)
|
|
||||||
form.Set("expiry", "36h")
|
|
||||||
|
|
||||||
w := submitTargetEdit(env, webhook.ID, unlisted.ID, form)
|
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
|
||||||
assert.JSONEq(
|
|
||||||
t, `{"expiry":"36h"}`, storedTarget(t, env, unlisted.ID).Config,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
const (
|
|
||||||
// archiveRotationNone is the archive rotation that keeps a
|
|
||||||
// database target's archive in one file. A stored empty rotation
|
|
||||||
// means the same.
|
|
||||||
archiveRotationNone = "none"
|
|
||||||
|
|
||||||
// tmplKeyArchiveRotationChoices is the template data key for the
|
|
||||||
// entries of a page's archive rotation select.
|
|
||||||
tmplKeyArchiveRotationChoices = "ArchiveRotationChoices"
|
|
||||||
)
|
|
||||||
|
|
||||||
// archiveRotationChoices lists the archive rotations offered by the
|
|
||||||
// new webhook page, the add target form and the target edit form.
|
|
||||||
func archiveRotationChoices() []archiveChoice {
|
|
||||||
return []archiveChoice{
|
|
||||||
{Value: archiveRotationNone, Label: archiveRotationNone},
|
|
||||||
{Value: "monthly", Label: "monthly"},
|
|
||||||
{Value: "daily", Label: "daily"},
|
|
||||||
{Value: "hourly", Label: "hourly"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// archiveRotationOptions returns the choices with rotation selected;
|
|
||||||
// an empty rotation, or one that is not a choice, selects none. A
|
|
||||||
// stored rotation is always a choice: the forms refuse any other.
|
|
||||||
func archiveRotationOptions(rotation string) []archiveChoice {
|
|
||||||
options := archiveRotationChoices()
|
|
||||||
|
|
||||||
for i := range options {
|
|
||||||
if options[i].Value == rotation {
|
|
||||||
options[i].Selected = true
|
|
||||||
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
options[0].Selected = true
|
|
||||||
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
|
||||||
)
|
|
||||||
|
|
||||||
// rotationNone is the archive rotation that keeps one file.
|
|
||||||
const rotationNone = "none"
|
|
||||||
|
|
||||||
// rotationShown returns the archive rotations the webhook page's
|
|
||||||
// target list shows.
|
|
||||||
func rotationShown(
|
|
||||||
t *testing.T, env *sourceTestEnv, webhookID string,
|
|
||||||
) []string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
return matched(
|
|
||||||
`Archive rotation:</span>\s*<span>([^<]*)</span>`,
|
|
||||||
renderedPage(t, env, webhookID),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchiveRotationChoices adds a database target with each archive
|
|
||||||
// rotation the forms offer, and checks that it is stored as chosen,
|
|
||||||
// shown in the target list, and that the target edit form starts on
|
|
||||||
// it. It then edits the target to hourly, keeping its expiry.
|
|
||||||
func TestArchiveRotationChoices(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
|
|
||||||
for _, rotation := range []string{rotationNone, "monthly", "daily", "hourly"} {
|
|
||||||
t.Run(rotation, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
webhook := seedWebhookWithRetention(t, env.db, 30)
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("name", "archive")
|
|
||||||
form.Set("type", string(database.TargetTypeDatabase))
|
|
||||||
form.Set("expiry", "720h")
|
|
||||||
form.Set("rotation", rotation)
|
|
||||||
|
|
||||||
w := serveTarget(
|
|
||||||
env, http.MethodPost, "/hook/"+webhook.ID+"/targets", form,
|
|
||||||
)
|
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
|
||||||
|
|
||||||
targets := targetsForWebhook(t, env.db, webhook.ID)
|
|
||||||
require.Len(t, targets, 1)
|
|
||||||
assert.JSONEq(t,
|
|
||||||
`{"expiry":"720h","rotation":"`+rotation+`"}`,
|
|
||||||
targets[0].Config,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t,
|
|
||||||
[]string{rotation}, rotationShown(t, env, webhook.ID))
|
|
||||||
|
|
||||||
page := targetEditPage(t, env, webhook.ID, targets[0].ID)
|
|
||||||
assert.Equal(t, []string{rotation}, selectedIn(page, "rotation"))
|
|
||||||
|
|
||||||
form = url.Values{}
|
|
||||||
form.Set("name", "archive")
|
|
||||||
form.Set("expiry", "720h")
|
|
||||||
form.Set("rotation", "hourly")
|
|
||||||
|
|
||||||
w = submitTargetEdit(env, webhook.ID, targets[0].ID, form)
|
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
|
||||||
assert.JSONEq(t,
|
|
||||||
`{"expiry":"720h","rotation":"hourly"}`,
|
|
||||||
storedTarget(t, env, targets[0].ID).Config,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchiveRotationEditStartsOnNone checks the edit form of a
|
|
||||||
// database target with no rotation stored starts on none.
|
|
||||||
func TestArchiveRotationEditStartsOnNone(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
webhook := seedWebhookWithRetention(t, env.db, 30)
|
|
||||||
target := seedConfiguredTarget(
|
|
||||||
t, env.db, webhook.ID, database.TargetTypeDatabase, "",
|
|
||||||
)
|
|
||||||
|
|
||||||
page := targetEditPage(t, env, webhook.ID, target.ID)
|
|
||||||
assert.Equal(t, []string{rotationNone}, selectedIn(page, "rotation"))
|
|
||||||
assert.Equal(t, []string{rotationNone}, rotationShown(t, env, webhook.ID))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchiveRotationRefused proves a rotation that is not one of the
|
|
||||||
// four is refused on the add target form and the target edit form, and
|
|
||||||
// that nothing is stored.
|
|
||||||
func TestArchiveRotationRefused(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
webhook := seedWebhookWithRetention(t, env.db, 30)
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("name", "archive")
|
|
||||||
form.Set("type", string(database.TargetTypeDatabase))
|
|
||||||
form.Set("rotation", "weekly")
|
|
||||||
|
|
||||||
w := serveTarget(
|
|
||||||
env, http.MethodPost, "/hook/"+webhook.ID+"/targets", form,
|
|
||||||
)
|
|
||||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
||||||
assert.Contains(t, w.Body.String(), "Invalid archive rotation")
|
|
||||||
assert.Empty(t, targetsForWebhook(t, env.db, webhook.ID))
|
|
||||||
|
|
||||||
target := seedConfiguredTarget(
|
|
||||||
t, env.db, webhook.ID, database.TargetTypeDatabase,
|
|
||||||
`{"rotation":"daily"}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
form.Del("type")
|
|
||||||
|
|
||||||
w = submitTargetEdit(env, webhook.ID, target.ID, form)
|
|
||||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
||||||
assert.Contains(t, w.Body.String(), "Invalid archive rotation")
|
|
||||||
assert.JSONEq(t,
|
|
||||||
`{"rotation":"daily"}`, storedTarget(t, env, target.ID).Config,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceCreateSubmit_ArchiveRotation proves the new webhook
|
|
||||||
// page's archive rotation is stored on the archive target it creates.
|
|
||||||
func TestHandleSourceCreateSubmit_ArchiveRotation(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("name", "rotated")
|
|
||||||
form.Set("archive", "on")
|
|
||||||
form.Set("archive_expiry", "720h")
|
|
||||||
form.Set("archive_rotation", "daily")
|
|
||||||
|
|
||||||
w := submitCreateForm(env, form)
|
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
|
||||||
|
|
||||||
var webhook database.Webhook
|
|
||||||
|
|
||||||
require.NoError(t, env.db.DB().
|
|
||||||
Where("name = ?", "rotated").First(&webhook).Error)
|
|
||||||
|
|
||||||
targets := targetsForWebhook(t, env.db, webhook.ID)
|
|
||||||
require.Len(t, targets, 1)
|
|
||||||
assert.JSONEq(t,
|
|
||||||
`{"expiry":"720h","rotation":"daily"}`, targets[0].Config,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchiveFileView_Rotated describes a daily target's archive files
|
|
||||||
// at two times. On a day that has a file, the view names that file;
|
|
||||||
// on the next, before any event, it names the file the next event
|
|
||||||
// will go to, not created yet. Both times the size is of every file
|
|
||||||
// together and the last write the latest of them.
|
|
||||||
func TestArchiveFileView_Rotated(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
webhook := seedWebhookWithRetention(t, env.db, 30)
|
|
||||||
target := seedConfiguredTarget(
|
|
||||||
t, env.db, webhook.ID, database.TargetTypeDatabase,
|
|
||||||
`{"rotation":"daily"}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
path := delivery.ArchivePath(env.dbMgr, &webhook, target)
|
|
||||||
stem := strings.TrimSuffix(path, ".db")
|
|
||||||
written := time.Date(2026, 10, 2, 9, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
for i, day := range []string{"2026-10-01", "2026-10-02"} {
|
|
||||||
file := stem + "-" + day + ".db"
|
|
||||||
require.NoError(t, os.WriteFile(file, make([]byte, 1000), 0o600))
|
|
||||||
|
|
||||||
at := written.Add(time.Duration(i-1) * 24 * time.Hour)
|
|
||||||
require.NoError(t, os.Chtimes(file, at, at))
|
|
||||||
}
|
|
||||||
|
|
||||||
view := env.handlers.ArchiveFileViewForTest(
|
|
||||||
&webhook, target, time.Date(2026, 10, 2, 23, 0, 0, 0, time.UTC),
|
|
||||||
)
|
|
||||||
assert.Equal(t, filepath.Base(stem)+"-2026-10-02.db", view.Name)
|
|
||||||
assert.Empty(t, view.Note)
|
|
||||||
assert.Equal(t, 2, view.Files)
|
|
||||||
assert.Equal(t, "2.0 kB", view.Size)
|
|
||||||
assert.Equal(t, "2026-10-02 09:00:00 UTC", view.WrittenUTC)
|
|
||||||
|
|
||||||
view = env.handlers.ArchiveFileViewForTest(
|
|
||||||
&webhook, target, time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC),
|
|
||||||
)
|
|
||||||
assert.Equal(t, filepath.Base(stem)+"-2026-10-03.db", view.Name)
|
|
||||||
assert.Equal(t, "not created yet", view.Note)
|
|
||||||
assert.Equal(t, 2, view.Files)
|
|
||||||
assert.Equal(t, "2.0 kB", view.Size)
|
|
||||||
|
|
||||||
page := targetList(t, renderedPage(t, env, webhook.ID))
|
|
||||||
assert.Contains(t, page, "Archive size: 2.0 kB in 2 files")
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderedPage returns the webhook page.
|
|
||||||
func renderedPage(t *testing.T, env *sourceTestEnv, webhookID string) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
env.handlers.HandleSourceDetail().ServeHTTP(w, getRequest(
|
|
||||||
t, "/hook/"+webhookID, env.cookies,
|
|
||||||
map[string]string{sourceIDParam: webhookID},
|
|
||||||
))
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
return w.Body.String()
|
|
||||||
}
|
|
||||||
+28
-11
@@ -74,7 +74,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
|
|||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("failed to parse form", "error", err)
|
h.log.Error("failed to parse form", "error", err)
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -139,7 +139,8 @@ func (h *Handlers) renderLoginError(
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplateStatus(w, r, "login.html", data, status)
|
w.WriteHeader(status)
|
||||||
|
h.renderTemplate(w, r, "login.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// authenticateUser looks up and verifies a user's credentials.
|
// authenticateUser looks up and verifies a user's credentials.
|
||||||
@@ -211,7 +212,11 @@ func (h *Handlers) authenticateUser(
|
|||||||
|
|
||||||
valid, err := database.VerifyPassword(password, user.Password)
|
valid, err := database.VerifyPassword(password, user.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to verify password", err)
|
h.log.Error("failed to verify password", "error", err)
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return user, err
|
return user, err
|
||||||
}
|
}
|
||||||
@@ -268,7 +273,7 @@ func (h *Handlers) rejectLogin(
|
|||||||
)))
|
)))
|
||||||
h.renderLoginError(
|
h.renderLoginError(
|
||||||
w, r,
|
w, r,
|
||||||
"Too many failed sign-in attempts. Please try again later.",
|
"Too many failed login attempts. Please try again later.",
|
||||||
http.StatusTooManyRequests,
|
http.StatusTooManyRequests,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -283,14 +288,24 @@ func (h *Handlers) createAuthenticatedSession(
|
|||||||
) error {
|
) error {
|
||||||
oldSess, err := h.session.Get(r)
|
oldSess, err := h.session.Get(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to get session", err)
|
h.log.Error("failed to get session", "error", err)
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
sess, err := h.session.Regenerate(r, w, oldSess)
|
sess, err := h.session.Regenerate(r, w, oldSess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to regenerate session", err)
|
h.log.Error(
|
||||||
|
"failed to regenerate session", "error", err,
|
||||||
|
)
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -299,7 +314,11 @@ func (h *Handlers) createAuthenticatedSession(
|
|||||||
|
|
||||||
err = h.session.Save(r, w, sess)
|
err = h.session.Save(r, w, sess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to save session", err)
|
h.log.Error("failed to save session", "error", err)
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -332,9 +351,7 @@ func (h *Handlers) HandleLogout() http.HandlerFunc {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
// Redirect to login page
|
||||||
w, r, withNotice("/pages/login", signedOut),
|
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
|
||||||
http.StatusSeeOther,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package handlers_test
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -405,60 +404,6 @@ func TestLogin_MissingCredentialsRejectedBeforeAnyHash(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestLogin_FormErrorAnswersItsStatusWithThePage proves that the login
|
|
||||||
// form shown again with an error still answers 400 with the whole page.
|
|
||||||
func TestLogin_FormErrorAnswersItsStatusWithThePage(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var h *handlers.Handlers
|
|
||||||
|
|
||||||
app := newTestApp(t, &h)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
w := submitLogin(h, sharedProxyPeer, "", "")
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
||||||
assert.Contains(
|
|
||||||
t, w.Body.String(), "Username and password are required",
|
|
||||||
)
|
|
||||||
assert.Contains(
|
|
||||||
t, w.Body.String(), "</html>",
|
|
||||||
"the page must render to completion",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLogin_FormErrorRenderFailureAnswers500 proves that a login form
|
|
||||||
// error page whose template fails answers 500 with the error page and
|
|
||||||
// none of the form page, rather than the 400 it meant to send.
|
|
||||||
func TestLogin_FormErrorRenderFailureAnswers500(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var h *handlers.Handlers
|
|
||||||
|
|
||||||
app := newTestApp(t, &h)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
// The page prints its error message and then fails.
|
|
||||||
h.AddTemplateForTest("login.html", template.Must(
|
|
||||||
template.New("login").Funcs(template.FuncMap{
|
|
||||||
"fail": func() (string, error) { return "", errMidRender },
|
|
||||||
}).Parse(`{{.Error}}{{fail}}`),
|
|
||||||
))
|
|
||||||
|
|
||||||
w := submitLogin(h, sharedProxyPeer, "", "")
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
||||||
assert.NotContains(
|
|
||||||
t, w.Body.String(), "Username and password are required",
|
|
||||||
"the response must carry no part of the aborted page",
|
|
||||||
)
|
|
||||||
assert.Contains(t, w.Body.String(), "500 Internal Server Error")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLogin_SuccessCreatesSession is the control for the tests above:
|
// TestLogin_SuccessCreatesSession is the control for the tests above:
|
||||||
// the success path they assert on really does authenticate.
|
// the success path they assert on really does authenticate.
|
||||||
func TestLogin_SuccessCreatesSession(t *testing.T) {
|
func TestLogin_SuccessCreatesSession(t *testing.T) {
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestDeliveryAttempts_ReadInTheTargetTypesOwnTerms proves, on the
|
|
||||||
// event's page and in the event log, that an http or slack attempt
|
|
||||||
// shows its status as before, while a database or log attempt, which
|
|
||||||
// sends no HTTP request, says what it did and shows no status.
|
|
||||||
func TestDeliveryAttempts_ReadInTheTargetTypesOwnTerms(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
targetType database.TargetType
|
|
||||||
success bool
|
|
||||||
statusCode int
|
|
||||||
errText string
|
|
||||||
outcome string
|
|
||||||
status string // "" when the attempt must show no status
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
database.TargetTypeHTTP, false, 0, "",
|
|
||||||
"failure", "Status: — (no response)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
database.TargetTypeSlack, true, http.StatusOK, "",
|
|
||||||
"success", "Status: 200",
|
|
||||||
},
|
|
||||||
{database.TargetTypeDatabase, true, 0, "", "archived", ""},
|
|
||||||
{
|
|
||||||
database.TargetTypeDatabase, false, 0,
|
|
||||||
"opening archive database: disk full", "failure", "",
|
|
||||||
},
|
|
||||||
{database.TargetTypeLog, true, 0, "", "written to the log", ""},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(string(tc.targetType), func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newRecentEventsFixture(t)
|
|
||||||
target := seedTarget(t, f.db, f.webhook.ID, tc.targetType)
|
|
||||||
event := f.event(t, contentTypeJSON, "{}", time.Now())
|
|
||||||
dlv := f.delivery(
|
|
||||||
t, event, target.ID, database.DeliveryStatusDelivered,
|
|
||||||
)
|
|
||||||
|
|
||||||
require.NoError(t, f.webhookDB.Omit(clause.Associations).Create(
|
|
||||||
&database.DeliveryResult{
|
|
||||||
DeliveryID: dlv.ID,
|
|
||||||
AttemptNum: 1,
|
|
||||||
Success: tc.success,
|
|
||||||
StatusCode: tc.statusCode,
|
|
||||||
Error: tc.errText,
|
|
||||||
},
|
|
||||||
).Error)
|
|
||||||
|
|
||||||
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
pages := []string{
|
|
||||||
w.Body.String(),
|
|
||||||
renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, page := range pages {
|
|
||||||
assert.Contains(t, page, ">"+tc.outcome+"</span>")
|
|
||||||
|
|
||||||
if tc.errText != "" {
|
|
||||||
assert.Contains(t, page, "Error: "+tc.errText)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tc.status == "" {
|
|
||||||
assert.NotContains(t, page, "Status:")
|
|
||||||
} else {
|
|
||||||
assert.Contains(t, page, tc.status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -10,39 +11,72 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The outcomes of a replay POST, as the notice codes its redirect
|
// replayOutcomeParam is the query parameter the replay POST redirects
|
||||||
// carries. noticeFor holds the line each one shows.
|
// with and the event log page reads its banner from.
|
||||||
|
const replayOutcomeParam = "replay"
|
||||||
|
|
||||||
|
// replayOutcomeCode is the outcome of a replay POST. The redirect
|
||||||
|
// carries one of these fixed codes rather than a message, so nothing a
|
||||||
|
// client submits can reach the rendered page through it.
|
||||||
|
type replayOutcomeCode string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// replayQueued reports that a new delivery was created and handed
|
// replayQueued reports that a new delivery was created and handed
|
||||||
// to the delivery engine.
|
// to the delivery engine.
|
||||||
replayQueued noticeCode = "replay-queued"
|
replayQueued replayOutcomeCode = "queued"
|
||||||
|
|
||||||
// replayTargetDeleted reports a target that once existed and has
|
// replayTargetDeleted reports a target that once existed and has
|
||||||
// since been deleted. Deletes are soft and deliveries carry no
|
// since been deleted. Deletes are soft and deliveries carry no
|
||||||
// foreign key to the target row, so the history survives its
|
// foreign key to the target row, so the history survives its
|
||||||
// target and this is the ordinary case for an old event. The
|
// target and this is the ordinary case for an old event.
|
||||||
// event log shows no Replay button for such a delivery, so only
|
replayTargetDeleted replayOutcomeCode = "target-deleted"
|
||||||
// a page loaded before the delete reaches this.
|
|
||||||
replayTargetDeleted noticeCode = "replay-target-deleted"
|
|
||||||
|
|
||||||
// replayTargetMissing reports a target id that names no row at
|
// replayTargetMissing reports a target id that names no row at
|
||||||
// all, deleted or otherwise.
|
// all, deleted or otherwise.
|
||||||
replayTargetMissing noticeCode = "replay-target-missing"
|
replayTargetMissing replayOutcomeCode = "target-missing"
|
||||||
|
|
||||||
// replayTargetInactive reports a target the operator has
|
// replayTargetInactive reports a target the operator has
|
||||||
// deactivated. A deactivated target receives no new deliveries, so
|
// deactivated. A deactivated target receives no new deliveries, so
|
||||||
// a replay to it would be a delivery they switched off.
|
// a replay to it would be a delivery they switched off.
|
||||||
replayTargetInactive noticeCode = "replay-target-inactive"
|
replayTargetInactive replayOutcomeCode = "target-inactive"
|
||||||
|
|
||||||
// replayNotTerminal reports a delivery the engine has not finished
|
// replayNotTerminal reports a delivery the engine has not finished
|
||||||
// with.
|
// with.
|
||||||
replayNotTerminal noticeCode = "replay-not-terminal"
|
replayNotTerminal replayOutcomeCode = "not-terminal"
|
||||||
|
|
||||||
// replayInFlight reports that an earlier replay of this event to
|
// replayInFlight reports that an earlier replay of this event to
|
||||||
// this target is still running.
|
// this target is still running.
|
||||||
replayInFlight noticeCode = "replay-in-flight"
|
replayInFlight replayOutcomeCode = "in-flight"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// replayOutcome returns the banner the event log page shows for an
|
||||||
|
// outcome code, and whether the replay was queued. An unrecognised
|
||||||
|
// code yields no banner.
|
||||||
|
func replayOutcome(code string) (string, bool) {
|
||||||
|
switch replayOutcomeCode(code) {
|
||||||
|
case replayQueued:
|
||||||
|
return "Replay queued: a new delivery was created against " +
|
||||||
|
"the target's current configuration.", true
|
||||||
|
case replayTargetDeleted:
|
||||||
|
return "Not replayed: the target this delivery was for has " +
|
||||||
|
"been deleted. Recreate the target, then replay.", false
|
||||||
|
case replayTargetMissing:
|
||||||
|
return "Not replayed: the target this delivery was for no " +
|
||||||
|
"longer exists.", false
|
||||||
|
case replayTargetInactive:
|
||||||
|
return "Not replayed: the target this delivery was for is " +
|
||||||
|
"deactivated. Activate it, then replay.", false
|
||||||
|
case replayNotTerminal:
|
||||||
|
return "Not replayed: this delivery has not finished yet.",
|
||||||
|
false
|
||||||
|
case replayInFlight:
|
||||||
|
return "Not replayed: a delivery of this event to this " +
|
||||||
|
"target is already in flight.", false
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// HandleDeliveryReplay re-sends a finished delivery's event to its
|
// HandleDeliveryReplay re-sends a finished delivery's event to its
|
||||||
// target.
|
// target.
|
||||||
//
|
//
|
||||||
@@ -71,7 +105,9 @@ func (h *Handlers) HandleDeliveryReplay() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
http.Error(
|
||||||
|
w, "Bad request", http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -88,14 +124,14 @@ func (h *Handlers) replayDelivery(
|
|||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
) {
|
) {
|
||||||
if !h.dbMgr.DBExists(webhook.ID) {
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to get webhook database", err)
|
h.serverError(w, "failed to get webhook database", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -106,14 +142,14 @@ func (h *Handlers) replayDelivery(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !original.Status.Terminal() {
|
if !original.Status.Terminal() {
|
||||||
redirectToEventLog(w, r, webhook, replayNotTerminal)
|
h.finishReplay(w, r, webhook, replayNotTerminal)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
target, code := h.replayTarget(webhook.ID, original.TargetID)
|
target, code := h.replayTarget(webhook.ID, original.TargetID)
|
||||||
if target == nil {
|
if target == nil {
|
||||||
redirectToEventLog(w, r, webhook, code)
|
h.finishReplay(w, r, webhook, code)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -137,7 +173,7 @@ func (h *Handlers) loadReplaySource(
|
|||||||
&original, "id = ?", chi.URLParam(r, "deliveryID"),
|
&original, "id = ?", chi.URLParam(r, "deliveryID"),
|
||||||
).Error
|
).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
@@ -159,14 +195,14 @@ func (h *Handlers) queueReplay(
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, r, "failed to count in-flight deliveries", err,
|
w, "failed to count in-flight deliveries", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if inFlight > 0 {
|
if inFlight > 0 {
|
||||||
redirectToEventLog(w, r, webhook, replayInFlight)
|
h.finishReplay(w, r, webhook, replayInFlight)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -176,7 +212,7 @@ func (h *Handlers) queueReplay(
|
|||||||
err = webhookDB.
|
err = webhookDB.
|
||||||
First(&event, "id = ?", original.EventID).Error
|
First(&event, "id = ?", original.EventID).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to load event for replay", err)
|
h.serverError(w, "failed to load event for replay", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -186,7 +222,7 @@ func (h *Handlers) queueReplay(
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, r, "failed to create replay delivery", err,
|
w, "failed to create replay delivery", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -204,7 +240,7 @@ func (h *Handlers) queueReplay(
|
|||||||
"delivery_id", task.DeliveryID,
|
"delivery_id", task.DeliveryID,
|
||||||
)
|
)
|
||||||
|
|
||||||
redirectToEventLog(w, r, webhook, replayQueued)
|
h.finishReplay(w, r, webhook, replayQueued)
|
||||||
}
|
}
|
||||||
|
|
||||||
// replayTarget loads the delivery's target as it stands now.
|
// replayTarget loads the delivery's target as it stands now.
|
||||||
@@ -217,7 +253,7 @@ func (h *Handlers) queueReplay(
|
|||||||
// with the returned code saying why.
|
// with the returned code saying why.
|
||||||
func (h *Handlers) replayTarget(
|
func (h *Handlers) replayTarget(
|
||||||
webhookID, targetID string,
|
webhookID, targetID string,
|
||||||
) (*database.Target, noticeCode) {
|
) (*database.Target, replayOutcomeCode) {
|
||||||
var target database.Target
|
var target database.Target
|
||||||
|
|
||||||
err := h.db.DB().Unscoped().Where(
|
err := h.db.DB().Unscoped().Where(
|
||||||
@@ -282,7 +318,6 @@ func createReplayDelivery(
|
|||||||
EventID: event.ID,
|
EventID: event.ID,
|
||||||
TargetID: target.ID,
|
TargetID: target.ID,
|
||||||
Status: database.DeliveryStatusPending,
|
Status: database.DeliveryStatusPending,
|
||||||
Replay: true,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err := webhookDB.Transaction(func(tx *gorm.DB) error {
|
err := webhookDB.Transaction(func(tx *gorm.DB) error {
|
||||||
@@ -328,26 +363,26 @@ func replayBody(body string) *string {
|
|||||||
return &body
|
return &body
|
||||||
}
|
}
|
||||||
|
|
||||||
// redirectToEventLog redirects a replay or resubmit back to the event
|
// finishReplay redirects back to the event log the replay was
|
||||||
// log it was triggered from, carrying the outcome as its notice. A
|
// triggered from, carrying the outcome code the page turns into a
|
||||||
// Replay form carries the list it was pressed in as show, so a replay
|
// banner and the page number the form submitted.
|
||||||
// returns to the Failed or Pending list; a Resubmit form carries none,
|
func (h *Handlers) finishReplay(
|
||||||
// so a resubmit returns to the full log, where its new event is the
|
|
||||||
// newest.
|
|
||||||
func redirectToEventLog(
|
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
code noticeCode,
|
code replayOutcomeCode,
|
||||||
) {
|
) {
|
||||||
location := withNotice("/hook/"+webhook.ID+"/events", code)
|
dest := "/hook/" + webhook.ID + "/events?" +
|
||||||
|
replayOutcomeParam + "=" + string(code)
|
||||||
|
|
||||||
show := r.PostFormValue(showParam)
|
// The page is read from the form rather than the query string:
|
||||||
if eventLogStatuses(show) != nil {
|
// this is a POST, and its query string is what logs and Referer
|
||||||
location += "&" + showParam + "=" + show
|
// headers record.
|
||||||
|
if page := pageOrFirst(
|
||||||
|
r.PostFormValue("page"),
|
||||||
|
); page > 1 {
|
||||||
|
dest += "&page=" + strconv.Itoa(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect( //nolint:gosec // show is checked by eventLogStatuses
|
http.Redirect(w, r, dest, http.StatusSeeOther)
|
||||||
w, r, location, http.StatusSeeOther,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
package handlers_test
|
package handlers_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -214,7 +212,7 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/hook/"+wh.ID+"/events?notice=replay-queued",
|
"/hook/"+wh.ID+"/events?replay=queued",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -364,7 +362,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/hook/"+wh.ID+"/events?notice=replay-target-deleted",
|
"/hook/"+wh.ID+"/events?replay=target-deleted",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -392,7 +390,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusSeeOther, missing.Code)
|
require.Equal(t, http.StatusSeeOther, missing.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/hook/"+wh.ID+"/events?notice=replay-target-missing",
|
"/hook/"+wh.ID+"/events?replay=target-missing",
|
||||||
missing.Header().Get("Location"),
|
missing.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -433,7 +431,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
|||||||
require.Equal(t, http.StatusSeeOther, first.Code)
|
require.Equal(t, http.StatusSeeOther, first.Code)
|
||||||
require.Equal(
|
require.Equal(
|
||||||
t,
|
t,
|
||||||
"/hook/"+wh.ID+"/events?notice=replay-queued",
|
"/hook/"+wh.ID+"/events?replay=queued",
|
||||||
first.Header().Get("Location"),
|
first.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -441,7 +439,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
|||||||
require.Equal(t, http.StatusSeeOther, second.Code)
|
require.Equal(t, http.StatusSeeOther, second.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/hook/"+wh.ID+"/events?notice=replay-in-flight",
|
"/hook/"+wh.ID+"/events?replay=in-flight",
|
||||||
second.Header().Get("Location"),
|
second.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -467,71 +465,11 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
|||||||
require.Equal(t, http.StatusSeeOther, pending.Code)
|
require.Equal(t, http.StatusSeeOther, pending.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/hook/"+wh.ID+"/events?notice=replay-not-terminal",
|
"/hook/"+wh.ID+"/events?replay=not-terminal",
|
||||||
pending.Header().Get("Location"),
|
pending.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleDeliveryReplay_ReturnsToTheListItWasPressedIn proves a
|
|
||||||
// Replay pressed in the Failed list carries that list in its form and
|
|
||||||
// returns to it, and that a show value the event log does not know
|
|
||||||
// returns to the full log.
|
|
||||||
func TestHandleDeliveryReplay_ReturnsToTheListItWasPressedIn(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
dbMgr *database.WebhookDBManager
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
wh := seedWebhook(t, db)
|
|
||||||
tgt := seedConfiguredTarget(
|
|
||||||
t, db, wh.ID, database.TargetTypeHTTP,
|
|
||||||
`{"url":"`+replayTargetURL+`"}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
_, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
|
|
||||||
|
|
||||||
assert.Contains(t, renderSourceLogsPageWithQuery(
|
|
||||||
t, h, sess, wh.ID, "?show=failed",
|
|
||||||
), `name="show" value="failed"`)
|
|
||||||
|
|
||||||
// The second replay is refused, as the first is still queued.
|
|
||||||
for _, tc := range []struct{ show, location string }{
|
|
||||||
{"failed", "/hook/" + wh.ID +
|
|
||||||
"/events?notice=replay-queued&show=failed"},
|
|
||||||
{"made-up", "/hook/" + wh.ID + "/events?notice=replay-in-flight"},
|
|
||||||
} {
|
|
||||||
req := postRequest(
|
|
||||||
"/hook/"+wh.ID+"/deliveries/"+original.ID+"/replay",
|
|
||||||
authenticatedCookies(
|
|
||||||
t, sess, deleteTestUserID, deleteTestUsername,
|
|
||||||
),
|
|
||||||
map[string]string{
|
|
||||||
paramSourceID: wh.ID,
|
|
||||||
paramDeliveryID: original.ID,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
req.Body = io.NopCloser(strings.NewReader("show=" + tc.show))
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.HandleDeliveryReplay().ServeHTTP(w, req)
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code, tc.show)
|
|
||||||
assert.Equal(t, tc.location, w.Header().Get("Location"), tc.show)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceLogs_RendersReplayControlAndBanner proves the action
|
// TestHandleSourceLogs_RendersReplayControlAndBanner proves the action
|
||||||
// reaches the page it belongs on: a finished delivery renders a POST
|
// reaches the page it belongs on: a finished delivery renders a POST
|
||||||
// form carrying a CSRF token, and the outcome code a refusal redirects
|
// form carrying a CSRF token, and the outcome code a refusal redirects
|
||||||
@@ -571,77 +509,18 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
|
|||||||
assert.Contains(t, body, ">Replay<")
|
assert.Contains(t, body, ">Replay<")
|
||||||
|
|
||||||
refused := renderSourceLogsPageWithQuery(
|
refused := renderSourceLogsPageWithQuery(
|
||||||
t, h, sess, wh.ID, "?notice=replay-target-deleted",
|
t, h, sess, wh.ID, "?replay=target-deleted",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Contains(t, refused, "alert-error")
|
assert.Contains(t, refused, "alert-error")
|
||||||
assert.Contains(
|
assert.Contains(t, refused, "has been deleted")
|
||||||
t, refused,
|
|
||||||
"has been deleted. Use Resubmit to send the event "+
|
|
||||||
"to the webhook",
|
|
||||||
)
|
|
||||||
|
|
||||||
// An outcome code nobody issued renders no banner at all.
|
// An outcome code nobody issued renders no banner at all.
|
||||||
unknown := renderSourceLogsPageWithQuery(
|
unknown := renderSourceLogsPageWithQuery(
|
||||||
t, h, sess, wh.ID, "?notice=made-up",
|
t, h, sess, wh.ID, "?replay=made-up",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.NotContains(t, unknown, "alert-error")
|
assert.NotContains(t, unknown, "alert-error")
|
||||||
assert.NotContains(t, unknown, "alert-success")
|
assert.NotContains(t, unknown, "alert-success")
|
||||||
assert.NotContains(t, unknown, "made-up")
|
assert.NotContains(t, unknown, "made-up")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleDeliveryReplay_LabelsTheReplay proves a delivery created
|
|
||||||
// by Replay is labelled as a replay in the event's summary line in the
|
|
||||||
// event log, and in the list of the event's deliveries there and on
|
|
||||||
// the event's page, while the delivery it repeats is not.
|
|
||||||
func TestHandleDeliveryReplay_LabelsTheReplay(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
dbMgr *database.WebhookDBManager
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
wh := seedWebhook(t, db)
|
|
||||||
tgt := seedConfiguredTarget(
|
|
||||||
t, db, wh.ID, database.TargetTypeHTTP,
|
|
||||||
`{"url":"`+replayTargetURL+`"}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
event, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
|
|
||||||
|
|
||||||
w := postReplay(t, h, sess, wh.ID, original.ID)
|
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
||||||
|
|
||||||
eventLog := renderSourceLogsPage(t, h, sess, wh.ID)
|
|
||||||
|
|
||||||
assert.Contains(t, eventLog, tgt.Name+": failed")
|
|
||||||
assert.Contains(t, eventLog, tgt.Name+" (replay): pending")
|
|
||||||
|
|
||||||
w = serveEventPage(t, h, sess, wh.ID, event.ID)
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
// In each delivery list a row names the target, then the label if
|
|
||||||
// it is a replay, then its status: the replay is still pending, the
|
|
||||||
// original failed.
|
|
||||||
replayRow := tgt.Name + `</span> ` +
|
|
||||||
`<span class="text-xs text-gray-500">replay</span> ` +
|
|
||||||
`<span class="text-xs text-gray-400">pending</span>`
|
|
||||||
originalRow := tgt.Name + `</span> ` +
|
|
||||||
`<span class="text-xs text-red-600">failed</span>`
|
|
||||||
|
|
||||||
for _, page := range []string{eventLog, w.Body.String()} {
|
|
||||||
page = strings.Join(strings.Fields(page), " ")
|
|
||||||
|
|
||||||
assert.Contains(t, page, replayRow)
|
|
||||||
assert.Contains(t, page, originalRow)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/dustin/go-humanize"
|
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,8 +24,8 @@ const maxRenderedResponseBytes = 4096
|
|||||||
// bytes rather than characters, and they make SQLite do the
|
// bytes rather than characters, and they make SQLite do the
|
||||||
// cut, so an oversized stored response never becomes a Go
|
// cut, so an oversized stored response never becomes a Go
|
||||||
// string at all.
|
// string at all.
|
||||||
const deliveryResultColumns = "delivery_id, attempt_num, created_at, " +
|
const deliveryResultColumns = "delivery_id, attempt_num, success, " +
|
||||||
"success, status_code, error, duration, " +
|
"status_code, error, duration, " +
|
||||||
"substr(cast(response_body as blob), 1, ?) AS response_body, " +
|
"substr(cast(response_body as blob), 1, ?) AS response_body, " +
|
||||||
"length(cast(response_body as blob)) AS response_bytes"
|
"length(cast(response_body as blob)) AS response_bytes"
|
||||||
|
|
||||||
@@ -48,11 +45,6 @@ type DeliveryResultView struct {
|
|||||||
AttemptNum int
|
AttemptNum int
|
||||||
Success bool
|
Success bool
|
||||||
|
|
||||||
// Ran is how long ago the attempt was recorded, and RanUTC the
|
|
||||||
// full timestamp the page shows on hover.
|
|
||||||
Ran string
|
|
||||||
RanUTC string
|
|
||||||
|
|
||||||
// StatusCode is 0 when the attempt never got a response,
|
// StatusCode is 0 when the attempt never got a response,
|
||||||
// which is why the page asks HasStatusCode rather than
|
// which is why the page asks HasStatusCode rather than
|
||||||
// printing the number.
|
// printing the number.
|
||||||
@@ -108,7 +100,6 @@ func (v DeliveryResultView) HasStatusCode() bool {
|
|||||||
type deliveryResultRow struct {
|
type deliveryResultRow struct {
|
||||||
DeliveryID string
|
DeliveryID string
|
||||||
AttemptNum int
|
AttemptNum int
|
||||||
CreatedAt time.Time
|
|
||||||
Success bool
|
Success bool
|
||||||
StatusCode int
|
StatusCode int
|
||||||
Error string
|
Error string
|
||||||
@@ -166,8 +157,6 @@ func (r *deliveryResultRow) view(
|
|||||||
return DeliveryResultView{
|
return DeliveryResultView{
|
||||||
AttemptNum: r.AttemptNum,
|
AttemptNum: r.AttemptNum,
|
||||||
Success: r.Success,
|
Success: r.Success,
|
||||||
Ran: humanize.Time(r.CreatedAt),
|
|
||||||
RanUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
|
|
||||||
StatusCode: r.StatusCode,
|
StatusCode: r.StatusCode,
|
||||||
Error: redactor.Redact(r.Error),
|
Error: redactor.Redact(r.Error),
|
||||||
DurationMS: r.Duration,
|
DurationMS: r.Duration,
|
||||||
|
|||||||
@@ -435,7 +435,9 @@ func TestHandleSourceLogs_BoundsRenderedAttempts(t *testing.T) {
|
|||||||
}).Error)
|
}).Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh)
|
views := h.LoadEventLogViewsForTest(
|
||||||
|
httptest.NewRecorder(), *wh, 1,
|
||||||
|
)
|
||||||
require.Len(t, views, 1)
|
require.Len(t, views, 1)
|
||||||
require.Len(t, views[0].Deliveries, 1)
|
require.Len(t, views[0].Deliveries, 1)
|
||||||
|
|
||||||
@@ -487,7 +489,9 @@ func TestHandleSourceLogs_BoundsOversizeResponse(t *testing.T) {
|
|||||||
stored := strings.Repeat("A", responseCap*4) + tail
|
stored := strings.Repeat("A", responseCap*4) + tail
|
||||||
seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, stored)
|
seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, stored)
|
||||||
|
|
||||||
views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh)
|
views := h.LoadEventLogViewsForTest(
|
||||||
|
httptest.NewRecorder(), *wh, 1,
|
||||||
|
)
|
||||||
require.Len(t, views, 1)
|
require.Len(t, views, 1)
|
||||||
require.Len(t, views[0].Deliveries, 1)
|
require.Len(t, views[0].Deliveries, 1)
|
||||||
require.Len(t, views[0].Deliveries[0].Results, 1)
|
require.Len(t, views[0].Deliveries[0].Results, 1)
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
// HandleEntrypointCreate handles adding a new entrypoint.
|
|
||||||
func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userID, ok := h.getUserID(r)
|
|
||||||
if !ok {
|
|
||||||
http.Redirect(
|
|
||||||
w, r, "/pages/login", http.StatusSeeOther,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceID := chi.URLParam(r, "sourceID")
|
|
||||||
|
|
||||||
var webhook database.Webhook
|
|
||||||
|
|
||||||
err := h.db.DB().Where(
|
|
||||||
"id = ? AND user_id = ?", sourceID, userID,
|
|
||||||
).First(&webhook).Error
|
|
||||||
if err != nil {
|
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// The body size cap is enforced by the MaxBodySize
|
|
||||||
// middleware, which runs before CSRF parses the form.
|
|
||||||
err = r.ParseForm()
|
|
||||||
if err != nil {
|
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
description := r.PostFormValue("description")
|
|
||||||
|
|
||||||
entrypoint := &database.Entrypoint{
|
|
||||||
WebhookID: webhook.ID,
|
|
||||||
Path: uuid.New().String(),
|
|
||||||
Description: description,
|
|
||||||
Active: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.db.DB().Create(entrypoint).Error
|
|
||||||
if err != nil {
|
|
||||||
h.serverError(w, r, "failed to create entrypoint", err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
http.Redirect(
|
|
||||||
w, r, withNotice("/hook/"+webhook.ID, entrypointAdded),
|
|
||||||
http.StatusSeeOther,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleEntrypointEdit handles changing an entrypoint's description.
|
|
||||||
// It writes only the description column, so the entrypoint keeps its
|
|
||||||
// URL, and an activate or deactivate saved since the page was shown
|
|
||||||
// is not undone.
|
|
||||||
func (h *Handlers) HandleEntrypointEdit() http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userID, ok := h.getUserID(r)
|
|
||||||
if !ok {
|
|
||||||
http.Redirect(
|
|
||||||
w, r, "/pages/login", http.StatusSeeOther,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceID := chi.URLParam(r, "sourceID")
|
|
||||||
entrypointID := chi.URLParam(r, "entrypointID")
|
|
||||||
|
|
||||||
var webhook database.Webhook
|
|
||||||
|
|
||||||
err := h.db.DB().Where(
|
|
||||||
"id = ? AND user_id = ?", sourceID, userID,
|
|
||||||
).First(&webhook).Error
|
|
||||||
if err != nil {
|
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// The body size cap is enforced by the MaxBodySize
|
|
||||||
// middleware, which runs before CSRF parses the form.
|
|
||||||
err = r.ParseForm()
|
|
||||||
if err != nil {
|
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
result := h.db.DB().Model(&database.Entrypoint{}).Where(
|
|
||||||
"id = ? AND webhook_id = ?", entrypointID, webhook.ID,
|
|
||||||
).Update("description", r.PostFormValue("description"))
|
|
||||||
if result.Error != nil {
|
|
||||||
h.serverError(
|
|
||||||
w, r, "failed to edit entrypoint", result.Error,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// The id came from the URL and may name another webhook's
|
|
||||||
// entrypoint, which this webhook does not have.
|
|
||||||
if result.RowsAffected == 0 {
|
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
http.Redirect(
|
|
||||||
w, r, withNotice("/hook/"+webhook.ID, entrypointSaved),
|
|
||||||
http.StatusSeeOther,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleEntrypointDelete handles deleting an entrypoint.
|
|
||||||
func (h *Handlers) HandleEntrypointDelete() http.HandlerFunc {
|
|
||||||
return h.deleteChildResource(
|
|
||||||
"entrypointID", &database.Entrypoint{},
|
|
||||||
"failed to delete entrypoint",
|
|
||||||
nil,
|
|
||||||
entrypointDeleted,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleEntrypointToggle handles toggling an entrypoint's
|
|
||||||
// active state.
|
|
||||||
func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
|
|
||||||
return h.toggleChildResource(
|
|
||||||
"entrypointID",
|
|
||||||
func(webhookID, childID string) (bool, error) {
|
|
||||||
var ep database.Entrypoint
|
|
||||||
|
|
||||||
err := h.db.DB().Where(
|
|
||||||
"id = ? AND webhook_id = ?",
|
|
||||||
childID, webhookID,
|
|
||||||
).First(&ep).Error
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only the active column: saving the whole row would
|
|
||||||
// write back the description read above over an edit
|
|
||||||
// saved since.
|
|
||||||
active := !ep.Active
|
|
||||||
|
|
||||||
return active, h.db.DB().Model(&ep).
|
|
||||||
Update("active", active).Error
|
|
||||||
},
|
|
||||||
"failed to toggle entrypoint",
|
|
||||||
entrypointActivated, entrypointDeactivated,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestHandleEntrypointToggle_DoesNotUndoAnEdit proves that a toggle
|
|
||||||
// which loaded the entrypoint before an edit of its description was
|
|
||||||
// saved does not write the old description back over the edit. The
|
|
||||||
// edit is submitted from a callback on the toggle's own read of the
|
|
||||||
// entrypoint, so it is saved after that read and before the toggle
|
|
||||||
// writes.
|
|
||||||
func TestHandleEntrypointToggle_DoesNotUndoAnEdit(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
wh := seedWebhookWithRetention(t, env.db, 30)
|
|
||||||
ep := seedEntrypoint(t, env.db, wh.ID)
|
|
||||||
require.True(t, ep.Active)
|
|
||||||
|
|
||||||
router := chi.NewRouter()
|
|
||||||
router.Post(
|
|
||||||
"/hook/{sourceID}/entrypoints/{entrypointID}/edit",
|
|
||||||
env.handlers.HandleEntrypointEdit(),
|
|
||||||
)
|
|
||||||
router.Post(
|
|
||||||
"/hook/{sourceID}/entrypoints/{entrypointID}/toggle",
|
|
||||||
env.handlers.HandleEntrypointToggle(),
|
|
||||||
)
|
|
||||||
|
|
||||||
// post submits one of the entrypoint's forms as the test user and
|
|
||||||
// returns the response's status code.
|
|
||||||
post := func(action string, form url.Values) int {
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodPost,
|
|
||||||
"/hook/"+wh.ID+"/entrypoints/"+ep.ID+"/"+action,
|
|
||||||
strings.NewReader(form.Encode()),
|
|
||||||
)
|
|
||||||
req.Header.Set(
|
|
||||||
"Content-Type", "application/x-www-form-urlencoded",
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, c := range env.cookies {
|
|
||||||
req.AddCookie(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
return w.Code
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
edited bool
|
|
||||||
editCode int
|
|
||||||
)
|
|
||||||
|
|
||||||
require.NoError(t, env.db.DB().Callback().Query().
|
|
||||||
After("gorm:query").
|
|
||||||
Register("test:edit_after_toggle_read", func(tx *gorm.DB) {
|
|
||||||
// Only the first read of an entrypoint, the toggle's,
|
|
||||||
// submits the edit.
|
|
||||||
if tx.Statement.Table != "entrypoints" || edited {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
edited = true
|
|
||||||
editCode = post(
|
|
||||||
"edit", url.Values{"description": {"Billing sender"}},
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusSeeOther, post("toggle", nil))
|
|
||||||
require.Equal(t, http.StatusSeeOther, editCode)
|
|
||||||
|
|
||||||
var stored database.Entrypoint
|
|
||||||
|
|
||||||
require.NoError(
|
|
||||||
t, env.db.DB().First(&stored, "id = ?", ep.ID).Error,
|
|
||||||
)
|
|
||||||
assert.False(t, stored.Active)
|
|
||||||
assert.Equal(t, "Billing sender", stored.Description)
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,6 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/dustin/go-humanize"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,21 +11,6 @@ type EntrypointView struct {
|
|||||||
Path string
|
Path string
|
||||||
Description string
|
Description string
|
||||||
Active bool
|
Active bool
|
||||||
|
|
||||||
// Events is how many events arrived on the entrypoint's URL within
|
|
||||||
// the webhook's retention period. LastEvent is when the newest
|
|
||||||
// event ever to arrive on it did, relative, and LastEventUTC the
|
|
||||||
// full time; both are empty when none ever did.
|
|
||||||
Events int64
|
|
||||||
LastEvent string
|
|
||||||
LastEventUTC string
|
|
||||||
}
|
|
||||||
|
|
||||||
// entrypointEvents is one entrypoint's count read by
|
|
||||||
// addEntrypointEvents.
|
|
||||||
type entrypointEvents struct {
|
|
||||||
EntrypointID string
|
|
||||||
Events int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewEntrypointViews projects entrypoints for rendering.
|
// NewEntrypointViews projects entrypoints for rendering.
|
||||||
@@ -52,60 +32,3 @@ func NewEntrypointViews(
|
|||||||
|
|
||||||
return views
|
return views
|
||||||
}
|
}
|
||||||
|
|
||||||
// addEntrypointEvents fills in each view's event figures from the
|
|
||||||
// webhook's event database: when the last event arrived on its URL,
|
|
||||||
// from its EntrypointTotals row, and how many events arrived on it
|
|
||||||
// since the webhook's retention cutoff, counted in one query over the
|
|
||||||
// events' entrypoint_id index. Resubmitted copies did not arrive on
|
|
||||||
// the URL and are left out of both.
|
|
||||||
func addEntrypointEvents(
|
|
||||||
webhookDB *gorm.DB,
|
|
||||||
webhook *database.Webhook,
|
|
||||||
views []EntrypointView,
|
|
||||||
now time.Time,
|
|
||||||
) error {
|
|
||||||
ids := make([]string, len(views))
|
|
||||||
byID := make(map[string]*EntrypointView, len(views))
|
|
||||||
|
|
||||||
for i := range views {
|
|
||||||
ids[i] = views[i].ID
|
|
||||||
byID[views[i].ID] = &views[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
var totals []database.EntrypointTotals
|
|
||||||
|
|
||||||
err := webhookDB.Where("entrypoint_id IN ?", ids).Find(&totals).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reading entrypoint totals: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
query := webhookDB.Model(&database.Event{}).
|
|
||||||
Select("entrypoint_id, count(*) AS events").
|
|
||||||
Where("entrypoint_id IN ? AND resubmitted_from_id IS NULL", ids)
|
|
||||||
|
|
||||||
cutoff, finite := webhook.RetentionCutoff(now)
|
|
||||||
if finite {
|
|
||||||
query = query.Where("created_at >= ?", cutoff)
|
|
||||||
}
|
|
||||||
|
|
||||||
var counts []entrypointEvents
|
|
||||||
|
|
||||||
err = query.Group("entrypoint_id").Find(&counts).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("counting events by entrypoint: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, row := range totals {
|
|
||||||
view := byID[row.EntrypointID]
|
|
||||||
view.LastEvent = humanize.Time(row.LastEventAt)
|
|
||||||
view.LastEventUTC =
|
|
||||||
row.LastEventAt.UTC().Format(time.DateTime) + " UTC"
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, row := range counts {
|
|
||||||
byID[row.EntrypointID].Events = row.Events
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,197 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
|
||||||
)
|
|
||||||
|
|
||||||
// entrypointRow returns the part of a rendered webhook page from an
|
|
||||||
// entrypoint's URL to the next entrypoint's, which holds its figures.
|
|
||||||
func entrypointRow(t *testing.T, page, entrypointID string) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
_, row, found := strings.Cut(page, `id="entrypoint-url-`+entrypointID+`"`)
|
|
||||||
require.True(t, found)
|
|
||||||
|
|
||||||
row, _, _ = strings.Cut(row, `id="entrypoint-url-`)
|
|
||||||
|
|
||||||
return row
|
|
||||||
}
|
|
||||||
|
|
||||||
// lastEventShown matches an entrypoint row's last event arriving at at.
|
|
||||||
func lastEventShown(at time.Time) string {
|
|
||||||
return `Last Event:</span>\s*<span title="` +
|
|
||||||
at.UTC().Format(time.DateTime) + ` UTC">[^<]+</span>`
|
|
||||||
}
|
|
||||||
|
|
||||||
// eventsShown matches an entrypoint row's count of n events.
|
|
||||||
func eventsShown(n int) string {
|
|
||||||
return `Events Within Retention:</span>\s*<span>` +
|
|
||||||
strconv.Itoa(n) + `</span>`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceDetail_ShowsEntrypointEvents proves each entrypoint
|
|
||||||
// on the webhook page shows its own figures: how many events arrived
|
|
||||||
// through it within the webhook's retention period, leaving out one
|
|
||||||
// older than that, and when the newest arrived, or "never" for an
|
|
||||||
// entrypoint with none.
|
|
||||||
func TestHandleSourceDetail_ShowsEntrypointEvents(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
dbMgr *database.WebhookDBManager
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
wh := &database.Webhook{
|
|
||||||
UserID: deleteTestUserID, Name: "figures", RetentionDays: 7,
|
|
||||||
}
|
|
||||||
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
|
||||||
|
|
||||||
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
entrypoint := func() *database.Entrypoint {
|
|
||||||
ep := &database.Entrypoint{
|
|
||||||
WebhookID: wh.ID, Path: uuid.New().String(), Active: true,
|
|
||||||
}
|
|
||||||
require.NoError(t,
|
|
||||||
db.DB().Omit(clause.Associations).Create(ep).Error)
|
|
||||||
|
|
||||||
return ep
|
|
||||||
}
|
|
||||||
|
|
||||||
// event stores an event that arrived on ep's URL age ago and
|
|
||||||
// records it as ep's last event, as the receiver does.
|
|
||||||
event := func(ep *database.Entrypoint, age time.Duration) time.Time {
|
|
||||||
e := &database.Event{
|
|
||||||
WebhookID: wh.ID,
|
|
||||||
EntrypointID: ep.ID,
|
|
||||||
Method: http.MethodPost,
|
|
||||||
}
|
|
||||||
e.CreatedAt = time.Now().Add(-age)
|
|
||||||
require.NoError(t,
|
|
||||||
webhookDB.Omit(clause.Associations).Create(e).Error)
|
|
||||||
require.NoError(t, database.AddEntrypointTotals(webhookDB,
|
|
||||||
database.EntrypointTotals{
|
|
||||||
EntrypointID: ep.ID, LastEventAt: e.CreatedAt,
|
|
||||||
}))
|
|
||||||
|
|
||||||
return e.CreatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
busy, quiet, unused := entrypoint(), entrypoint(), entrypoint()
|
|
||||||
|
|
||||||
event(busy, 8*24*time.Hour) // older than the 7 days kept
|
|
||||||
event(busy, 3*time.Hour)
|
|
||||||
busyLast := event(busy, time.Hour)
|
|
||||||
quietLast := event(quiet, 2*24*time.Hour)
|
|
||||||
|
|
||||||
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
|
||||||
|
|
||||||
assert.Regexp(t, lastEventShown(busyLast), entrypointRow(t, body, busy.ID))
|
|
||||||
assert.Regexp(t, eventsShown(2), entrypointRow(t, body, busy.ID))
|
|
||||||
assert.Regexp(t, lastEventShown(quietLast), entrypointRow(t, body, quiet.ID))
|
|
||||||
assert.Regexp(t, eventsShown(1), entrypointRow(t, body, quiet.ID))
|
|
||||||
assert.Regexp(t, `Last Event:</span>\s*<span>never</span>`,
|
|
||||||
entrypointRow(t, body, unused.ID))
|
|
||||||
assert.Regexp(t, eventsShown(0), entrypointRow(t, body, unused.ID))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceDetail_EntrypointLastEventSurvivesRetention checks
|
|
||||||
// that once retention has removed every event that arrived on an
|
|
||||||
// entrypoint's URL, the entrypoint still shows when the last one
|
|
||||||
// arrived rather than "never".
|
|
||||||
func TestHandleSourceDetail_EntrypointLastEventSurvivesRetention(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
dbMgr *database.WebhookDBManager
|
|
||||||
log *logger.Logger
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
wh := &database.Webhook{
|
|
||||||
UserID: deleteTestUserID, Name: "swept", RetentionDays: 1,
|
|
||||||
}
|
|
||||||
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
|
||||||
|
|
||||||
ep := seedEntrypoint(t, db, wh.ID)
|
|
||||||
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, ep.Path, 1)
|
|
||||||
arrived := events[0].CreatedAt
|
|
||||||
|
|
||||||
statsAge(t, webhookDB, events[0].ID, time.Now().Add(-50*time.Hour))
|
|
||||||
statsPrune(t, db, dbMgr, log, webhookDB)
|
|
||||||
require.Empty(t, listEvents(t, webhookDB))
|
|
||||||
|
|
||||||
row := entrypointRow(t, renderSourceDetailPage(t, h, sess, wh.ID), ep.ID)
|
|
||||||
assert.Regexp(t, lastEventShown(arrived), row)
|
|
||||||
assert.Regexp(t, eventsShown(0), row)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceDetail_ResubmitLeavesEntrypointFigures checks that a
|
|
||||||
// resubmitted copy, which did not arrive on the entrypoint's URL,
|
|
||||||
// changes neither the entrypoint's last event nor its count.
|
|
||||||
func TestHandleSourceDetail_ResubmitLeavesEntrypointFigures(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
dbMgr *database.WebhookDBManager
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
wh := seedWebhook(t, db)
|
|
||||||
ep := seedEntrypoint(t, db, wh.ID)
|
|
||||||
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, ep.Path, 1)
|
|
||||||
arrived := events[0].CreatedAt
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusSeeOther,
|
|
||||||
postResubmit(t, h, sess, wh.ID, events[0].ID).Code)
|
|
||||||
require.Len(t, listEvents(t, webhookDB), 2)
|
|
||||||
|
|
||||||
var totals database.EntrypointTotals
|
|
||||||
|
|
||||||
require.NoError(t, webhookDB.Take(&totals).Error)
|
|
||||||
assert.True(t, arrived.Equal(totals.LastEventAt))
|
|
||||||
|
|
||||||
row := entrypointRow(t, renderSourceDetailPage(t, h, sess, wh.ID), ep.ID)
|
|
||||||
assert.Regexp(t, lastEventShown(arrived), row)
|
|
||||||
assert.Regexp(t, eventsShown(1), row)
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"html/template"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestErrorPage_RenderFailureKeepsStatus proves that an error page
|
|
||||||
// which cannot render answers with the status it was reporting, as
|
|
||||||
// plain text, and is not attempted again: a page whose own render
|
|
||||||
// fails reaches the error page, and the error page failing as well
|
|
||||||
// ends there with the 500.
|
|
||||||
func TestErrorPage_RenderFailureKeepsStatus(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var h *handlers.Handlers
|
|
||||||
|
|
||||||
app := newTestApp(t, &h)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
// .Status is an int, so asking it for a field fails the render.
|
|
||||||
failing := `{{.Status.Missing}}`
|
|
||||||
h.AddTemplateForTest("error.html", template.Must(
|
|
||||||
template.New("error").Parse(failing),
|
|
||||||
))
|
|
||||||
h.AddTemplateForTest("failing.html", template.Must(
|
|
||||||
template.New("failing").Parse(`{{.Data.Missing}}`),
|
|
||||||
))
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodGet, "/", nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.HandleErrorPage(http.StatusNotFound).ServeHTTP(w, req)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
||||||
assert.Equal(t, "Not Found\n", w.Body.String())
|
|
||||||
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
h.RenderTemplateForTest(w, req, "failing.html", 0)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
||||||
assert.Equal(t, "Internal Server Error\n", w.Body.String())
|
|
||||||
}
|
|
||||||
@@ -15,19 +15,16 @@ import (
|
|||||||
// eventBodyQuery reads one event's stored body as bytes. The cast
|
// eventBodyQuery reads one event's stored body as bytes. The cast
|
||||||
// to blob is what makes the driver hand back the stored bytes
|
// to blob is what makes the driver hand back the stored bytes
|
||||||
// rather than a string conversion, so Content-Length taken from
|
// rather than a string conversion, so Content-Length taken from
|
||||||
// the result matches what goes on the wire. The retention reaper
|
// the result matches what goes on the wire. The soft-delete
|
||||||
// deletes event rows outright, so a reaped event is simply gone
|
// predicate is spelled out because Raw bypasses GORM's default
|
||||||
// and the query finds no row. The deleted_at predicate repeats
|
// scope, and it is what stops a reaped event still being
|
||||||
// the soft-delete scope GORM adds to its own queries, which Raw
|
// downloadable.
|
||||||
// bypasses; nothing soft-deletes an event, so today it excludes
|
|
||||||
// nothing.
|
|
||||||
const eventBodyQuery = "SELECT cast(body as blob) " +
|
const eventBodyQuery = "SELECT cast(body as blob) " +
|
||||||
"FROM events WHERE id = ? AND webhook_id = ? AND deleted_at IS NULL"
|
"FROM events WHERE id = ? AND webhook_id = ? AND deleted_at IS NULL"
|
||||||
|
|
||||||
// HandleEventBodyDownload serves one event's stored body byte
|
// HandleEventBodyDownload serves one event's stored body in
|
||||||
// for byte, which the pages do not: they show it as escaped
|
// full, which the event log page cannot: it caps each rendered
|
||||||
// text, cut at maxRenderedBodyBytes in the lists of events, and
|
// body at maxRenderedBodyBytes.
|
||||||
// leave a binary one out.
|
|
||||||
//
|
//
|
||||||
// The bytes are attacker-supplied — anyone who can reach the
|
// The bytes are attacker-supplied — anyone who can reach the
|
||||||
// public receiver chooses them — and this route hands them back
|
// public receiver chooses them — and this route hands them back
|
||||||
@@ -55,7 +52,7 @@ func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc {
|
|||||||
// steered by a client.
|
// steered by a client.
|
||||||
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -106,21 +103,21 @@ func (h *Handlers) serveEventBody(
|
|||||||
eventID string,
|
eventID string,
|
||||||
) {
|
) {
|
||||||
if !h.dbMgr.DBExists(webhook.ID) {
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to get webhook database", err)
|
h.serverError(w, "failed to get webhook database", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
body, found, err := eventBody(webhookDB, webhook.ID, eventID)
|
body, found, err := eventBody(webhookDB, webhook.ID, eventID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to read event body", err)
|
h.serverError(w, "failed to read event body", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -133,7 +130,7 @@ func (h *Handlers) serveEventBody(
|
|||||||
// row and the whole body is served, or it does not and the
|
// row and the whole body is served, or it does not and the
|
||||||
// response is a clean 404.
|
// response is a clean 404.
|
||||||
if !found {
|
if !found {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -405,11 +405,10 @@ func TestHandleEventBodyDownload_UnknownEvent404s(t *testing.T) {
|
|||||||
// route. The body is read in one query before any header is
|
// route. The body is read in one query before any header is
|
||||||
// written, so a reaped event cannot produce a partial download:
|
// written, so a reaped event cannot produce a partial download:
|
||||||
// it is a clean 404 with no Content-Length and no
|
// it is a clean 404 with no Content-Length and no
|
||||||
// Content-Disposition. The reaper deletes event rows outright,
|
// Content-Disposition. Both removals the codebase performs are
|
||||||
// which is the "hard deleted" case. The "soft deleted" case
|
// covered — the reaper hard-deletes, and a soft-deleted row is
|
||||||
// covers a row no code produces today: it only pins the query's
|
// excluded by the query's own deleted_at predicate rather than
|
||||||
// own deleted_at predicate, the soft-delete condition Raw would
|
// by GORM's default scope, which Raw bypasses.
|
||||||
// otherwise skip.
|
|
||||||
func TestHandleEventBodyDownload_ReapedEvent404s(t *testing.T) {
|
func TestHandleEventBodyDownload_ReapedEvent404s(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -1,168 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"unicode"
|
|
||||||
"unicode/utf8"
|
|
||||||
)
|
|
||||||
|
|
||||||
// maxRenderedBodyBytes is the most of one event's body that the
|
|
||||||
// recent events on a webhook's page and the event log show; a larger
|
|
||||||
// body is cut there and shown whole only on the event's own page.
|
|
||||||
// Bodies come from the unauthenticated receiver under its 1 MB cap,
|
|
||||||
// and renderTemplate buffers a whole page before writing it, so a list
|
|
||||||
// of events cannot show every body whole.
|
|
||||||
const maxRenderedBodyBytes = 32 << 10
|
|
||||||
|
|
||||||
// maxInlineBodyLines is the most lines a body is shown at its full
|
|
||||||
// height with. A body with more lines, or larger than
|
|
||||||
// maxRenderedBodyBytes, is shown in a box of fixed height that
|
|
||||||
// scrolls, so that it does not make the page huge.
|
|
||||||
const maxInlineBodyLines = 200
|
|
||||||
|
|
||||||
// maxIndentDepth is how deeply a JSON body's objects and arrays may
|
|
||||||
// nest for it to be indented at all; a deeper one is shown as received.
|
|
||||||
// Each level indents every line inside it two more spaces, so 10 KB of
|
|
||||||
// nested brackets would indent to some 50 MB; within this depth a body
|
|
||||||
// grows at most 35 times.
|
|
||||||
const maxIndentDepth = 16
|
|
||||||
|
|
||||||
// A JSON body is shown pretty-printed only when that makes it at most
|
|
||||||
// maxIndentGrowth times its size plus indentAllowance bytes, and
|
|
||||||
// otherwise as received, so that indenting does not undo
|
|
||||||
// maxRenderedBodyBytes. The allowance keeps a small nested body
|
|
||||||
// pretty-printed.
|
|
||||||
const (
|
|
||||||
maxIndentGrowth = 4
|
|
||||||
indentAllowance = 1 << 10
|
|
||||||
)
|
|
||||||
|
|
||||||
// jsonIndent is the indent of a pretty-printed JSON body.
|
|
||||||
const jsonIndent = " "
|
|
||||||
|
|
||||||
// BodyView is an event's body as the pages show it. newBodyView
|
|
||||||
// decides it and templates/event_body.html shows it, the same way in
|
|
||||||
// the recent events on a webhook's page, in the event log and on the
|
|
||||||
// event's own page.
|
|
||||||
type BodyView struct {
|
|
||||||
// EventURL is the event's own page. The stored body downloads
|
|
||||||
// from EventURL/body.
|
|
||||||
EventURL string
|
|
||||||
|
|
||||||
// Text is the body as shown, pretty-printed when it is JSON.
|
|
||||||
Text string
|
|
||||||
|
|
||||||
// Size is the stored body's size in bytes, and ShownBytes how
|
|
||||||
// many of them Text holds when Cut.
|
|
||||||
Size int64
|
|
||||||
ShownBytes int
|
|
||||||
|
|
||||||
// Cut reports that Text is only the start of the body.
|
|
||||||
Cut bool
|
|
||||||
|
|
||||||
// Binary reports a body that is not text. It is not shown.
|
|
||||||
Binary bool
|
|
||||||
|
|
||||||
// Scroll reports a body to show in a box that scrolls.
|
|
||||||
Scroll bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// newBodyView decides how to show an event's body. body is the
|
|
||||||
// stored body, or its first maxRenderedBodyBytes when only those were
|
|
||||||
// read, and size is the stored body's size.
|
|
||||||
func newBodyView(eventURL string, body []byte, size int64) BodyView {
|
|
||||||
v := BodyView{EventURL: eventURL, Size: size}
|
|
||||||
|
|
||||||
if size > int64(len(body)) {
|
|
||||||
v.Cut = true
|
|
||||||
body = trimPartialRune(body)
|
|
||||||
v.ShownBytes = len(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// html/template shows invalid UTF-8 as replacement characters,
|
|
||||||
// and a browser shows a control character other than tab, line
|
|
||||||
// feed and carriage return as a box or not at all, so a body
|
|
||||||
// holding either is not text.
|
|
||||||
isControl := func(r rune) bool {
|
|
||||||
return unicode.IsControl(r) && r != '\t' && r != '\n' && r != '\r'
|
|
||||||
}
|
|
||||||
|
|
||||||
if !utf8.Valid(body) || bytes.IndexFunc(body, isControl) >= 0 {
|
|
||||||
v.Binary = true
|
|
||||||
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
// A cut JSON document is no longer valid JSON.
|
|
||||||
if !v.Cut {
|
|
||||||
body = indentJSON(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The page shows a carriage return, a line feed, or the two
|
|
||||||
// together as one line break. A final one ends the last line
|
|
||||||
// rather than starting another.
|
|
||||||
text := bytes.TrimSuffix(body, []byte("\n"))
|
|
||||||
text = bytes.TrimSuffix(text, []byte("\r"))
|
|
||||||
breaks := bytes.Count(text, []byte("\n")) + bytes.Count(text, []byte("\r")) -
|
|
||||||
bytes.Count(text, []byte("\r\n"))
|
|
||||||
lines := breaks + 1
|
|
||||||
|
|
||||||
v.Text = string(body)
|
|
||||||
v.Scroll = lines > maxInlineBodyLines || size > maxRenderedBodyBytes
|
|
||||||
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
// indentJSON returns body pretty-printed when it is a JSON document,
|
|
||||||
// and unchanged when it is not, nests deeper than maxIndentDepth, or
|
|
||||||
// would grow past maxIndentGrowth times its size plus indentAllowance
|
|
||||||
// bytes.
|
|
||||||
func indentJSON(body []byte) []byte {
|
|
||||||
if !json.Valid(body) || !indentFits(body) {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
|
|
||||||
err := json.Indent(&out, body, "", jsonIndent)
|
|
||||||
if err != nil || out.Len() > maxIndentGrowth*len(body)+indentAllowance {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
return out.Bytes()
|
|
||||||
}
|
|
||||||
|
|
||||||
// indentFits reports whether the objects and arrays of the JSON
|
|
||||||
// document body nest at most maxIndentDepth deep.
|
|
||||||
func indentFits(body []byte) bool {
|
|
||||||
depth := 0
|
|
||||||
|
|
||||||
dec := json.NewDecoder(bytes.NewReader(body))
|
|
||||||
|
|
||||||
// A number too large for a float64 is still valid JSON.
|
|
||||||
dec.UseNumber()
|
|
||||||
|
|
||||||
for {
|
|
||||||
tok, err := dec.Token()
|
|
||||||
if errors.Is(err, io.EOF) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
switch tok {
|
|
||||||
case json.Delim('{'), json.Delim('['):
|
|
||||||
depth++
|
|
||||||
if depth > maxIndentDepth {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
case json.Delim('}'), json.Delim(']'):
|
|
||||||
depth--
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user