Compare commits
3
Commits
next
..
8a03b9f866
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a03b9f866 | ||
|
|
0529ea5cd5 | ||
|
|
89294e8c0f |
@@ -15,9 +15,6 @@ 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; a host copy would overwrite it at the
|
|
||||||
# js-lint stage's `COPY . .`.
|
|
||||||
node_modules/
|
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
*.db
|
*.db
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Fingerprint the build context
|
- name: Fingerprint the build context
|
||||||
# Writes the hash of the commit being checked into the context, which
|
# Writes the hash of the commit being checked into the context, which
|
||||||
# invalidates the `COPY . .` layer of every check stage: a commit
|
# invalidates the `COPY . .` layer of both check stages: a commit
|
||||||
# that was never linted, format-checked, stylesheet-checked, tested
|
# that was never linted, format-checked, tested and built cannot
|
||||||
# and built cannot report success from cache.
|
# report success from cache.
|
||||||
run: git rev-parse HEAD > .ci-fingerprint
|
run: git rev-parse HEAD > .ci-fingerprint
|
||||||
|
|
||||||
- name: Build Docker image (runs make fmt-check, golangci-lint, the stylesheet check, ESLint, make test, make build)
|
- name: Build Docker image (runs make fmt-check, golangci-lint, make test, make build)
|
||||||
run: script/cibuild
|
run: script/cibuild
|
||||||
|
|||||||
@@ -15,9 +15,6 @@ bin/
|
|||||||
# Go vendor directory
|
# Go vendor directory
|
||||||
vendor/
|
vendor/
|
||||||
|
|
||||||
# ESLint and its dependencies, installed from yarn.lock
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# IDE specific files
|
# IDE specific files
|
||||||
.idea/
|
.idea/
|
||||||
*.swp
|
*.swp
|
||||||
|
|||||||
+1
-54
@@ -29,67 +29,14 @@ 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 --build-tags browser ./...
|
||||||
|
|
||||||
# Stylesheet stages. static/css/tailwind.css is generated, by this pinned
|
|
||||||
# tailwindcss, from static/css/input.css and the files its @source lines
|
|
||||||
# name. `make css` (script/css) writes it out from the css-output stage.
|
|
||||||
# The css-check stage fails when the committed file differs from what is
|
|
||||||
# generated; `make check` runs it, and so does the build stage below.
|
|
||||||
#
|
|
||||||
# tailwindcss v4.2.1 standalone CLI, released 2026-02-23: one binary per
|
|
||||||
# architecture, each pinned by its sha256 from the release's sha256sums.txt.
|
|
||||||
# debian:bookworm-slim, 2026-10-02: the binary needs glibc.
|
|
||||||
FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 AS tailwind-amd64
|
|
||||||
ADD --checksum=sha256:39e8d4e24b3c83b0a6e69e100a972fbc75d5fef8dce47b3ddac3cf92dea81fe3 --chmod=755 \
|
|
||||||
https://github.com/tailwindlabs/tailwindcss/releases/download/v4.2.1/tailwindcss-linux-x64 /usr/local/bin/tailwindcss
|
|
||||||
|
|
||||||
FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 AS tailwind-arm64
|
|
||||||
ADD --checksum=sha256:d87e6486bb3f70b04ef1dcaacc4ee6548a5a15fbf521b31bc24d2c774f68a951 --chmod=755 \
|
|
||||||
https://github.com/tailwindlabs/tailwindcss/releases/download/v4.2.1/tailwindcss-linux-arm64 /usr/local/bin/tailwindcss
|
|
||||||
|
|
||||||
# TARGETARCH, set by docker, is the architecture being built for.
|
|
||||||
FROM tailwind-${TARGETARCH} AS css
|
|
||||||
WORKDIR /src
|
|
||||||
COPY . .
|
|
||||||
RUN tailwindcss -i static/css/input.css -o /out/tailwind.css --minify
|
|
||||||
|
|
||||||
FROM scratch AS css-output
|
|
||||||
COPY --from=css /out/tailwind.css /
|
|
||||||
|
|
||||||
# Both files are split after each "}", one rule per line, so that when they
|
|
||||||
# differ the diff shows the rules that differ.
|
|
||||||
FROM css AS css-check
|
|
||||||
RUN sed 's/}/}\n/g' static/css/tailwind.css > /tmp/committed.css \
|
|
||||||
&& sed 's/}/}\n/g' /out/tailwind.css > /tmp/generated.css \
|
|
||||||
&& diff -U0 /tmp/committed.css /tmp/generated.css || { \
|
|
||||||
echo "static/css/tailwind.css is not what make css generates; run make css" >&2; \
|
|
||||||
exit 1; \
|
|
||||||
}
|
|
||||||
|
|
||||||
# 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
|
|
||||||
# stays cached until those two files change. script/lint forces only js-lint
|
|
||||||
# to re-run, and the build stage below runs it too. COPY . . brings in the CI
|
|
||||||
# cache barrier described in the lint stage above.
|
|
||||||
# node:24.21.0-alpine (LTS, with yarn 1.22.22), 2026-09-18
|
|
||||||
FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS js-deps
|
|
||||||
WORKDIR /src
|
|
||||||
COPY package.json yarn.lock ./
|
|
||||||
RUN yarn install --frozen-lockfile --ignore-scripts
|
|
||||||
|
|
||||||
FROM js-deps AS js-lint
|
|
||||||
COPY . .
|
|
||||||
RUN --network=none node_modules/.bin/eslint static/js
|
|
||||||
|
|
||||||
# Build stage
|
# Build stage
|
||||||
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
||||||
# Using Debian-based image because gorm.io/driver/sqlite pulls in
|
# Using Debian-based image because gorm.io/driver/sqlite pulls in
|
||||||
# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl.
|
# 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 and JavaScript lint stages passing
|
# Depend on lint stage 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
|
|
||||||
|
|
||||||
# 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. git is what script/version derives the version with.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.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 test-browser lint fmt fmt-check check build run dev deps docker clean hooks css version
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
.DEFAULT_GOAL := check
|
.DEFAULT_GOAL := check
|
||||||
@@ -74,7 +74,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
|
|
||||||
|
|||||||
@@ -19,16 +19,12 @@ before deploying one.
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Go 1.26.1+ (the version in `go.mod`)
|
- Go 1.26.1+ (the version in `go.mod`)
|
||||||
- Docker (for `make lint` and `make css`, and so for `make check`, for the
|
- Docker (for `make lint` and so for `make check`, for the browser test in
|
||||||
browser test in `make test-browser`, for the CI gate, and for
|
`make test-browser`, for the CI gate, and for containerized deployment)
|
||||||
containerized deployment)
|
|
||||||
|
|
||||||
golangci-lint is not a prerequisite and must not be installed on the
|
golangci-lint is not a prerequisite and must not be installed on the
|
||||||
host: `script/bootstrap` does not install it, and `make lint` runs the
|
host: `script/bootstrap` does not install it, and `make lint` runs the
|
||||||
digest-pinned linter image via `Dockerfile.lint`. The same holds for
|
digest-pinned linter image via `Dockerfile.lint`.
|
||||||
tailwindcss (see [Stylesheet](#stylesheet)). ESLint, node and yarn are
|
|
||||||
not prerequisites either, and `make lint` never uses a host copy of them
|
|
||||||
(see [Linting](#linting)).
|
|
||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
|
|
||||||
@@ -40,7 +36,7 @@ cd webhooker
|
|||||||
# Install the Go toolchain if missing, and the Go dependencies
|
# Install the Go toolchain if missing, and the Go dependencies
|
||||||
make bootstrap
|
make bootstrap
|
||||||
|
|
||||||
# Run all checks (test, lint, format check, stylesheet check)
|
# Run all checks (test, lint, format check)
|
||||||
make check
|
make check
|
||||||
|
|
||||||
# Run the server from the clone. DATA_DIR defaults to
|
# Run the server from the clone. DATA_DIR defaults to
|
||||||
@@ -60,10 +56,10 @@ make setup # Bootstrap + install git pre-commit hook
|
|||||||
make assets # Extract Alpine.js from 3p/ (test, check, build, dev run it)
|
make assets # Extract Alpine.js from 3p/ (test, check, build, dev run it)
|
||||||
make fmt # Format code (gofmt + goimports)
|
make fmt # Format code (gofmt + goimports)
|
||||||
make fmt-check # Fail if gofmt would change anything (writes nothing)
|
make fmt-check # Fail if gofmt would change anything (writes nothing)
|
||||||
make lint # Run golangci-lint and ESLint in Docker
|
make lint # Run golangci-lint in Docker (Dockerfile.lint)
|
||||||
make test # Run tests with race detection
|
make test # Run tests with race detection
|
||||||
make test-browser # Run the browser test in Docker (Dockerfile.browser)
|
make test-browser # Run the browser test in Docker (Dockerfile.browser)
|
||||||
make check # test + lint + fmt-check + css-check (CI gate)
|
make check # test + lint + fmt-check (CI gate)
|
||||||
make build # Build binary to bin/webhooker (version-stamped)
|
make build # Build binary to bin/webhooker (version-stamped)
|
||||||
make version # Print the version this checkout would stamp
|
make version # Print the version this checkout would stamp
|
||||||
make run # build, then run ./bin/webhooker
|
make run # build, then run ./bin/webhooker
|
||||||
@@ -71,8 +67,7 @@ make dev # go run ./cmd/webhooker
|
|||||||
make deps # go mod download + go mod tidy
|
make deps # go mod download + go mod tidy
|
||||||
make docker # Build Docker image
|
make docker # Build Docker image
|
||||||
make hooks # Install git pre-commit hook that runs script/precommit
|
make hooks # Install git pre-commit hook that runs script/precommit
|
||||||
make css # Regenerate static/css/tailwind.css (tailwindcss in Docker)
|
make css # Regenerate static/css/tailwind.css (needs tailwindcss)
|
||||||
make css-check # Fail if static/css/tailwind.css is stale (writes nothing)
|
|
||||||
make clean # Remove bin/
|
make clean # Remove bin/
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -84,8 +79,7 @@ directory, read once at startup before anything else looks at the
|
|||||||
environment.
|
environment.
|
||||||
|
|
||||||
The file is optional and having none is the normal case for a
|
The file is optional and having none is the normal case for a
|
||||||
deployment. An empty file is the same as none: it has nothing in it to
|
deployment. A file that is there but cannot be parsed aborts startup
|
||||||
apply. A file that is there but cannot be parsed aborts startup
|
|
||||||
with a message naming it, because a single malformed line makes none
|
with a message naming it, because a single malformed line makes none
|
||||||
of the file apply: every variable in it silently reverts to its
|
of the file apply: every variable in it silently reverts to its
|
||||||
default, which is exactly the failure [Invalid values abort
|
default, which is exactly the failure [Invalid values abort
|
||||||
@@ -313,7 +307,7 @@ itself; a production deployment puts a reverse proxy in front of it
|
|||||||
[Deployment behind a reverse proxy](#deployment-behind-a-reverse-proxy)),
|
[Deployment behind a reverse proxy](#deployment-behind-a-reverse-proxy)),
|
||||||
and the proxy reaches it over loopback. A default that bound every
|
and the proxy reaches it over loopback. A default that bound every
|
||||||
interface would leave that cleartext port answering the internet
|
interface would leave that cleartext port answering the internet
|
||||||
alongside the proxy — the admin sign-in form and the receiver, in the
|
alongside the proxy — the admin login form and the receiver, in the
|
||||||
clear, on a port nobody chose to publish. Reaching webhooker from
|
clear, on a port nobody chose to publish. Reaching webhooker from
|
||||||
another host is therefore something you configure, not something you
|
another host is therefore something you configure, not something you
|
||||||
get by default.
|
get by default.
|
||||||
@@ -570,13 +564,11 @@ its Argon2id hash. There is no second account and no forgot-password
|
|||||||
flow, so the banner and the reset command below are the only two ways
|
flow, so the banner and the reset command below are the only two ways
|
||||||
in.
|
in.
|
||||||
|
|
||||||
A start that finds no `webhooker.db` in `DATA_DIR`, or a zero-length
|
A start that finds no `webhooker.db` in `DATA_DIR` also logs
|
||||||
one (which SQLite opens as an empty database), also logs
|
|
||||||
`created a new, empty database` at `WARN`, with the file's path,
|
`created a new, empty database` at `WARN`, with the file's path,
|
||||||
shortly before the banner. On a deployment that has run before, that
|
shortly before the banner. On a deployment that has run before, that
|
||||||
line means `webhooker.db` was lost: either the file was missing, most
|
line means `DATA_DIR` was empty, most often because its volume is not
|
||||||
often because the volume holding `DATA_DIR` is not mounted, or it was
|
mounted.
|
||||||
zero-length, as a truncated copy leaves it.
|
|
||||||
|
|
||||||
#### Recovering a lost admin password
|
#### Recovering a lost admin password
|
||||||
|
|
||||||
@@ -620,8 +612,7 @@ What it will not do:
|
|||||||
the old password, so a reset underneath it would report a change the
|
the old password, so a reset underneath it would report a change the
|
||||||
service does not honour.
|
service does not honour.
|
||||||
- **Create anything.** A `DATA_DIR` that does not exist, or that holds
|
- **Create anything.** A `DATA_DIR` that does not exist, or that holds
|
||||||
no `webhooker.db` or a zero-length one, is an error naming the path
|
no `webhooker.db`, is an error rather than a new empty deployment —
|
||||||
rather than a new empty deployment —
|
|
||||||
a mistyped path must not be built out and then reported as a success.
|
a mistyped path must not be built out and then reported as a success.
|
||||||
- **Create an account.** A username that does not exist is an error.
|
- **Create an account.** A username that does not exist is an error.
|
||||||
`resetpw` changes an existing account's password and nothing else.
|
`resetpw` changes an existing account's password and nothing else.
|
||||||
@@ -738,8 +729,7 @@ The app runs as a non-root user (`webhooker`, UID 1000), exposes port
|
|||||||
The `/var/lib/webhooker` volume holds all SQLite databases: the main
|
The `/var/lib/webhooker` volume holds all SQLite databases: the main
|
||||||
application database (`webhooker.db`), the per-webhook event databases
|
application database (`webhooker.db`), the per-webhook event databases
|
||||||
(`events-{uuid}.db`), and any archive databases written by `database`
|
(`events-{uuid}.db`), and any archive databases written by `database`
|
||||||
targets (`archive-{webhook_name}-{target_name}-{target_uuid}.db`, with
|
targets (`archive-{webhook_name}-{target_name}-{target_uuid}.db`). Mount
|
||||||
`-{period}` before `.db` for a target that rotates). Mount
|
|
||||||
this as a persistent volume to
|
this as a persistent volume to
|
||||||
preserve data across container restarts.
|
preserve data across container restarts.
|
||||||
|
|
||||||
@@ -835,7 +825,7 @@ reports.
|
|||||||
`-p 127.0.0.1:8080:8080`. Either way the port must reach the proxy
|
`-p 127.0.0.1:8080:8080`. Either way the port must reach the proxy
|
||||||
and nothing else; widen it only with a firewall or a publish
|
and nothing else; widen it only with a firewall or a publish
|
||||||
address in front of it. A cleartext port answering the internet
|
address in front of it. A cleartext port answering the internet
|
||||||
serves the admin sign-in form and the unauthenticated receiver with
|
serves the admin login form and the unauthenticated receiver with
|
||||||
no TLS at all, and the proxy in front of it changes nothing about
|
no TLS at all, and the proxy in front of it changes nothing about
|
||||||
that.
|
that.
|
||||||
2. **Make sure the environment is not `dev` (leave
|
2. **Make sure the environment is not `dev` (leave
|
||||||
@@ -979,11 +969,9 @@ is both the simplest and the only complete rule:
|
|||||||
- `events-{webhook_uuid}.db` — **one per webhook**. Events, deliveries,
|
- `events-{webhook_uuid}.db` — **one per webhook**. Events, deliveries,
|
||||||
delivery results.
|
delivery results.
|
||||||
- `archive-{webhook_name}-{target_name}-{target_uuid}.db` — **one per
|
- `archive-{webhook_name}-{target_name}-{target_uuid}.db` — **one per
|
||||||
`database` target**, or one per month, day or hour for a target that
|
`database` target**. Archived events. The two names are made safe for
|
||||||
rotates, with `-{period}` before `.db`. Archived events. The two names
|
a file name, and the file is renamed when the webhook or the target is
|
||||||
are made safe for a file name, and the files are renamed when the
|
(see [Database Architecture](#database-architecture)).
|
||||||
webhook or the target is (see
|
|
||||||
[Database Architecture](#database-architecture)).
|
|
||||||
|
|
||||||
`{webhook_uuid}` and `{target_uuid}` are UUID primary keys in their
|
`{webhook_uuid}` and `{target_uuid}` are UUID primary keys in their
|
||||||
canonical 36-character hyphenated form, so a real filename looks like
|
canonical 36-character hyphenated form, so a real filename looks like
|
||||||
@@ -1005,16 +993,6 @@ its sidecars; a killed or crashed instance leaves them, and they must be
|
|||||||
carried with the `.db`. An archive the service has not opened since a
|
carried with the `.db`. An archive the service has not opened since a
|
||||||
crash keeps that crash's sidecars, even across a later clean stop.
|
crash keeps that crash's sidecars, even across a later clean stop.
|
||||||
|
|
||||||
A missing sidecar is therefore normal, and SQLite makes new ones, so a
|
|
||||||
`-wal` lost from a copy cannot be reported: the transactions it held
|
|
||||||
are simply gone. SQLite reads a `-wal` up to its first damaged frame,
|
|
||||||
as after a crash, and rebuilds a damaged `-shm`. A sidecar with the
|
|
||||||
wrong mode is set back to `0600` when its database is opened. A
|
|
||||||
directory in place of either is refused then, with an error naming it:
|
|
||||||
for `webhooker.db` the server and `webhooker resetpw` stop, and an event
|
|
||||||
or archive database fails as a damaged one does (see
|
|
||||||
[Database Architecture](#database-architecture)).
|
|
||||||
|
|
||||||
Configuration is **not** in `DATA_DIR` — it comes from the environment
|
Configuration is **not** in `DATA_DIR` — it comes from the environment
|
||||||
and from a `.env` file read out of the process working directory. Back
|
and from a `.env` file read out of the process working directory. Back
|
||||||
that up with your deployment config, separately.
|
that up with your deployment config, separately.
|
||||||
@@ -1098,19 +1076,13 @@ with any `-wal`/`-shm` beside it, or wait until there are none.
|
|||||||
1. Stop the service.
|
1. Stop the service.
|
||||||
|
|
||||||
2. Restore the **whole set together**: `webhooker.db` *and* every
|
2. Restore the **whole set together**: `webhooker.db` *and* every
|
||||||
`events-*.db` *and* every `archive-*.db`. A restore that leaves out
|
`events-*.db` *and* every `archive-*.db`. A partial restore fails
|
||||||
`webhooker.db` or an `events-*.db` is reported, not refused; one
|
quietly rather than loudly. Every database is opened `mode=rwc`, so a
|
||||||
that leaves out an `archive-*.db` or a `-wal` (step 3) is not
|
missing `events-{uuid}.db` is **created empty** on first access
|
||||||
reported at all. Every database is opened `mode=rwc`, so a
|
instead of erroring — the webhook comes back with its configuration
|
||||||
missing `events-{uuid}.db` is **created empty**: the webhook comes
|
intact and its entire event history silently gone. Event databases
|
||||||
back with its configuration intact and its entire event history
|
restored without `webhooker.db` are simply orphaned; nothing
|
||||||
gone. The first start after the restore logs
|
references their UUIDs.
|
||||||
`created a new, empty database` at `WARN` for each such file, with
|
|
||||||
its path, as it does for a missing `webhooker.db`. A missing
|
|
||||||
`archive-*.db` is recreated at its target's next delivery without a
|
|
||||||
warning, since moving one away is a supported workflow. Event
|
|
||||||
databases restored without `webhooker.db` are simply orphaned;
|
|
||||||
nothing references their UUIDs.
|
|
||||||
|
|
||||||
3. Carry any `*.db-wal` and `*.db-shm` files that are in the backup.
|
3. Carry any `*.db-wal` and `*.db-shm` files that are in the backup.
|
||||||
They are part of the database, and dropping a `-wal` silently
|
They are part of the database, and dropping a `-wal` silently
|
||||||
@@ -1135,7 +1107,7 @@ unconditionally against whatever files it finds:
|
|||||||
- the main database on connect — `Setting`, `User`, `APIKey`, `Webhook`,
|
- the main database on connect — `Setting`, `User`, `APIKey`, `Webhook`,
|
||||||
`Entrypoint`, `Target`
|
`Entrypoint`, `Target`
|
||||||
- each event database when it is lazily opened — `Event`, `Delivery`,
|
- each event database when it is lazily opened — `Event`, `Delivery`,
|
||||||
`DeliveryResult`, `EventTotals`, `TargetTotals`, `EntrypointTotals`
|
`DeliveryResult`, `EventTotals`, `TargetTotals`
|
||||||
- each archive database on every open and reopen
|
- each archive database on every open and reopen
|
||||||
|
|
||||||
There is no schema version table, no migration ledger, and no down
|
There is no schema version table, no migration ledger, and no down
|
||||||
@@ -1300,11 +1272,11 @@ What that means for an operator:
|
|||||||
This repository adheres to the
|
This repository adheres to the
|
||||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
standard: normalized scripts in `script/` are the entrypoints for the
|
standard: normalized scripts in `script/` are the entrypoints for the
|
||||||
development workflow. Thirteen of the Makefile's nineteen targets are thin
|
development workflow. Eleven of the Makefile's eighteen targets are thin
|
||||||
shims that call them; `build`, `run`, `dev`, `deps`, `clean` and `version`
|
shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and
|
||||||
are inline commands with no script behind them, though `build`, `run` and
|
`version` are inline commands with no script behind them, though `build`,
|
||||||
`dev` first run `script/assets`, and `build` and `version` both take their
|
`run` and `dev` first run `script/assets`, and `build` and `version` both
|
||||||
value from `script/version`.
|
take their value from `script/version`.
|
||||||
|
|
||||||
`script/test`, `make build` and `make dev` each run `script/assets`
|
`script/test`, `make build` and `make dev` each run `script/assets`
|
||||||
first, which writes the ignored `static/js/alpine.min.js` (see
|
first, which writes the ignored `static/js/alpine.min.js` (see
|
||||||
@@ -1323,15 +1295,10 @@ We provide:
|
|||||||
- `script/test` — run the test suite
|
- `script/test` — run the test suite
|
||||||
- `script/test-browser` — run the browser test in Docker (see
|
- `script/test-browser` — run the browser test in Docker (see
|
||||||
[Third-party browser assets](#third-party-browser-assets))
|
[Third-party browser assets](#third-party-browser-assets))
|
||||||
- `script/lint` — run golangci-lint and ESLint in Docker (see Linting
|
- `script/lint` — run golangci-lint in Docker (see Linting below)
|
||||||
below)
|
|
||||||
- `script/fmt` — format all code (writes)
|
- `script/fmt` — format all code (writes)
|
||||||
- `script/fmt-check` — check formatting (read-only)
|
- `script/fmt-check` — check formatting (read-only)
|
||||||
- `script/css` — regenerate `static/css/tailwind.css` in Docker (writes;
|
- `script/check` — run test, lint, and fmt-check
|
||||||
see [Stylesheet](#stylesheet))
|
|
||||||
- `script/css-check` — fail if `static/css/tailwind.css` differs from what
|
|
||||||
`script/css` would generate (read-only)
|
|
||||||
- `script/check` — run test, lint, fmt-check, and css-check
|
|
||||||
- `script/version` — output the version to stamp into the binary (see
|
- `script/version` — output the version to stamp into the binary (see
|
||||||
[Version stamping](#version-stamping))
|
[Version stamping](#version-stamping))
|
||||||
- `script/docker` — build the Docker image tagged via
|
- `script/docker` — build the Docker image tagged via
|
||||||
@@ -1356,25 +1323,16 @@ markup. The CSP build runs no expressions, so every Alpine directive in
|
|||||||
`static/js/app.js`: `x-data="collapsible"` and `@click="toggle"`, never
|
`static/js/app.js`: `x-data="collapsible"` and `@click="toggle"`, never
|
||||||
`x-data="{ open: false }"` or `@click="open = !open"`.
|
`x-data="{ open: false }"` or `@click="open = !open"`.
|
||||||
|
|
||||||
A browser test in `internal/server` loads the webhook page, its edit pages and
|
A browser test in `internal/server` loads the webhook page and the event log
|
||||||
the event log under the real policy and checks that: the add entrypoint form
|
under the real policy and checks that: the add entrypoint form stays hidden
|
||||||
stays hidden until Add is clicked; for every target type, the targets section's
|
until Add is clicked; for every target type, the targets section's Add shows
|
||||||
Add shows only a choice of type with Next and Cancel, Next shows only that
|
only a choice of type with Next and Cancel, Next shows only that type's fields
|
||||||
type's fields (no url field for `database` or `log`), Cancel at either step
|
(no url field for `database` or `log`), Cancel at either step closes the form,
|
||||||
closes the form, and saving adds the target; a refused target comes back with
|
and saving adds the target; a refused target comes back with its form open, the
|
||||||
its form open, the values entered and the reason, and after Cancel the next Add
|
values entered and the reason, and after Cancel the next Add starts with an
|
||||||
starts with an empty form and no reason; a refused save on the target edit page
|
empty form and no reason; the Copy button beside an entrypoint URL reads
|
||||||
and on the webhook edit page comes back with the reason and every value
|
"Copied" once clicked; an event expands and collapses, and so do a delivery's
|
||||||
entered; the Copy button beside an entrypoint URL reads "Copied" once clicked;
|
attempts inside it; and at phone width the menu button opens and closes the
|
||||||
an entrypoint's Edit button shows its edit form in place of its description and
|
|
||||||
hides until the form closes, Cancel hides the form and drops what was typed, as
|
|
||||||
does leaving the page and going back to it, and Save changes the description;
|
|
||||||
of the recent events on the webhook page only the newest starts expanded, each
|
|
||||||
expands and collapses, and Open leads to the event's own page; of the events in
|
|
||||||
the event log only the newest starts expanded, and an event there expands and
|
|
||||||
collapses when its row's caret or its ID is clicked, and from the keyboard, but
|
|
||||||
not when its ID is selected with the mouse, and a delivery's attempts inside it
|
|
||||||
expand and collapse; and at phone width the menu button opens and closes the
|
|
||||||
mobile menu. It also fails if the browser reports a console warning or error, an
|
mobile menu. It also fails if the browser reports a console warning or error, an
|
||||||
uncaught exception, or anything the policy refused. `make check` and the image
|
uncaught exception, or anything the policy refused. `make check` and the image
|
||||||
build lint it but do not run it, and `make test` leaves it out (its file is
|
build lint it but do not run it, and `make test` leaves it out (its file is
|
||||||
@@ -1405,23 +1363,6 @@ the `dist.integrity` hash listed at
|
|||||||
`3p/` with it as `alpinejs-csp-<version>.tgz`, update its file name in
|
`3p/` with it as `alpinejs-csp-<version>.tgz`, update its file name in
|
||||||
`script/assets`, and run `make check` and `make test-browser`.
|
`script/assets`, and run `make check` and `make test-browser`.
|
||||||
|
|
||||||
## Stylesheet
|
|
||||||
|
|
||||||
`static/css/tailwind.css` is generated by Tailwind and committed. To change the
|
|
||||||
styles, edit the templates, `static/js/app.js`,
|
|
||||||
`internal/handlers/recent_events.go` or `static/css/input.css`, run `make css`,
|
|
||||||
and commit the regenerated file with the change. Tailwind takes classes only
|
|
||||||
from the files that `input.css` names in its `@source` lines; a class written in
|
|
||||||
any other file is not generated until that file is named there too. `make check`
|
|
||||||
and the image build fail when the committed file differs from what `make css`
|
|
||||||
generates. `static/css/style.css` is hand-written and is not generated.
|
|
||||||
|
|
||||||
`make css` runs the Tailwind standalone CLI in Docker, at the version and sha256
|
|
||||||
pinned in the Dockerfile's stylesheet stages; it is never installed on the host.
|
|
||||||
To move to a new version, change the version in both download URLs and both
|
|
||||||
sha256 sums, taken from the release's `sha256sums.txt`, then run `make css` and
|
|
||||||
commit the result.
|
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
Webhook integrations between services are inherently fragile. The
|
Webhook integrations between services are inherently fragile. The
|
||||||
@@ -1565,9 +1506,6 @@ tier** (event ingestion, delivery, and logging).
|
|||||||
│ ┌──────────────┐ (one row per target: running counts │
|
│ ┌──────────────┐ (one row per target: running counts │
|
||||||
│ │ TargetTotals │ of its deliveries) │
|
│ │ TargetTotals │ of its deliveries) │
|
||||||
│ └──────────────┘ │
|
│ └──────────────┘ │
|
||||||
│ ┌──────────────────┐ (one row per entrypoint: when the │
|
|
||||||
│ │ EntrypointTotals │ last event arrived on its URL) │
|
|
||||||
│ └──────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1614,15 +1552,6 @@ more entrypoints (receiver URLs) and one or more targets (delivery
|
|||||||
destinations) into a logical unit. A user creates a webhook to set up
|
destinations) into a logical unit. A user creates a webhook to set up
|
||||||
event routing.
|
event routing.
|
||||||
|
|
||||||
The new webhook form can also give the webhook its first targets: an
|
|
||||||
optional HTTP target URL creates an `http` target named `HTTP`, and the
|
|
||||||
archive checkbox creates a `database` target named `Archive` whose
|
|
||||||
`expiry` is the archive expiry chosen beside it (never, 1h, 12h, 24h,
|
|
||||||
30d, 90d or 365d) and whose `rotation` is the rotation chosen below that
|
|
||||||
(none, monthly, daily or hourly). Both are validated as on the add
|
|
||||||
target form, and the webhook and its targets are created together or
|
|
||||||
not at all.
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ---------------- | ------- | ----------- |
|
| ---------------- | ------- | ----------- |
|
||||||
| `id` | UUID | Primary key |
|
| `id` | UUID | Primary key |
|
||||||
@@ -1690,11 +1619,6 @@ different event sources that all feed into the same processing pipeline
|
|||||||
(e.g., one entrypoint for GitHub, another for Stripe, both routing to
|
(e.g., one entrypoint for GitHub, another for Stripe, both routing to
|
||||||
the same targets).
|
the same targets).
|
||||||
|
|
||||||
The webhook page shows, for each entrypoint, when the last event arrived
|
|
||||||
on its URL, which retention leaves in place, or "never" if none ever has,
|
|
||||||
and how many events arrived on it within the webhook's retention period.
|
|
||||||
A resubmitted event did not arrive on the URL and counts in neither.
|
|
||||||
|
|
||||||
#### Target
|
#### Target
|
||||||
|
|
||||||
A delivery destination for events. Each target defines where and how
|
A delivery destination for events. Each target defines where and how
|
||||||
@@ -1708,7 +1632,8 @@ events should be forwarded.
|
|||||||
| `type` | TargetType | One of: `http`, `slack`, `database`, `log` |
|
| `type` | TargetType | One of: `http`, `slack`, `database`, `log` |
|
||||||
| `active` | boolean | Whether deliveries are enabled (default: true) |
|
| `active` | boolean | Whether deliveries are enabled (default: true) |
|
||||||
| `config` | JSON text | Type-specific configuration |
|
| `config` | JSON text | Type-specific configuration |
|
||||||
| `max_retries` | integer | Total delivery attempts for `http` and `slack` targets, not retries on top of the first: 0 is a single fire-and-forget attempt with no retries and no circuit breaker, and a value of N makes N attempts in all, with exponential backoff and a per-target circuit breaker. Ignored by `database` and `log` targets. The web UI labels it Delivery attempts |
|
| `max_retries` | integer | Total delivery attempts for `http` and `slack` targets, not retries on top of the first: 0 is a single fire-and-forget attempt with no retries and no circuit breaker, and a value of N makes N attempts in all, with exponential backoff and a per-target circuit breaker. Ignored by `database` and `log` targets |
|
||||||
|
| `max_queue_size` | integer | Stored and shown on the target's detail view, but not enforced anywhere yet: nothing in the delivery engine consults it. Queue depth is set by the two fixed 10,000-entry channels |
|
||||||
|
|
||||||
**Relations:** Belongs to Webhook. Has many Deliveries.
|
**Relations:** Belongs to Webhook. Has many Deliveries.
|
||||||
|
|
||||||
@@ -1730,16 +1655,9 @@ events should be forwarded.
|
|||||||
own archive database
|
own archive database
|
||||||
(`archive-{webhook_name}-{target_name}-{target_uuid}.db`) for long-term
|
(`archive-{webhook_name}-{target_name}-{target_uuid}.db`) for long-term
|
||||||
retention, with an optional creation-validated expiry (default: keep
|
retention, with an optional creation-validated expiry (default: keep
|
||||||
forever) and rotation (default: none, one file). The new webhook form,
|
forever). No external delivery and no retries; an archive write
|
||||||
the add target form and the target edit form all offer the same
|
failure fails the delivery. See the database target section under
|
||||||
expiries: never, 1h, 12h, 24h, 30d, 90d or 365d, and the same
|
"Per-Webhook Event Databases" for the full semantics.
|
||||||
rotations: none, monthly, daily or hourly. The target list shows the
|
|
||||||
expiry in plain units, such as "30 days", and the rotation. No external
|
|
||||||
delivery and no retries; an archive write failure fails the delivery.
|
|
||||||
See the database target section under "Per-Webhook Event Databases"
|
|
||||||
for the full semantics. The web UI calls this type an archive: its
|
|
||||||
badge, the add target form's type list and the target edit page say
|
|
||||||
so, and its settings are labelled Archive expiry and Archive rotation.
|
|
||||||
- **`log`** — Write the event to the application log (stdout). Useful
|
- **`log`** — Write the event to the application log (stdout). Useful
|
||||||
for debugging.
|
for debugging.
|
||||||
|
|
||||||
@@ -1843,7 +1761,6 @@ status across potentially multiple attempts.
|
|||||||
| `target_id`| UUID | Foreign key → Target |
|
| `target_id`| UUID | Foreign key → Target |
|
||||||
| `status` | DeliveryStatus | One of: `pending`, `delivered`, `failed`, `retrying` |
|
| `status` | DeliveryStatus | One of: `pending`, `delivered`, `failed`, `retrying` |
|
||||||
| `finished_at` | timestamp | When the delivery became `delivered` or `failed` (nullable; empty while `pending` or `retrying`) |
|
| `finished_at` | timestamp | When the delivery became `delivered` or `failed` (nullable; empty while `pending` or `retrying`) |
|
||||||
| `replay` | boolean | Whether the delivery was created by **Replay** |
|
|
||||||
|
|
||||||
**Relations:** Belongs to Event. Belongs to Target. Has many
|
**Relations:** Belongs to Event. Belongs to Target. Has many
|
||||||
DeliveryResults.
|
DeliveryResults.
|
||||||
@@ -1863,8 +1780,7 @@ NEW `pending` delivery for the same event and target and hands it to
|
|||||||
the engine on the ordinary path — same retries, same SSRF guard, same
|
the engine on the ordinary path — same retries, same SSRF guard, same
|
||||||
circuit breaker as a first attempt. It never touches the delivery it
|
circuit breaker as a first attempt. It never touches the delivery it
|
||||||
repeats: that row's status, timestamps and recorded attempts stand as
|
repeats: that row's status, timestamps and recorded attempts stand as
|
||||||
the record of what happened. The new delivery records `replay`, and the
|
the record of what happened.
|
||||||
event log and the event's page label it a replay.
|
|
||||||
|
|
||||||
What is re-sent is the stored event body, against the target's
|
What is re-sent is the stored event body, against the target's
|
||||||
configuration **as it stands now** — the point of a replay is to
|
configuration **as it stands now** — the point of a replay is to
|
||||||
@@ -1872,11 +1788,7 @@ deliver where the destination has since been fixed. A target that has
|
|||||||
been deleted or deactivated therefore refuses the replay with a
|
been deleted or deactivated therefore refuses the replay with a
|
||||||
message on the event log rather than delivering from stale
|
message on the event log rather than delivering from stale
|
||||||
configuration, and a replay is refused while an earlier one for the
|
configuration, and a replay is refused while an earlier one for the
|
||||||
same event and target is still pending or retrying. A delivery whose
|
same event and target is still pending or retrying.
|
||||||
target has been deleted shows no **Replay** action at all: recreating
|
|
||||||
the target makes a new one that the old delivery does not name, so
|
|
||||||
**Resubmit** is how that event reaches the webhook's currently active
|
|
||||||
targets.
|
|
||||||
|
|
||||||
**Resubmit.** Replay recovers one delivery; **resubmit** re-injects one
|
**Resubmit.** Replay recovers one delivery; **resubmit** re-injects one
|
||||||
EVENT. The event log offers a per-event **Resubmit** action that stores
|
EVENT. The event log offers a per-event **Resubmit** action that stores
|
||||||
@@ -1914,21 +1826,12 @@ retries) is individually logged for full observability.
|
|||||||
| `error` | string | Error message (on failure) |
|
| `error` | string | Error message (on failure) |
|
||||||
| `duration` | integer | Request duration in milliseconds |
|
| `duration` | integer | Request duration in milliseconds |
|
||||||
|
|
||||||
A `database` or `log` target sends no HTTP request, so in the event log and
|
|
||||||
on the event's page its attempts show no status: a successful one reads
|
|
||||||
"archived" or "written to the log".
|
|
||||||
|
|
||||||
The event log and the event's page show when each attempt was recorded and
|
|
||||||
when each delivery was created, as the recent events list shows when an event
|
|
||||||
arrived: how long ago, with the full UTC time on hover.
|
|
||||||
|
|
||||||
**Relations:** Belongs to Delivery.
|
**Relations:** Belongs to Delivery.
|
||||||
|
|
||||||
#### EventTotals, TargetTotals and EntrypointTotals
|
#### EventTotals and TargetTotals
|
||||||
|
|
||||||
Running counts in each event database, read by the statistics pane at the
|
Running counts in each event database, read by the statistics pane at the
|
||||||
top of the webhook page and by the webhook list, and each entrypoint's last
|
top of the webhook page and by the webhook list. `EventTotals` is one row:
|
||||||
event, read by the webhook page's entrypoint list. `EventTotals` is one row:
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ---------------- | --------- | ----------- |
|
| ---------------- | --------- | ----------- |
|
||||||
@@ -1947,26 +1850,18 @@ event, read by the webhook page's entrypoint list. `EventTotals` is one row:
|
|||||||
| `deliveries_removed` | integer | Its deliveries retention has deleted |
|
| `deliveries_removed` | integer | Its deliveries retention has deleted |
|
||||||
| `failed_removed` | integer | Its failed deliveries retention has deleted |
|
| `failed_removed` | integer | Its failed deliveries retention has deleted |
|
||||||
|
|
||||||
`EntrypointTotals` is one row per entrypoint, created by the first event
|
Each count changes in the transaction that writes or deletes the rows it
|
||||||
that arrives on its URL:
|
counts. The pane's lifetime events are `events`, and its lifetime
|
||||||
|
deliveries and failures are `deliveries` and `failed` summed over the
|
||||||
| Field | Type | Description |
|
targets; each figure within retention is the same less what retention
|
||||||
| --------------- | --------- | ----------- |
|
removed, so neither needs the rows themselves. Its last event is
|
||||||
| `entrypoint_id` | UUID | The entrypoint (primary key) |
|
`last_event_at`, written in the transaction that stores the event, so it
|
||||||
| `last_event_at` | timestamp | When the newest event arrived on its URL; a resubmitted event leaves it as it is, and so does retention |
|
still shows once retention has removed every event. Its last-10-minutes and
|
||||||
|
last-24-hours figures are counted from the `events` and `deliveries`
|
||||||
Each count changes in the transaction that writes or deletes the rows it counts,
|
indexes over just that window, the deliveries in one query grouped by
|
||||||
and each `last_event_at` in the transaction that stores the event. The pane's
|
target. Its failure percentage for a window is the deliveries that became
|
||||||
lifetime events are `events`, and its lifetime deliveries and failures are
|
`failed` in it out of all that became `delivered` or `failed` in it, and
|
||||||
`deliveries` and `failed` summed over the targets; each figure within retention
|
a dash when none did.
|
||||||
is the same less what retention removed, so neither needs the rows themselves.
|
|
||||||
Its last event is `last_event_at` in `EventTotals`, so it still shows once
|
|
||||||
retention has removed every event; each entrypoint's last event, from
|
|
||||||
`EntrypointTotals`, does too. Its last-10-minutes and last-24-hours figures are
|
|
||||||
counted from the `events` and `deliveries` indexes over just that window, the
|
|
||||||
deliveries in one query grouped by target. Its failure percentage for a window
|
|
||||||
is the deliveries that became `failed` in it out of all that became `delivered`
|
|
||||||
or `failed` in it, and a dash when none did.
|
|
||||||
|
|
||||||
The webhook list at `/hooks` shows three of the pane's figures for each
|
The webhook list at `/hooks` shows three of the pane's figures for each
|
||||||
webhook: its events within retention and its last event, both from
|
webhook: its events within retention and its last event, both from
|
||||||
@@ -1976,12 +1871,6 @@ counted with the pane's query. It opens each webhook's event database once
|
|||||||
with the number of webhooks and, for each, with the deliveries that
|
with the number of webhooks and, for each, with the deliveries that
|
||||||
finished in the last 24 hours, never with the events stored.
|
finished in the last 24 hours, never with the events stored.
|
||||||
|
|
||||||
The target list on the webhook page shows, for each target, its
|
|
||||||
`delivered` and `failed` totals, which retention does not reduce, and its
|
|
||||||
deliveries that became `delivered` and `failed` in the last 24 hours,
|
|
||||||
counted with the pane's query. Deliveries still `pending` or `retrying`
|
|
||||||
count in neither.
|
|
||||||
|
|
||||||
#### Event-tier indexes
|
#### Event-tier indexes
|
||||||
|
|
||||||
These indexes on the per-webhook event databases are declared in the model
|
These indexes on the per-webhook event databases are declared in the model
|
||||||
@@ -1989,36 +1878,31 @@ tags, so `AutoMigrate` creates them on a fresh database:
|
|||||||
|
|
||||||
| Table | Columns | Serves |
|
| Table | Columns | Serves |
|
||||||
| ------------------ | --------------------------- | ------ |
|
| ------------------ | --------------------------- | ------ |
|
||||||
| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, the webhook page's statistics and target list and the webhook list, which count each target's deliveries by status and when they finished, and the event log, which lists and counts the events with a failed delivery or one pending or retrying |
|
| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, and the webhook page's statistics and the webhook list, which count each target's deliveries by status and when they finished |
|
||||||
| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which counts and deletes the deliveries of expired events |
|
| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which counts and deletes the deliveries of expired events |
|
||||||
| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events |
|
| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events |
|
||||||
| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events |
|
| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events |
|
||||||
| `events` | `resubmitted_from_id`, `deleted_at` | The event log, which counts the events resubmitted from each event on a page |
|
| `events` | `resubmitted_from_id`, `deleted_at` | The event log, which counts the events resubmitted from each event on a page |
|
||||||
| `events` | `entrypoint_id`, `deleted_at`, `resubmitted_from_id`, `created_at` | The webhook page's entrypoint list, which counts the events that arrived on each entrypoint's URL within the retention period |
|
|
||||||
| `events` | `created_at` | Retention, which selects expired events by age |
|
| `events` | `created_at` | Retention, which selects expired events by age |
|
||||||
|
|
||||||
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention leaves
|
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention
|
||||||
it out. SQLite keeps no statistics on these tables, and without them it rates
|
leaves it out. SQLite keeps no statistics on these tables, and without them it
|
||||||
the `deleted_at` index, which every live row matches, above an index on a column
|
rates the `deleted_at` index, which every live row matches, above an index on
|
||||||
matched against several values or compared with a range. So every index but the
|
a column matched against several values or compared with a range. So every
|
||||||
last also covers `deleted_at`. It comes second in the `event_id` and
|
index but the last also covers `deleted_at`. It comes second in the `event_id`
|
||||||
`delivery_id` indexes, so that retention can use them without it. The event
|
and `delivery_id` indexes, so that retention can use them without it. The event
|
||||||
log's count, the one query on the `resubmitted_from_id` index, always carries
|
log's count, the one query on the `resubmitted_from_id` index, always carries
|
||||||
`deleted_at IS NULL` and uses both columns. The entrypoint list's count, the one
|
`deleted_at IS NULL` and uses both columns. In the statistics' `events` index
|
||||||
query on the `entrypoint_id` index, uses all four, `resubmitted_from_id IS NULL`
|
`deleted_at` comes first, because they compare `created_at` with a range (`>=`)
|
||||||
leaving out resubmitted copies and `created_at` last because it compares it with
|
and SQLite narrows by a range only on the last column it uses.
|
||||||
a range (`>=`). In the statistics' `events` index `deleted_at` comes first,
|
|
||||||
because they compare `created_at` with a range (`>=`) and SQLite narrows by a
|
|
||||||
range only on the last column it uses.
|
|
||||||
|
|
||||||
#### Common Fields
|
#### Common Fields
|
||||||
|
|
||||||
Every entity except `Setting`, `EventTotals`, `TargetTotals` and
|
Every entity except `Setting`, `EventTotals` and `TargetTotals` includes
|
||||||
`EntrypointTotals` includes these fields from `BaseModel`. `Setting` is a bare
|
these fields from `BaseModel`. `Setting` is a bare key-value row with no
|
||||||
key-value row with no `id`, no timestamps and no soft delete. Of the three
|
`id`, no timestamps and no soft delete, and the two totals tables hold
|
||||||
totals tables, `event_totals` holds counts and `last_event_at`, keyed by a
|
counts, plus `last_event_at` in `event_totals`, keyed by a numeric `id`
|
||||||
numeric `id`; `target_totals` holds counts, keyed by `target_id`; and
|
and by `target_id`:
|
||||||
`entrypoint_totals` holds `last_event_at`, keyed by `entrypoint_id`:
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ------------ | --------- | ----------- |
|
| ------------ | --------- | ----------- |
|
||||||
@@ -2060,24 +1944,14 @@ encryption key is generated and stored, and an `admin` user is created.
|
|||||||
- **Events** — captured incoming webhook payloads
|
- **Events** — captured incoming webhook payloads
|
||||||
- **Deliveries** — event-to-target pairings and their status
|
- **Deliveries** — event-to-target pairings and their status
|
||||||
- **DeliveryResults** — individual delivery attempt logs
|
- **DeliveryResults** — individual delivery attempt logs
|
||||||
- **EventTotals**, **TargetTotals** and **EntrypointTotals** — running
|
- **EventTotals** and **TargetTotals** — running counts of the above,
|
||||||
counts of the above, the deliveries per target, and each entrypoint's
|
the deliveries per target, kept through retention
|
||||||
last event, kept through retention
|
|
||||||
|
|
||||||
Per-webhook databases are created automatically when a webhook is
|
Per-webhook databases are created automatically when a webhook is
|
||||||
created. They are managed by the `WebhookDBManager` component, which
|
created (and lazily on first access for webhooks that predate this
|
||||||
|
feature). They are managed by the `WebhookDBManager` component, which
|
||||||
handles connection pooling, lazy opening, migrations, and cleanup.
|
handles connection pooling, lazy opening, migrations, and cleanup.
|
||||||
|
|
||||||
A per-webhook database that is missing or zero-length later means its
|
|
||||||
webhook's events and pending deliveries are gone. The next time it is
|
|
||||||
opened, an empty one is created in its place, so the webhook keeps
|
|
||||||
receiving, and `created a new, empty database` is logged at `WARN` with
|
|
||||||
the file's path. Every webhook's database is opened when the service
|
|
||||||
starts, so this appears at the latest at the first start after the
|
|
||||||
file was lost. A file there that SQLite cannot open fails that
|
|
||||||
webhook alone, with an `ERROR` naming the webhook on every access and a
|
|
||||||
500 to its senders, so one damaged file does not stop the others.
|
|
||||||
|
|
||||||
This separation provides:
|
This separation provides:
|
||||||
|
|
||||||
- **Isolation** — a high-volume webhook won't cause lock contention or
|
- **Isolation** — a high-volume webhook won't cause lock contention or
|
||||||
@@ -2116,31 +1990,15 @@ single `-`, no `-` at either end, cut to 40 characters, and `unnamed`
|
|||||||
when nothing is left. The target UUID keeps the file name unique. A
|
when nothing is left. The target UUID keeps the file name unique. A
|
||||||
webhook named `Orders (EU)` with a target named `Long-term archive`
|
webhook named `Orders (EU)` with a target named `Long-term archive`
|
||||||
archives into `archive-orders-eu-long-term-archive-{target_uuid}.db`.
|
archives into `archive-orders-eu-long-term-archive-{target_uuid}.db`.
|
||||||
|
Renaming the webhook or the target renames the file, under the same
|
||||||
An optional `rotation` in the target's config JSON (e.g.
|
lock the archive writes and the archive sweeper take. Webhook edits,
|
||||||
`{"rotation":"daily"}`) is `none`, the default, which keeps the one
|
target edits and target creation run one at a time, so no edit can
|
||||||
file, or `monthly`, `daily` or `hourly`. A target that rotates writes
|
rename the file between another's rename and save, and the name on disk
|
||||||
each event to a file named for the period of the event's receive time,
|
matches the UI. A rename never replaces a file: if one already has
|
||||||
in UTC, put before the `.db`:
|
the new name, the edit is refused with an error naming that file, and
|
||||||
`archive-orders-eu-long-term-archive-{target_uuid}-2026-10.db` monthly,
|
the stored name stays. If the archive is not there (the operator moved
|
||||||
`…-2026-10-01.db` daily and `…-2026-10-01-19.db` hourly. Each file holds
|
it away), the rename is not an error, and the next write creates the
|
||||||
exactly its period's events, and the first event of a new period starts
|
file under the new name.
|
||||||
the next file, so a finished period's file can be moved away like any
|
|
||||||
archive. A changed rotation applies from the next event: the files
|
|
||||||
already written keep their names and stay, pruned, shown and downloaded
|
|
||||||
with the rest, since every file named for the target is its archive,
|
|
||||||
whichever rotation wrote it.
|
|
||||||
|
|
||||||
Renaming the webhook or the target renames every one of the target's
|
|
||||||
files, each keeping its period, under the same lock the archive writes
|
|
||||||
and the archive sweeper take. Webhook edits, target edits and target
|
|
||||||
creation run one at a time, so no edit can rename the files between
|
|
||||||
another's rename and save, and the names on disk match the UI. A rename
|
|
||||||
never replaces a file: if one already has a new name, the edit is
|
|
||||||
refused with an error naming that file, nothing is moved, and the
|
|
||||||
stored name stays. If the archive is not there (the operator moved it
|
|
||||||
away), the rename is not an error, and the next write creates the file
|
|
||||||
under the new name.
|
|
||||||
|
|
||||||
The file is moved just before the new name is saved. If the process
|
The file is moved just before the new name is saved. If the process
|
||||||
stops between the two, the archive is left under the new name while the
|
stops between the two, the archive is left under the new name while the
|
||||||
@@ -2158,9 +2016,7 @@ After each write the archive handle is closed
|
|||||||
and reopened, debounced to at most once per second, so an operator can
|
and reopened, debounced to at most once per second, so an operator can
|
||||||
move the archive file away for offline archiving without stopping the
|
move the archive file away for offline archiving without stopping the
|
||||||
service; a moved or removed archive file is recreated automatically on
|
service; a moved or removed archive file is recreated automatically on
|
||||||
the next write. A zero-length archive file is written to as a new
|
the next write. An optional `expiry` in the target's config JSON (e.g.
|
||||||
archive: SQLite opens it as an empty database, so it holds nothing to
|
|
||||||
lose. An optional `expiry` in the target's config JSON (e.g.
|
|
||||||
`{"expiry":"720h"}`) is validated when the target is created — the
|
`{"expiry":"720h"}`) is validated when the target is created — the
|
||||||
default (unset or the literal `never`) keeps rows forever — and rows
|
default (unset or the literal `never`) keeps rows forever — and rows
|
||||||
older than the expiry are pruned each time the archive is (re)opened. An
|
older than the expiry are pruned each time the archive is (re)opened. An
|
||||||
@@ -2179,29 +2035,18 @@ interleave with a write, and it leaves the archive closed afterwards so
|
|||||||
the move-the-file-away workflow keeps working. Archives with no expiry,
|
the move-the-file-away workflow keeps working. Archives with no expiry,
|
||||||
or the expiry `never`, are not touched by the sweep at all.
|
or the expiry `never`, are not touched by the sweep at all.
|
||||||
|
|
||||||
For a target with several files, the write path prunes only the file it
|
|
||||||
writes to, and the sweep prunes every one of them, taking the target's
|
|
||||||
lock for one file at a time, so a write to the target waits for at most
|
|
||||||
one file's prune. A file that is gone by the time the sweep reaches it
|
|
||||||
is skipped. A file named for a period that the sweep leaves empty is
|
|
||||||
deleted, with any `-wal` and `-shm` beside it; the file without a period
|
|
||||||
is kept even when empty, as it always has been.
|
|
||||||
|
|
||||||
Because each `database` target has its own archive file, a target's
|
Because each `database` target has its own archive file, a target's
|
||||||
`expiry` governs only its own archive. Two `database` targets on one
|
`expiry` governs only its own archive. Two `database` targets on one
|
||||||
webhook with different expiries keep two archives, each pruned on its
|
webhook with different expiries keep two archives, each pruned on its
|
||||||
own schedule.
|
own schedule.
|
||||||
|
|
||||||
The webhook page shows, for each `database` target, the name of the
|
The webhook page shows, for each `database` target, its archive file's
|
||||||
archive file an event received now would go to, the size on disk of all
|
name, its size on disk and when it was last written. The size counts
|
||||||
the target's archive files together, how many there are when there is
|
the `.db` and its `-wal` together, and the last write is the later of
|
||||||
more than one, and when the latest of them was last written. The size
|
their two modification times, since a write lands in the `-wal` first.
|
||||||
counts each `.db` and its `-wal` together, and the last write is the
|
Both are read from the files' metadata; the archive is never opened.
|
||||||
latest of their modification times, since a write lands in the `-wal`
|
Before the first write, and after the file has been moved away, the page
|
||||||
first. All are read from the files' metadata; the archive is never
|
shows `not created yet` beside the name.
|
||||||
opened. While the named file does not exist — before the first write,
|
|
||||||
before the first event of a new period, and after the file has been
|
|
||||||
moved away — the page shows `not created yet` beside the name.
|
|
||||||
|
|
||||||
Each `database` target on the webhook page has a **Download** button,
|
Each `database` target on the webhook page has a **Download** button,
|
||||||
which returns its archive as one gzipped JSON file,
|
which returns its archive as one gzipped JSON file,
|
||||||
@@ -2209,31 +2054,22 @@ which returns its archive as one gzipped JSON file,
|
|||||||
names made safe as above and the time in UTC. The file holds one
|
names made safe as above and the time in UTC. The file holds one
|
||||||
object: `webhook` and `target`, each an `id` and a `name`;
|
object: `webhook` and `target`, each an `id` and a `name`;
|
||||||
`exported_at`; and `archived_events`, one object per archived row with
|
`exported_at`; and `archived_events`, one object per archived row with
|
||||||
every column, keyed by column name. The rows come from every one of the
|
every column, keyed by column name. A body that is not valid UTF-8 is
|
||||||
target's files: the file without a period first, then the others in
|
written in base64, with `"body_encoding": "base64"` beside it. An
|
||||||
the order of their periods, oldest first, and each row from a file named
|
archive that does not exist yet, or was moved away, downloads with an
|
||||||
for a period has that `period` beside its columns. A body that is not
|
empty `archived_events`; the download never creates the file.
|
||||||
valid UTF-8 is written in base64, with `"body_encoding": "base64"`
|
|
||||||
beside it. An archive that does not exist yet, or was moved away,
|
|
||||||
downloads with an empty `archived_events`; the download never creates a
|
|
||||||
file.
|
|
||||||
|
|
||||||
The download streams: each row is read and written out compressed
|
The download streams: each row is read and written out compressed
|
||||||
before the next is read, so neither the archive nor the JSON is held in
|
before the next is read, so neither the archive nor the JSON is held in
|
||||||
memory. When it starts it lists the target's files by the stored names,
|
memory. It reads on a connection of its own, inside one read-only
|
||||||
under the lock that webhook edits, target edits and target creation
|
transaction, so the file holds the archive as it stood when the
|
||||||
hold, and lets go. It then opens one file at a time, only when its rows
|
download started, and archive writes go on meanwhile, since under WAL a
|
||||||
are about to be written out, and closes it before it opens the next, so
|
reader never blocks a writer. While it runs, the `-wal` cannot be
|
||||||
it never has more than one of the target's files open. To open each, it
|
checkpointed past what it reads, so a long download lets the `-wal`
|
||||||
takes the lock again just long enough to find the file by its period
|
grow. It finds the file by the stored names under the lock that webhook
|
||||||
under the names stored then, so a rename during the download loses no
|
edits, target edits and target creation hold, and lets go once the file
|
||||||
file; a file that is gone by then, emptied by the sweep or moved away,
|
is open: a rename during the download moves the file without affecting
|
||||||
is skipped. Each file is read on a connection of its own inside one
|
it.
|
||||||
read-only transaction, so it is written out as it stood when it was
|
|
||||||
opened, and archive writes go on meanwhile, since under WAL a reader
|
|
||||||
never blocks a writer. Until the open file is closed its `-wal` cannot
|
|
||||||
be checkpointed past what the download reads, so a long download lets
|
|
||||||
that `-wal` grow.
|
|
||||||
|
|
||||||
Deleting a webhook releases its archives: the delivery engine's cached
|
Deleting a webhook releases its archives: the delivery engine's cached
|
||||||
archive writers are dropped and their file handles closed, so nothing
|
archive writers are dropped and their file handles closed, so nothing
|
||||||
@@ -2430,20 +2266,6 @@ just delayed until the target is healthy again. A delivery already in
|
|||||||
`retrying` keeps that status without another database write each time
|
`retrying` keeps that status without another database write each time
|
||||||
the breaker turns it away.
|
the breaker turns it away.
|
||||||
|
|
||||||
While a target's breaker is open, the target's row on the webhook page
|
|
||||||
says its deliveries are paused until the cooldown ends, in UTC and as a
|
|
||||||
time from now. Each of its `retrying` deliveries shows as waiting in the
|
|
||||||
event log and on the event's page, with the earliest it can be tried
|
|
||||||
next: the later of the cooldown's end and the end of its own backoff
|
|
||||||
after its last attempt. It is only the earliest: when the cooldown ends,
|
|
||||||
one of the target's waiting deliveries is sent to test it while the
|
|
||||||
others wait at least one more cooldown, as the row also says. A time not
|
|
||||||
on the current UTC day is shown with its date. While the breaker is
|
|
||||||
half-open, the row says instead that deliveries are held while one
|
|
||||||
delivery tests whether the target has recovered, with no time, and the
|
|
||||||
target's deliveries show their plain status, since any of them may be
|
|
||||||
the one being sent.
|
|
||||||
|
|
||||||
### Metrics
|
### Metrics
|
||||||
|
|
||||||
`/metrics` serves one Prometheus registry behind basic auth (see
|
`/metrics` serves one Prometheus registry behind basic auth (see
|
||||||
@@ -2591,10 +2413,8 @@ The query string is never logged; it is replaced by the fixed marker
|
|||||||
`/.well-known/healthcheck` and `/s/*` answer 200 to anyone with no rate
|
`/.well-known/healthcheck` and `/s/*` answer 200 to anyone with no rate
|
||||||
limiter in front of them, so a query on a fixed 200 URL would otherwise
|
limiter in front of them, so a query on a fixed 200 URL would otherwise
|
||||||
buy the same amplification as an invented path. Nothing debuggable is
|
buy the same amplification as an invented path. Nothing debuggable is
|
||||||
lost: the only query parameters this service reads are the sign-in page's
|
lost: `page`, on the authenticated pagination links, is the only query
|
||||||
`next`, the page to return to, `notice`, which names the line a page
|
parameter this service reads.
|
||||||
shows after an action, and the event log's `show`, which picks the events
|
|
||||||
it lists.
|
|
||||||
|
|
||||||
Client-supplied request content does not leave the host by the other
|
Client-supplied request content does not leave the host by the other
|
||||||
route either. The Sentry SDK attaches the request to every event it
|
route either. The Sentry SDK attaches the request to every event it
|
||||||
@@ -2747,9 +2567,9 @@ wider than it:
|
|||||||
| `... rate limit exceeded` (429) | `WARN` | path | yes, on the receiver |
|
| `... rate limit exceeded` (429) | `WARN` | path | yes, on the receiver |
|
||||||
| `auth middleware: unauthenticated request` | `DEBUG` | path, method | yes, by definition |
|
| `auth middleware: unauthenticated request` | `DEBUG` | path, method | yes, by definition |
|
||||||
| `entrypoint not found` | `DEBUG` | entrypoint UUID | yes, on the receiver |
|
| `entrypoint not found` | `DEBUG` | entrypoint UUID | yes, on the receiver |
|
||||||
| `user not found` / `invalid password` | `DEBUG` | username | yes, on the sign-in form |
|
| `user not found` / `invalid password` | `DEBUG` | username | yes, on the login form |
|
||||||
| `login failure limit exceeded` (429) | `WARN` | path | yes, on the sign-in form |
|
| `login failure limit exceeded` (429) | `WARN` | path | yes, on the login form |
|
||||||
| `password verification capacity exhausted` | `WARN` | path | yes, on the sign-in form |
|
| `password verification capacity exhausted` | `WARN` | path | yes, on the login form |
|
||||||
|
|
||||||
`DEBUG` being off by default is not a bound. An operator turning it on
|
`DEBUG` being off by default is not a bound. An operator turning it on
|
||||||
to diagnose a flood must not thereby hand the flood an unbounded write,
|
to diagnose a flood must not thereby hand the flood an unbounded write,
|
||||||
@@ -2797,7 +2617,7 @@ standard output on every statement that returned an error, including a
|
|||||||
plain record-not-found, at a level no operator setting reached. Two of
|
plain record-not-found, at a level no operator setting reached. Two of
|
||||||
this service's lookups miss by design on unauthenticated routes: the
|
this service's lookups miss by design on unauthenticated routes: the
|
||||||
entrypoint lookup behind `/h/{uuid}` and the user lookup behind
|
entrypoint lookup behind `/h/{uuid}` and the user lookup behind
|
||||||
the sign-in form, whose path segment and submitted username the client
|
the login form, whose path segment and submitted username the client
|
||||||
picks outright. Every
|
picks outright. Every
|
||||||
`gorm.Open` in the service now installs the adapter in
|
`gorm.Open` in the service now installs the adapter in
|
||||||
`internal/gormlog` instead. It writes through the same `slog` logger as
|
`internal/gormlog` instead. It writes through the same `slog` logger as
|
||||||
@@ -3085,14 +2905,14 @@ abuse limit later; they are tracked as future work.
|
|||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
| ------ | --------------- | ----------- |
|
| ------ | --------------- | ----------- |
|
||||||
| `GET` | `/pages/login` | Sign-in page (not rate limited). Its `next` parameter names the page to return to after signing in; anything but a path on this site is replaced with `/` |
|
| `GET` | `/pages/login` | Login page (not rate limited). Its `next` parameter names the page to return to after login; anything but a path on this site is replaced with `/` |
|
||||||
| `POST` | `/pages/login` | Sign-in form submission. On success, redirects to the form's `next` when it is a path on this site, otherwise to `/`. Credentials are verified before any limit is consulted, so a correct password is never throttled; 5 FAILED attempts per minute per bucket per submitted username, then `429`. `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one (see [Rate Limiting](#rate-limiting)) |
|
| `POST` | `/pages/login` | Login form submission. On success, redirects to the form's `next` when it is a path on this site, otherwise to `/`. Credentials are verified before any limit is consulted, so a correct password is never throttled; 5 FAILED attempts per minute per bucket per submitted username, then `429`. `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one (see [Rate Limiting](#rate-limiting)) |
|
||||||
| `POST` | `/pages/logout` | Sign out (destroys session) |
|
| `POST` | `/pages/logout` | Logout (destroys session) |
|
||||||
|
|
||||||
#### Authenticated Endpoints
|
#### Authenticated Endpoints
|
||||||
|
|
||||||
A signed-out `GET` of any of these is redirected to `/pages/login` with
|
A logged-out `GET` of any of these is redirected to `/pages/login` with
|
||||||
its path and query as `next` when they fit in 2048 bytes, so signing in
|
its path and query as `next` when they fit in 2048 bytes, so logging in
|
||||||
returns to the page that was asked for.
|
returns to the page that was asked for.
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
@@ -3107,13 +2927,11 @@ returns to the page that was asked for.
|
|||||||
| `GET` | `/hook/{id}/edit` | Edit webhook form |
|
| `GET` | `/hook/{id}/edit` | Edit webhook form |
|
||||||
| `POST` | `/hook/{id}/edit` | Edit webhook submission |
|
| `POST` | `/hook/{id}/edit` | Edit webhook submission |
|
||||||
| `POST` | `/hook/{id}/delete` | Delete webhook |
|
| `POST` | `/hook/{id}/delete` | Delete webhook |
|
||||||
| `GET` | `/hook/{id}/events` | Full Event Log. `?show=failed` lists only the events with a failed delivery, and `?show=pending` only those with a delivery pending or retrying |
|
| `GET` | `/hook/{id}/events` | Full Event Log |
|
||||||
| `GET` | `/hook/{id}/events/{eventID}` | One event's own page: its details, the entrypoint it arrived at (for a resubmitted copy, the one the request it copies arrived at), its request headers, its whole body and every delivery of it |
|
| `GET` | `/hook/{id}/events/{eventID}/body` | Download an event's full stored body. The log page renders each body only up to its cap, so this is the only route that serves a whole one; it is offered wherever a body is shown truncated |
|
||||||
| `GET` | `/hook/{id}/events/{eventID}/body` | Download an event's stored body. The pages show a body as text, cut at 32 KiB in the recent events and the event log, and leave a binary one out, so this is the only route that serves the stored bytes; it is offered wherever a body is cut or binary |
|
|
||||||
| `POST` | `/hook/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) |
|
| `POST` | `/hook/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) |
|
||||||
| `POST` | `/hook/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) |
|
| `POST` | `/hook/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) |
|
||||||
| `POST` | `/hook/{id}/entrypoints` | Add entrypoint to webhook |
|
| `POST` | `/hook/{id}/entrypoints` | Add entrypoint to webhook |
|
||||||
| `POST` | `/hook/{id}/entrypoints/{entrypointID}/edit` | Change an entrypoint's description; its URL stays the same |
|
|
||||||
| `POST` | `/hook/{id}/entrypoints/{entrypointID}/delete` | Delete an entrypoint |
|
| `POST` | `/hook/{id}/entrypoints/{entrypointID}/delete` | Delete an entrypoint |
|
||||||
| `POST` | `/hook/{id}/entrypoints/{entrypointID}/toggle` | Enable or disable an entrypoint |
|
| `POST` | `/hook/{id}/entrypoints/{entrypointID}/toggle` | Enable or disable an entrypoint |
|
||||||
| `POST` | `/hook/{id}/targets` | Add target to webhook |
|
| `POST` | `/hook/{id}/targets` | Add target to webhook |
|
||||||
@@ -3179,7 +2997,7 @@ webhooker/
|
|||||||
│ │ ├── model_event.go # Event entity (per-webhook DB)
|
│ │ ├── model_event.go # Event entity (per-webhook DB)
|
||||||
│ │ ├── model_delivery.go # Delivery entity (per-webhook DB)
|
│ │ ├── model_delivery.go # Delivery entity (per-webhook DB)
|
||||||
│ │ ├── model_delivery_result.go # DeliveryResult entity (per-webhook DB)
|
│ │ ├── model_delivery_result.go # DeliveryResult entity (per-webhook DB)
|
||||||
│ │ ├── model_totals.go # EventTotals, TargetTotals and EntrypointTotals (per-webhook DB)
|
│ │ ├── model_totals.go # EventTotals and TargetTotals (per-webhook DB)
|
||||||
│ │ ├── model_apikey.go # APIKey entity
|
│ │ ├── model_apikey.go # APIKey entity
|
||||||
│ │ ├── password.go # Argon2id hashing and verification
|
│ │ ├── password.go # Argon2id hashing and verification
|
||||||
│ │ ├── retention.go # Retention reaper (per-webhook event expiry)
|
│ │ ├── retention.go # Retention reaper (per-webhook event expiry)
|
||||||
@@ -3201,7 +3019,6 @@ webhooker/
|
|||||||
│ │ ├── target_slack.go # Slack/Mattermost incoming-webhook target
|
│ │ ├── target_slack.go # Slack/Mattermost incoming-webhook target
|
||||||
│ │ ├── target_database.go # Database archive target
|
│ │ ├── target_database.go # Database archive target
|
||||||
│ │ ├── target_database_archive.go # Archive file lifecycle and pruning
|
│ │ ├── target_database_archive.go # Archive file lifecycle and pruning
|
||||||
│ │ ├── target_database_rotation.go # Archive rotation and file names
|
|
||||||
│ │ ├── target_database_export.go # Archive download as gzipped JSON
|
│ │ ├── target_database_export.go # Archive download as gzipped JSON
|
||||||
│ │ ├── target_log.go # Log target (stdout)
|
│ │ ├── target_log.go # Log target (stdout)
|
||||||
│ │ ├── target_config_view.go # Masked target config for templates
|
│ │ ├── target_config_view.go # Masked target config for templates
|
||||||
@@ -3256,14 +3073,12 @@ webhooker/
|
|||||||
│ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed
|
│ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed
|
||||||
├── templates/ # Go HTML templates (base, login, sources, etc.)
|
├── templates/ # Go HTML templates (base, login, sources, etc.)
|
||||||
├── script/ # Scripts to Rule Them All entrypoints
|
├── script/ # Scripts to Rule Them All entrypoints
|
||||||
├── Dockerfile # Stages: lint, stylesheet, JavaScript lint, test+build, Alpine runtime
|
├── Dockerfile # Three stages: lint, test+build, Alpine runtime
|
||||||
├── Dockerfile.lint # Lint-only image built by script/lint
|
├── Dockerfile.lint # Lint-only image built by script/lint
|
||||||
├── Dockerfile.browser # Browser test image built by script/test-browser
|
├── Dockerfile.browser # Browser test image built by script/test-browser
|
||||||
├── Makefile # 13 of 19 targets shim script/; 6 are inline
|
├── Makefile # 11 of 18 targets shim script/; 7 are inline
|
||||||
├── go.mod / go.sum
|
├── go.mod / go.sum
|
||||||
├── package.json / yarn.lock # ESLint, pinned, for the JavaScript lint stage
|
└── .golangci.yml # Linter configuration
|
||||||
├── eslint.config.mjs # ESLint configuration for static/js/
|
|
||||||
└── .golangci.yml # golangci-lint configuration
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Dependency Injection
|
### Dependency Injection
|
||||||
@@ -3441,7 +3256,7 @@ check, see [The login endpoint](#the-login-endpoint).
|
|||||||
still evaluated, so roughly 27 guesses a second get through and the
|
still evaluated, so roughly 27 guesses a second get through and the
|
||||||
admin password has to carry that load (see
|
admin password has to carry that load (see
|
||||||
[The login endpoint](#the-login-endpoint)). `GET` requests to the
|
[The login endpoint](#the-login-endpoint)). `GET` requests to the
|
||||||
sign-in page are not limited
|
login page are not limited
|
||||||
- **Password-change rate limiting** via [go-chi/httprate](https://github.com/go-chi/httprate):
|
- **Password-change rate limiting** via [go-chi/httprate](https://github.com/go-chi/httprate):
|
||||||
sliding-window rate limiter, 5 POST attempts per minute per bucket.
|
sliding-window rate limiter, 5 POST attempts per minute per bucket.
|
||||||
It runs behind session auth, so only a client already holding a
|
It runs behind session auth, so only a client already holding a
|
||||||
@@ -3458,9 +3273,9 @@ check, see [The login endpoint](#the-login-endpoint).
|
|||||||
`ENTRYPOINT` script, which sets the data directory's owner and mode
|
`ENTRYPOINT` script, which sets the data directory's owner and mode
|
||||||
before the app starts; the image's health check; and `docker exec`,
|
before the app starts; the image's health check; and `docker exec`,
|
||||||
unless given `--user`
|
unless given `--user`
|
||||||
- GORM soft deletes on every entity that carries `BaseModel`, which is all of
|
- GORM soft deletes on every entity that carries `BaseModel`, which is
|
||||||
them but `Setting`, `EventTotals`, `TargetTotals` and `EntrypointTotals`
|
all of them but `Setting`, `EventTotals` and `TargetTotals` (data
|
||||||
(data preserved for audit)
|
preserved for audit)
|
||||||
|
|
||||||
### Shutdown
|
### Shutdown
|
||||||
|
|
||||||
@@ -3573,46 +3388,20 @@ Three properties are load-bearing:
|
|||||||
`golangci-lint run` silently ignores config keys it does not
|
`golangci-lint run` silently ignores config keys it does not
|
||||||
recognize, so a typo would disable a setting with no warning.
|
recognize, so a typo would disable a setting with no warning.
|
||||||
|
|
||||||
ESLint never runs on the host either. It lints `static/js/` (not the
|
|
||||||
extracted Alpine.js) in the Dockerfile's `js-lint` stage, which
|
|
||||||
`script/lint` builds after `Dockerfile.lint` and the image build runs
|
|
||||||
before the builder stage. Its version is pinned in `package.json` and
|
|
||||||
every package's hash in `yarn.lock`. The `js-deps` stage before it
|
|
||||||
installs ESLint and stays cached until either file changes, so only the
|
|
||||||
lint step re-runs and ESLint is not downloaded again.
|
|
||||||
`eslint.config.mjs` turns on the rules of the JavaScript styleguide
|
|
||||||
`REPO_POLICIES.md` links to that a linter can check: `no-var` and
|
|
||||||
`prefer-const`. ESLint prints nothing on a pass, so `script/lint` has no
|
|
||||||
summary line to look for; it names the stage once for both `--target`
|
|
||||||
and `--no-cache-filter`, and `--target` fails on a name that matches no
|
|
||||||
stage.
|
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
The Dockerfile uses a multi-stage build. Each stage is pinned by
|
The Dockerfile uses a three-stage build. Each stage is pinned by
|
||||||
digest, and the lint and builder stages are separate images so the
|
digest, and the two check stages are separate images so the linter's
|
||||||
linter's version is fixed independently of the compiler's:
|
version is fixed independently of the compiler's:
|
||||||
|
|
||||||
1. **Lint stage** (`golangci/golangci-lint:v2.12.2`, Debian-based) —
|
1. **Lint stage** (`golangci/golangci-lint:v2.12.2`, Debian-based) —
|
||||||
installs `make`, downloads dependencies, copies the source, and runs
|
installs `make`, downloads dependencies, copies the source, and runs
|
||||||
`make fmt-check`, then `script/assets` to extract Alpine.js from
|
`make fmt-check`, then `script/assets` to extract Alpine.js from
|
||||||
`3p/`, then `golangci-lint config verify` and `golangci-lint run`,
|
`3p/`, then `golangci-lint config verify` and `golangci-lint run`,
|
||||||
both with `--network=none`.
|
both with `--network=none`.
|
||||||
2. **Stylesheet stages** (`debian:bookworm-slim`, with the Tailwind
|
2. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint
|
||||||
standalone CLI pinned by version and sha256, one binary per
|
stage passing (it copies a file from it), runs `make test` and
|
||||||
architecture) — generate `static/css/tailwind.css` from
|
`make build` (both extract Alpine.js from `3p/` first), and finally
|
||||||
`static/css/input.css` and the files its `@source` lines name.
|
|
||||||
`css-check` fails when the committed file differs from the generated
|
|
||||||
one, and `make css` writes the generated file out from `css-output`
|
|
||||||
(see [Stylesheet](#stylesheet)).
|
|
||||||
3. **JavaScript lint stages** (`node:24.21.0-alpine`, with yarn) —
|
|
||||||
`js-deps` installs ESLint from `yarn.lock` and `js-lint` runs it over
|
|
||||||
`static/js/` (see [Linting](#linting)).
|
|
||||||
4. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint,
|
|
||||||
`css-check` and `js-lint` stages passing (it copies a file from
|
|
||||||
each), runs
|
|
||||||
`make test` and `make build` (both extract Alpine.js from `3p/`
|
|
||||||
first), and finally
|
|
||||||
rebuilds the binary with `CGO_ENABLED=1` and static linking so it
|
rebuilds the binary with `CGO_ENABLED=1` and static linking so it
|
||||||
runs on musl. Both builds go through `make build`, the relink adding
|
runs on musl. Both builds go through `make build`, the relink adding
|
||||||
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that
|
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that
|
||||||
@@ -3620,7 +3409,7 @@ linter's version is fixed independently of the compiler's:
|
|||||||
given, otherwise derived from the `.git` in the context, and the
|
given, otherwise derived from the `.git` in the context, and the
|
||||||
stage fails if a context with `.git` would stamp `unknown` (see
|
stage fails if a context with `.git` would stamp `unknown` (see
|
||||||
[Version stamping](#version-stamping)).
|
[Version stamping](#version-stamping)).
|
||||||
5. **Runtime stage** (`alpine:3.21`) — copies the static binary and
|
3. **Runtime stage** (`alpine:3.21`) — copies the static binary and
|
||||||
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker`
|
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker`
|
||||||
directory for all SQLite databases, exposes port 8080, and includes
|
directory for all SQLite databases, exposes port 8080, and includes
|
||||||
a health check against `/.well-known/healthcheck`. It sets no
|
a health check against `/.well-known/healthcheck`. It sets no
|
||||||
@@ -3632,19 +3421,18 @@ The lint stage invokes `golangci-lint` directly rather than `make lint`:
|
|||||||
it is already the pinned linter image, and `make lint` builds
|
it is already the pinned linter image, and `make lint` builds
|
||||||
`Dockerfile.lint`, which would need a docker daemon inside this build.
|
`Dockerfile.lint`, which would need a docker daemon inside this build.
|
||||||
|
|
||||||
The lint and builder stages use Debian rather than Alpine because
|
Both check stages use Debian rather than Alpine because
|
||||||
`gorm.io/driver/sqlite` pulls in `mattn/go-sqlite3`, which needs CGO
|
`gorm.io/driver/sqlite` pulls in `mattn/go-sqlite3`, which needs CGO
|
||||||
and does not compile against musl. Only the final binary is statically
|
and does not compile against musl. Only the final binary is statically
|
||||||
linked, which is what lets it run on the Alpine runtime image.
|
linked, which is what lets it run on the Alpine runtime image.
|
||||||
|
|
||||||
`script/cibuild` — `docker build .` — is the CI gate: the checks run
|
`script/cibuild` — `docker build .` — is the CI gate: the checks run
|
||||||
inside the image, so a build that succeeds is a repo that is formatted,
|
inside the image, so a build that succeeds is a repo that is formatted,
|
||||||
linted, tested and compiled, with a current stylesheet. `script/lint`
|
linted, tested and compiled. `script/lint` also uses Docker
|
||||||
also uses Docker (`Dockerfile.lint` and the `js-lint` stage, see Linting
|
(`Dockerfile.lint`, see Linting above), so `make lint` and `make check`
|
||||||
above), so `make lint` and `make check` run the same pinned linter
|
run the same pinned linter version the gate does; of the steps
|
||||||
versions the gate does; of
|
`make check` runs, only `script/test` and `script/fmt-check` run on the
|
||||||
the steps `make check` runs, only `script/test` and `script/fmt-check`
|
host.
|
||||||
run on the host.
|
|
||||||
|
|
||||||
#### CI gate honesty
|
#### CI gate honesty
|
||||||
|
|
||||||
@@ -3654,10 +3442,9 @@ check meaningless. The `check` workflow therefore writes
|
|||||||
`.ci-fingerprint` into the build context before building. Its value is
|
`.ci-fingerprint` into the build context before building. Its value is
|
||||||
the hash of the commit being checked, so every commit, docs-only ones
|
the hash of the commit being checked, so every commit, docs-only ones
|
||||||
and a squash merge whose tree matches an already-built branch included,
|
and a squash merge whose tree matches an already-built branch included,
|
||||||
gets a new fingerprint, invalidates the `COPY . .` layer of every check
|
gets a new fingerprint, invalidates the `COPY . .` layer of both check
|
||||||
stage, and really runs `make fmt-check`, `golangci-lint`, the stylesheet
|
stages, and really runs `make fmt-check`, `golangci-lint`, `make test`,
|
||||||
check, ESLint, `make test`, and `make build`. A run that reports success
|
and `make build`. A run that reports success ran them.
|
||||||
ran them.
|
|
||||||
|
|
||||||
The module download layer sits above `COPY . .` and stays cached.
|
The module download layer sits above `COPY . .` and stays cached.
|
||||||
|
|
||||||
|
|||||||
@@ -212,10 +212,6 @@ func newApp() *fx.App {
|
|||||||
// or renaming a webhook or target reaches its archive
|
// or renaming a webhook or target reaches its archive
|
||||||
// files.
|
// files.
|
||||||
func(e *delivery.Engine) delivery.Archives { return e },
|
func(e *delivery.Engine) delivery.Archives { 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(
|
||||||
|
|||||||
@@ -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,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
|
||||||
@@ -289,43 +233,6 @@ func TestResubmitCountUsesItsIndex(t *testing.T) {
|
|||||||
"(resubmitted_from_id=? AND deleted_at=?)")
|
"(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.
|
||||||
|
|||||||
@@ -56,10 +56,6 @@ 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
|
|
||||||
// action, so the pages can tell it from the delivery it repeats.
|
|
||||||
Replay bool `gorm:"not null;default:false" json:"replay"`
|
|
||||||
|
|
||||||
// Relations. No model marshals the record it belongs to:
|
// Relations. No model marshals the record it belongs to:
|
||||||
// Event.Deliveries and Target.Deliveries lead back here, and the
|
// Event.Deliveries and Target.Deliveries lead back here, and the
|
||||||
// JSON could loop.
|
// JSON could loop.
|
||||||
|
|||||||
@@ -20,15 +20,12 @@ type Event struct {
|
|||||||
// 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
|
// DeletedAt is also the second column of the resubmitted_from_id
|
||||||
// index, for the reason DeliveryResult gives. The entrypoint_id
|
// index, for the reason DeliveryResult gives.
|
||||||
// index, for the webhook page's entrypoint list, has it second too,
|
CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2" json:"createdAt"`
|
||||||
// resubmitted_from_id third, and created_at last, which the list
|
DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1;index:idx_events_resubmitted_from_id,priority:2" json:"deletedAt,omitzero"`
|
||||||
// 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 +34,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,7 +44,7 @@ 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:idx_events_resubmitted_from_id,priority:1" json:"resubmittedFromId,omitempty"`
|
||||||
|
|
||||||
// Relations. No model marshals the record it belongs to, so
|
// Relations. No model marshals the record it belongs to, so
|
||||||
// Webhook and Entrypoint are left out of the JSON.
|
// Webhook and Entrypoint are left out of the JSON.
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ 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. No model marshals the record it belongs to:
|
||||||
// Webhook.Targets leads back here, and the JSON could loop.
|
// Webhook.Targets leads back here, and the JSON could loop.
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -111,13 +111,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,8 @@ func (r *RetentionReaper) sweep(ctx context.Context) {
|
|||||||
|
|
||||||
wh := webhooks[i]
|
wh := webhooks[i]
|
||||||
|
|
||||||
// A missing database has nothing to reap. Restart recovery
|
// Nothing to reap if the per-webhook database has never
|
||||||
// reports a lost one (see WebhookDBManager.GetDB).
|
// been created.
|
||||||
if !r.dbManager.DBExists(wh.ID) {
|
if !r.dbManager.DBExists(wh.ID) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ var ErrSidecarNotRemoved = errors.New(
|
|||||||
// 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 +98,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 +143,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
|
||||||
}
|
}
|
||||||
@@ -269,54 +266,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 +330,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()
|
||||||
|
|||||||
@@ -289,75 +289,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()
|
||||||
|
|
||||||
|
|||||||
@@ -163,17 +163,6 @@ func (env *archiveEnv) seedArchiveRows(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
path := env.archivePath(tgt)
|
path := env.archivePath(tgt)
|
||||||
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),
|
||||||
@@ -193,7 +182,7 @@ func seedArchiveFile(
|
|||||||
for i, at := range archivedAt {
|
for i, at := range archivedAt {
|
||||||
row := delivery.ExportArchivedEvent{
|
row := delivery.ExportArchivedEvent{
|
||||||
EventID: fmt.Sprintf("ev-%d", i),
|
EventID: fmt.Sprintf("ev-%d", i),
|
||||||
WebhookID: webhookID,
|
WebhookID: tgt.WebhookID,
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
Body: `{"seeded":true}`,
|
Body: `{"seeded":true}`,
|
||||||
ArchivedAt: at,
|
ArchivedAt: at,
|
||||||
@@ -202,6 +191,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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
+14
-68
@@ -143,15 +143,6 @@ type Archives interface {
|
|||||||
Rename(targetID, webhookName, targetName string) error
|
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
|
||||||
// engine.
|
// engine.
|
||||||
type EngineParams struct {
|
type EngineParams struct {
|
||||||
@@ -195,11 +186,9 @@ type Engine struct {
|
|||||||
// 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 eviction, renames and the idle sweep.
|
||||||
@@ -295,13 +284,12 @@ func (e *Engine) EvictTarget(targetID string) {
|
|||||||
e.dbTarget.evict(targetID)
|
e.dbTarget.evict(targetID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename implements Archives. It renames every one of a database
|
// Rename implements Archives. It renames a database target's
|
||||||
// target's archive files to ArchiveFileName(webhookName, targetName,
|
// archive file to ArchiveFileName(webhookName, targetName,
|
||||||
// targetID), each keeping the period in its name, under the lock the
|
// targetID), under the lock the target's archive writes and the
|
||||||
// target's archive writes and the idle sweep take. It never replaces
|
// idle sweep take. It never replaces a file: if one already has the
|
||||||
// a file: if one already has a new name, the error is
|
// new name, the error is ErrArchiveNameTaken. The caller renames
|
||||||
// ErrArchiveNameTaken. The caller renames before it saves the new
|
// before it saves the new name: see databaseTarget.rename.
|
||||||
// name: see databaseTarget.rename.
|
|
||||||
func (e *Engine) Rename(
|
func (e *Engine) Rename(
|
||||||
targetID, webhookName, targetName string,
|
targetID, webhookName, targetName string,
|
||||||
) error {
|
) error {
|
||||||
@@ -312,28 +300,6 @@ func (e *Engine) Rename(
|
|||||||
return e.dbTarget.rename(targetID, webhookName, targetName)
|
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
|
||||||
// retry channel after delay. It implements the Scheduler
|
// retry channel after delay. It implements the Scheduler
|
||||||
// interface the targets use to own their durable retries.
|
// interface the targets use to own their durable retries.
|
||||||
@@ -733,9 +699,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 +710,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 +721,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"
|
||||||
@@ -1137,85 +1136,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) {
|
||||||
|
|||||||
@@ -1018,62 +1018,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()
|
||||||
|
|
||||||
|
|||||||
@@ -212,14 +212,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,
|
||||||
@@ -501,32 +493,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
|
// SetNow replaces the clock the writer measures its reopen
|
||||||
@@ -556,7 +539,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.
|
||||||
|
|||||||
@@ -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 err != nil {
|
|
||||||
return unavailableConfigFields()
|
|
||||||
}
|
|
||||||
|
|
||||||
rotation, err := parseArchiveRotation(configJSON)
|
if configJSON != "" {
|
||||||
if err != nil {
|
var cfg databaseTargetConfig
|
||||||
return unavailableConfigFields()
|
|
||||||
}
|
|
||||||
|
|
||||||
value := archiveExpiryNever
|
err := json.Unmarshal([]byte(configJSON), &cfg)
|
||||||
if expiry > 0 {
|
if err != nil {
|
||||||
value = plainDuration(expiry)
|
return unavailableConfigFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Expiry != "" {
|
||||||
|
if ValidateArchiveExpiry(cfg.Expiry) != nil {
|
||||||
|
return unavailableConfigFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
expiry = cfg.Expiry
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return []ConfigField{{
|
return []ConfigField{{
|
||||||
Label: "Archive expiry",
|
Label: "Archive Expiry",
|
||||||
Value: value,
|
Value: expiry,
|
||||||
}, {
|
|
||||||
Label: "Archive rotation",
|
|
||||||
Value: rotation,
|
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// plainDuration writes a positive duration as a count of the
|
|
||||||
// largest whole unit it divides into: "30 days", "12 hours",
|
|
||||||
// "1 minute". A duration with a fraction of a second is
|
|
||||||
// written as Go writes it.
|
|
||||||
func plainDuration(d time.Duration) string {
|
|
||||||
const day = 24 * time.Hour
|
|
||||||
|
|
||||||
units := []struct {
|
|
||||||
size time.Duration
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{day, "day"},
|
|
||||||
{time.Hour, "hour"},
|
|
||||||
{time.Minute, "minute"},
|
|
||||||
{time.Second, "second"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, unit := range units {
|
|
||||||
if d%unit.size != 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
count := int64(d / unit.size)
|
|
||||||
if count == 1 {
|
|
||||||
return "1 " + unit.name
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("%d %ss", count, unit.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
return d.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MaskedWebhookURL returns the Slack webhook URL reduced to
|
// MaskedWebhookURL returns the Slack webhook URL reduced to
|
||||||
// its scheme and host, with the path, query and any userinfo
|
// its scheme and host, with the path, query and any userinfo
|
||||||
// elided. The path segments are the credential, so none of
|
// elided. The path segments are the credential, so none of
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ const (
|
|||||||
viewMaskedOrigin = viewExampleOrigin + "/..."
|
viewMaskedOrigin = viewExampleOrigin + "/..."
|
||||||
viewUnavailable = "(unavailable)"
|
viewUnavailable = "(unavailable)"
|
||||||
viewExpiryNever = "never"
|
viewExpiryNever = "never"
|
||||||
viewMaxRetries = "Delivery attempts"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMaskedWebhookURL(t *testing.T) {
|
func TestMaskedWebhookURL(t *testing.T) {
|
||||||
@@ -158,7 +157,9 @@ func TestNewTargetViews_DeletedTarget(t *testing.T) {
|
|||||||
t, slackTargetName+" (deleted)", view.DisplayName(),
|
t, slackTargetName+" (deleted)", view.DisplayName(),
|
||||||
)
|
)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, viewFor(t, slackTarget()).Config, view.Config,
|
t,
|
||||||
|
map[string]string{"Webhook URL": slackMaskedURL},
|
||||||
|
fieldMap(view.Config),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,30 +189,7 @@ func TestNewTargetViews_Slack(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
map[string]string{
|
map[string]string{"Webhook URL": slackMaskedURL},
|
||||||
"Webhook URL": slackMaskedURL,
|
|
||||||
viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
|
|
||||||
},
|
|
||||||
fieldMap(view.Config),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewTargetViews_SlackRetries proves a Slack target shows
|
|
||||||
// its retry count the same way an HTTP target does.
|
|
||||||
func TestNewTargetViews_SlackRetries(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
target := slackTarget()
|
|
||||||
target.MaxRetries = 2
|
|
||||||
|
|
||||||
view := viewFor(t, target)
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t,
|
|
||||||
map[string]string{
|
|
||||||
"Webhook URL": slackMaskedURL,
|
|
||||||
viewMaxRetries: "2",
|
|
||||||
},
|
|
||||||
fieldMap(view.Config),
|
fieldMap(view.Config),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -224,7 +202,8 @@ func TestNewTargetViews_HTTP(t *testing.T) {
|
|||||||
Config: `{"url":"` + viewExampleHook + `",` +
|
Config: `{"url":"` + viewExampleHook + `",` +
|
||||||
`"timeout":30,` +
|
`"timeout":30,` +
|
||||||
`"headers":{"Authorization":"Bearer sekrit"}}`,
|
`"headers":{"Authorization":"Bearer sekrit"}}`,
|
||||||
MaxRetries: 5,
|
MaxRetries: 5,
|
||||||
|
MaxQueueSize: 100,
|
||||||
})
|
})
|
||||||
|
|
||||||
fields := fieldMap(view.Config)
|
fields := fieldMap(view.Config)
|
||||||
@@ -235,7 +214,8 @@ func TestNewTargetViews_HTTP(t *testing.T) {
|
|||||||
"Destination URL": viewMaskedOrigin,
|
"Destination URL": viewMaskedOrigin,
|
||||||
"Timeout": "30s",
|
"Timeout": "30s",
|
||||||
"Headers": "1 configured",
|
"Headers": "1 configured",
|
||||||
viewMaxRetries: "5",
|
"Max Retries": "5",
|
||||||
|
"Max Queue Size": "100",
|
||||||
},
|
},
|
||||||
fields,
|
fields,
|
||||||
)
|
)
|
||||||
@@ -258,7 +238,7 @@ func TestNewTargetViews_HTTPFireAndForget(t *testing.T) {
|
|||||||
t,
|
t,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"Destination URL": viewMaskedOrigin,
|
"Destination URL": viewMaskedOrigin,
|
||||||
viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
|
"Max Retries": "0 (fire-and-forget)",
|
||||||
},
|
},
|
||||||
fieldMap(view.Config),
|
fieldMap(view.Config),
|
||||||
)
|
)
|
||||||
@@ -301,20 +281,14 @@ func TestNewTargetViews_Database(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
"empty config": {config: "", want: viewExpiryNever},
|
"empty config": {config: "", want: viewExpiryNever},
|
||||||
"empty expiry": {config: `{}`, want: viewExpiryNever},
|
"empty expiry": {config: `{}`, want: viewExpiryNever},
|
||||||
|
"explicit": {
|
||||||
|
config: `{"expiry":"720h"}`,
|
||||||
|
want: "720h",
|
||||||
|
},
|
||||||
"never literal": {
|
"never literal": {
|
||||||
config: `{"expiry":"` + viewExpiryNever + `"}`,
|
config: `{"expiry":"` + viewExpiryNever + `"}`,
|
||||||
want: viewExpiryNever,
|
want: viewExpiryNever,
|
||||||
},
|
},
|
||||||
"1h": {config: `{"expiry":"1h"}`, want: "1 hour"},
|
|
||||||
"12h": {config: `{"expiry":"12h"}`, want: "12 hours"},
|
|
||||||
"24h": {config: `{"expiry":"24h"}`, want: "1 day"},
|
|
||||||
"720h": {config: `{"expiry":"720h"}`, want: "30 days"},
|
|
||||||
"2160h": {config: `{"expiry":"2160h"}`, want: "90 days"},
|
|
||||||
"8760h": {config: `{"expiry":"8760h"}`, want: "365 days"},
|
|
||||||
"36h": {config: `{"expiry":"36h"}`, want: "36 hours"},
|
|
||||||
"1h30m": {config: `{"expiry":"1h30m"}`, want: "90 minutes"},
|
|
||||||
"45s": {config: `{"expiry":"45s"}`, want: "45 seconds"},
|
|
||||||
"1.5s": {config: `{"expiry":"1.5s"}`, want: "1.5s"},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, tc := range tests {
|
for name, tc := range tests {
|
||||||
@@ -328,49 +302,13 @@ func TestNewTargetViews_Database(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
map[string]string{
|
map[string]string{"Archive Expiry": tc.want},
|
||||||
"Archive expiry": tc.want,
|
|
||||||
"Archive rotation": rotationNone,
|
|
||||||
},
|
|
||||||
fieldMap(view.Config),
|
fieldMap(view.Config),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestNewTargetViews_DatabaseRotation proves the target list shows a
|
|
||||||
// database target's rotation, none when it has none stored.
|
|
||||||
func TestNewTargetViews_DatabaseRotation(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Each stored config, and the rotation the list shows for it.
|
|
||||||
tests := map[string]string{
|
|
||||||
"": rotationNone,
|
|
||||||
`{"rotation":""}`: rotationNone,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, rotation := range []string{
|
|
||||||
rotationNone, rotationMonthly, rotationDaily, rotationHourly,
|
|
||||||
} {
|
|
||||||
tests[`{"rotation":"`+rotation+`"}`] = rotation
|
|
||||||
}
|
|
||||||
|
|
||||||
for config, want := range tests {
|
|
||||||
t.Run(config, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
view := viewFor(t, database.Target{
|
|
||||||
Type: database.TargetTypeDatabase,
|
|
||||||
Config: config,
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t, want, fieldMap(view.Config)["Archive rotation"],
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewTargetViews_Log(t *testing.T) {
|
func TestNewTargetViews_Log(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -418,10 +356,6 @@ func TestNewTargetViews_Unpresentable(t *testing.T) {
|
|||||||
Type: database.TargetTypeDatabase,
|
Type: database.TargetTypeDatabase,
|
||||||
Config: `{"expiry":"a fortnight"}`,
|
Config: `{"expiry":"a fortnight"}`,
|
||||||
},
|
},
|
||||||
"invalid archive rotation": {
|
|
||||||
Type: database.TargetTypeDatabase,
|
|
||||||
Config: weeklyConfig,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, target := range tests {
|
for name, target := range tests {
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ const archiveNameMaxLen = 40
|
|||||||
// from the per-webhook event database. The event is already
|
// from the per-webhook event database. The event is already
|
||||||
// persisted in the per-webhook event DB by the time delivery runs;
|
// persisted in the per-webhook event DB by the time delivery runs;
|
||||||
// the database target additionally writes a durable long-term copy
|
// the database target additionally writes a durable long-term copy
|
||||||
// into the file ArchiveFileName names, with a period added when the
|
// into the file ArchiveFileName names and then records a single
|
||||||
// target rotates (see archivePeriodPath), and then records a single
|
|
||||||
// attempt whose outcome reflects whether the archive write
|
// attempt whose outcome reflects whether the archive write
|
||||||
// succeeded. See archiveWriter for the close/reopen, auto-recreate,
|
// succeeded. See archiveWriter for the close/reopen, auto-recreate,
|
||||||
// and expiry semantics.
|
// and expiry semantics.
|
||||||
@@ -147,9 +146,8 @@ 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 target'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,19 +159,6 @@ func (t *databaseTarget) archive(d *database.Delivery) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
rotation, err := parseArchiveRotation(d.Target.Config)
|
|
||||||
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)
|
w, err := t.writerFor(d.TargetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -189,7 +174,7 @@ 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 archive writer for a database target,
|
||||||
@@ -290,10 +275,10 @@ func (t *databaseTarget) releaseSweepWriter(
|
|||||||
delete(t.writers, targetID)
|
delete(t.writers, targetID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// newWriter builds the writer for a database target's archive. Its
|
// newWriter builds the writer for a database target's archive. The
|
||||||
// path is the one ArchivePath gives for the webhook and the target as
|
// file is the one ArchivePath gives for the webhook and the target as
|
||||||
// the main database names them now; from then on only rename changes
|
// the main database names them now; from then on only rename changes
|
||||||
// the name the writer uses. It does not touch the archive files.
|
// the name the writer uses. It does not touch the archive file.
|
||||||
func (t *databaseTarget) newWriter(
|
func (t *databaseTarget) newWriter(
|
||||||
targetID string,
|
targetID string,
|
||||||
) (*archiveWriter, error) {
|
) (*archiveWriter, error) {
|
||||||
@@ -321,10 +306,10 @@ func (t *databaseTarget) newWriter(
|
|||||||
return w, nil
|
return w, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// rename moves every one of a database target's archive files to the
|
// rename moves a database target's archive file to the name for
|
||||||
// name for webhookName and targetName. It goes through the target's
|
// webhookName and targetName. It goes through the target's writer,
|
||||||
// writer, so the move holds the lock that writes and the idle sweep
|
// so the move holds the lock that writes and the idle sweep take,
|
||||||
// take, and later writes use the new name.
|
// and later writes use the new name.
|
||||||
//
|
//
|
||||||
// The writer is created if there is none, and it stays cached. The
|
// 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
|
// handlers rename before they save the new name, so until the save
|
||||||
|
|||||||
@@ -85,10 +85,6 @@ 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
|
||||||
@@ -182,7 +178,7 @@ func ValidateArchiveExpiry(expiry string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// archiveWriter owns one database target's archive SQLite files.
|
// archiveWriter owns one database target's archive SQLite file.
|
||||||
// It serialises writes, and after each write closes and reopens
|
// It 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
|
||||||
@@ -190,16 +186,8 @@ func ValidateArchiveExpiry(expiry string) error {
|
|||||||
// file is opened create-if-missing and its schema is migrated
|
// file is opened create-if-missing and its schema is migrated
|
||||||
// on every open.
|
// on every open.
|
||||||
type archiveWriter struct {
|
type archiveWriter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
path string
|
||||||
// path is the target's archive file as ArchivePath names it. A
|
|
||||||
// target that rotates writes to the files archivePeriodPath names
|
|
||||||
// for path and a period instead.
|
|
||||||
path string
|
|
||||||
|
|
||||||
// current is the file db is open on.
|
|
||||||
current string
|
|
||||||
|
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
debounce time.Duration
|
debounce time.Duration
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
@@ -248,14 +236,12 @@ func newArchiveWriter(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 +252,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 +264,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 w.now().Sub(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 +314,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,12 +323,11 @@ 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 = w.now()
|
w.lastReopen = w.now()
|
||||||
w.reopens++
|
w.reopens++
|
||||||
|
|
||||||
@@ -355,12 +338,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 +360,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 +385,7 @@ func (w *archiveWriter) sweepFile(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !fileExists(file.path) {
|
if !fileExists(w.path) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,56 +393,26 @@ func (w *archiveWriter) sweepFile(
|
|||||||
// freshly opened file, matching the write path's semantics.
|
// freshly opened file, matching the write path's semantics.
|
||||||
w.close()
|
w.close()
|
||||||
|
|
||||||
err := w.openMode(file.path, archiveModeExisting, expiry)
|
err := w.openMode(archiveModeExisting, expiry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if file.period == "" {
|
|
||||||
w.close()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var rows int64
|
|
||||||
|
|
||||||
err = w.db.Model(&archivedEvent{}).Count(&rows).Error
|
|
||||||
|
|
||||||
w.close()
|
w.close()
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"counting rows in archive %s: %w", file.path, err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if rows > 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, suffix := range []string{"", "-wal", "-shm"} {
|
|
||||||
err = os.Remove(file.path + suffix)
|
|
||||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return fmt.Errorf("deleting empty archive file: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
w.log.Info("deleted empty archive file", "path", file.path)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// rename gives every one of the target's archive files the new
|
// rename gives the archive file a new name in the same directory,
|
||||||
// name, keeping the period in the name of each (see
|
// and the writer uses the file under that name from now on. The
|
||||||
// archivePeriodPath), and the writer uses the files under that name
|
// handle is closed first, which folds the -wal into the .db; any
|
||||||
// from now on. The handle is closed first, which folds the -wal into
|
// -wal or -shm still beside the file (left by a crash) is moved with
|
||||||
// the .db; any -wal or -shm still beside a file (left by a crash) is
|
// it, because SQLite finds them by name. A missing file is not an
|
||||||
// moved with it, because SQLite finds them by name. A target with no
|
// error: the operator may have moved it away, and the next write
|
||||||
// files is not an error: the operator may have moved them away, and
|
// creates it under the new name.
|
||||||
// the next write creates its file under the new name.
|
|
||||||
//
|
//
|
||||||
// If a file already has one of the new names, nothing is moved and
|
// If a file already has the new name, nothing is moved and the
|
||||||
// the error is ErrArchiveNameTaken. If one file fails to move, those
|
// error is ErrArchiveNameTaken. If one file fails to move, those
|
||||||
// already moved are moved back before the error is returned, so the
|
// already moved are moved back before the error is returned, so the
|
||||||
// archive is never split across two names.
|
// archive is never split across two names.
|
||||||
func (w *archiveWriter) rename(name string) error {
|
func (w *archiveWriter) rename(name string) error {
|
||||||
@@ -511,53 +430,38 @@ func (w *archiveWriter) rename(name string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
files, err := archiveFiles(w.path)
|
suffixes := []string{"", "-wal", "-shm"}
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// from[i] moves to to[i].
|
for _, suffix := range suffixes {
|
||||||
var from, to []string
|
if fileExists(path + suffix) {
|
||||||
|
|
||||||
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(
|
return fmt.Errorf(
|
||||||
"%w: %s", ErrArchiveNameTaken, filepath.Base(taken),
|
"%w: %s", ErrArchiveNameTaken, name+suffix,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
w.close()
|
w.close()
|
||||||
|
|
||||||
for i := range from {
|
for i, suffix := range suffixes {
|
||||||
err = os.Rename(from[i], to[i])
|
err := os.Rename(w.path+suffix, path+suffix)
|
||||||
if err == nil || errors.Is(err, fs.ErrNotExist) {
|
if err == nil || errors.Is(err, fs.ErrNotExist) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for j := range i {
|
for _, moved := range suffixes[:i] {
|
||||||
backErr := os.Rename(to[j], from[j])
|
backErr := os.Rename(path+moved, w.path+moved)
|
||||||
if backErr != nil && !errors.Is(backErr, fs.ErrNotExist) {
|
if backErr != nil && !errors.Is(backErr, fs.ErrNotExist) {
|
||||||
w.log.Error(
|
w.log.Error(
|
||||||
"failed to move archive file back",
|
"failed to move archive file back",
|
||||||
"from", to[j],
|
"from", path+moved,
|
||||||
"to", from[j],
|
"to", w.path+moved,
|
||||||
"error", backErr,
|
"error", backErr,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"renaming archive %s to %s: %w", from[i], to[i], err,
|
"renaming archive %s to %s: %w", w.path+suffix, path+suffix, err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,7 +499,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,76 +509,48 @@ 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
|
// ArchiveFileInfo is what the metadata of a database target's archive
|
||||||
// files says about them.
|
// file says about it.
|
||||||
type ArchiveFileInfo struct {
|
type ArchiveFileInfo struct {
|
||||||
// Files counts the files.
|
// Size is the bytes on disk of the file and its -wal together.
|
||||||
Files int
|
|
||||||
|
|
||||||
// Size is the bytes on disk of the files and their -wal together.
|
|
||||||
Size int64
|
Size int64
|
||||||
|
|
||||||
// Written is when a file or a -wal was last modified, whichever is
|
// Written is when the file or its -wal was last modified, whichever
|
||||||
// latest: a write lands in the -wal first.
|
// is later: a write lands in the -wal first.
|
||||||
Written time.Time
|
Written time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// StatArchive reads the metadata of a database target's archive
|
// StatArchive reads the metadata of the archive file at path and of
|
||||||
// files, given the path ArchivePath gives it (see archiveFiles), and
|
// its -wal, without opening the archive. With no file at path, which is
|
||||||
// of their -wal, without opening them. With no files, which is so
|
// so before the first write and after the operator moved it away, the
|
||||||
// before the first write and after the operator moved them away, the
|
|
||||||
// error wraps fs.ErrNotExist.
|
// error wraps fs.ErrNotExist.
|
||||||
func StatArchive(path string) (ArchiveFileInfo, error) {
|
func StatArchive(path string) (ArchiveFileInfo, error) {
|
||||||
files, err := archiveFiles(path)
|
file, err := os.Stat(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ArchiveFileInfo{}, err
|
return ArchiveFileInfo{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var info ArchiveFileInfo
|
info := ArchiveFileInfo{Size: file.Size(), Written: file.ModTime()}
|
||||||
|
|
||||||
for _, file := range files {
|
wal, err := os.Stat(path + "-wal")
|
||||||
db, err := os.Stat(file.path)
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
return info, nil
|
||||||
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 {
|
if err != nil {
|
||||||
return ArchiveFileInfo{}, fmt.Errorf(
|
return ArchiveFileInfo{}, err
|
||||||
"no archive file for %s: %w", path, fs.ErrNotExist,
|
}
|
||||||
)
|
|
||||||
|
info.Size += wal.Size()
|
||||||
|
|
||||||
|
if wal.ModTime().After(info.Written) {
|
||||||
|
info.Written = wal.ModTime()
|
||||||
}
|
}
|
||||||
|
|
||||||
return info, nil
|
return info, nil
|
||||||
|
|||||||
@@ -6,14 +6,10 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
@@ -54,46 +50,21 @@ func ArchiveExportFileName(
|
|||||||
at.UTC().Format("20060102T150405Z") + ".json.gz"
|
at.UTC().Format("20060102T150405Z") + ".json.gz"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArchiveExport is a database target's archive listed for download. It
|
// ArchiveExport is a database target's archive opened for download.
|
||||||
// opens one of the target's files at a time, only when its rows are
|
// It reads the file on its own connection, inside one read-only
|
||||||
// about to be written out, and closes it before it opens the next, so
|
// transaction, so it writes out the archive as it stood when
|
||||||
// an export holds at most one file open however many the target has.
|
// OpenArchiveExport returned.
|
||||||
//
|
//
|
||||||
// Each file is read on its own connection inside one read-only
|
// Archives are in WAL mode, where a reader works from a snapshot and
|
||||||
// transaction, so its rows are written out as the file stood when it
|
// never blocks a writer: archive writes go on while an export is open,
|
||||||
// was opened. Archives are in WAL mode, where a reader works from a
|
// and the export does not see them. SQLite cannot checkpoint the -wal
|
||||||
// snapshot and never blocks a writer: archive writes go on while a file
|
// past an open snapshot, so the -wal grows until the export is closed.
|
||||||
// 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 {
|
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
|
db *sql.DB
|
||||||
tx *gorm.DB
|
tx *gorm.DB
|
||||||
|
|
||||||
// period is the period in the file's name, "" for none.
|
// empty is true when there is nothing to read: no file, or a file
|
||||||
period string
|
// without the archive's table yet.
|
||||||
|
|
||||||
// empty is true for a file without the archive's table yet.
|
|
||||||
empty bool
|
empty bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,46 +74,26 @@ type exportedName struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewArchiveExport lists a database target's archive files for export,
|
// OpenArchiveExport opens the archive file at path for export and
|
||||||
// given the path ArchivePath gives it (see archiveFiles). It opens none
|
// takes the snapshot the export reads. It never creates the file: with
|
||||||
// of them. Its caller holds lock, which every rename of the target's
|
// no file at path, the export has no rows.
|
||||||
// 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
|
// Once it has returned, the file is open, so a rename or a move of it
|
||||||
// period under the path currentPath gives, holding lock while it does
|
// does not affect the export, which reads the same file under its new
|
||||||
// and while it opens the file, so a rename during the export loses no
|
// name.
|
||||||
// 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
|
// The transaction lasts as long as ctx does, so ctx must last for the
|
||||||
// has no rows.
|
// whole export.
|
||||||
func NewArchiveExport(
|
func OpenArchiveExport(
|
||||||
path string,
|
ctx context.Context, path string, log *slog.Logger,
|
||||||
lock sync.Locker,
|
|
||||||
currentPath func() (string, error),
|
|
||||||
log *slog.Logger,
|
|
||||||
) (*ArchiveExport, error) {
|
) (*ArchiveExport, error) {
|
||||||
files, err := archiveFiles(path)
|
if !fileExists(path) {
|
||||||
if err != nil {
|
return &ArchiveExport{empty: true}, nil
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
x := &ArchiveExport{lock: lock, currentPath: currentPath, log: log}
|
db, err := database.OpenSQLite(path, archiveModeExisting)
|
||||||
|
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("opening archive %s: %w", file.path, err)
|
return nil, fmt.Errorf("opening archive %s: %w", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
gdb, err := gorm.Open(
|
gdb, err := gorm.Open(
|
||||||
@@ -155,7 +106,7 @@ func openExportFile(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
_ = db.Close()
|
_ = db.Close()
|
||||||
|
|
||||||
return nil, fmt.Errorf("opening archive %s: %w", file.path, err)
|
return nil, fmt.Errorf("opening archive %s: %w", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadOnly makes the driver begin a deferred transaction in place
|
// ReadOnly makes the driver begin a deferred transaction in place
|
||||||
@@ -165,9 +116,7 @@ func openExportFile(
|
|||||||
if tx.Error != nil {
|
if tx.Error != nil {
|
||||||
_ = db.Close()
|
_ = db.Close()
|
||||||
|
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf("reading archive %s: %w", path, tx.Error)
|
||||||
"reading archive %s: %w", file.path, tx.Error,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The transaction's first read is what takes the snapshot.
|
// The transaction's first read is what takes the snapshot.
|
||||||
@@ -178,27 +127,22 @@ func openExportFile(
|
|||||||
_ = tx.Rollback()
|
_ = tx.Rollback()
|
||||||
_ = db.Close()
|
_ = db.Close()
|
||||||
|
|
||||||
return nil, fmt.Errorf("reading archive %s: %w", file.path, err)
|
return nil, fmt.Errorf("reading archive %s: %w", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &exportFile{
|
return &ArchiveExport{db: db, tx: tx, empty: tables == 0}, nil
|
||||||
db: db, tx: tx, period: file.period, empty: tables == 0,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteGzipJSON writes the export to w as one gzipped JSON object:
|
// WriteGzipJSON writes the export to w as one gzipped JSON object:
|
||||||
// webhook and target, each an id and a name; exported_at; and
|
// webhook and target, each an id and a name; exported_at; and
|
||||||
// archived_events, one object per archived row, keyed by column name,
|
// archived_events, one object per archived row, keyed by column name.
|
||||||
// the files in the order archiveFiles lists them. A row from a file
|
// A body that is not valid UTF-8 cannot be a JSON string, so it is
|
||||||
// named for a period has "period" beside its columns. A body that is
|
// written in base64, with "body_encoding": "base64" beside it.
|
||||||
// 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
|
// 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
|
// archive nor its JSON is ever held in memory whole. After an error
|
||||||
// closed once its rows are written, before the next is opened. When it
|
// the gzip stream is left unfinished, so what was written does not
|
||||||
// returns, no file is open. After an error the gzip stream is left
|
// decompress as a whole file.
|
||||||
// unfinished, so what was written does not decompress as a whole file.
|
|
||||||
func (x *ArchiveExport) WriteGzipJSON(
|
func (x *ArchiveExport) WriteGzipJSON(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
w io.Writer,
|
w io.Writer,
|
||||||
@@ -225,35 +169,15 @@ func (x *ArchiveExport) WriteGzipJSON(
|
|||||||
return zw.Close()
|
return zw.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// openFile finds the target's archive file for period under the path
|
// Close ends the export's transaction and closes its connection.
|
||||||
// currentPath gives now, and opens it for the export, holding x.lock
|
func (x *ArchiveExport) Close() error {
|
||||||
// for both. For a file that is gone, the error wraps fs.ErrNotExist.
|
if x.db == nil {
|
||||||
func (x *ArchiveExport) openFile(
|
return nil
|
||||||
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}
|
_ = x.tx.Rollback()
|
||||||
|
|
||||||
_, err = os.Stat(file.path)
|
return x.db.Close()
|
||||||
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,
|
// writeJSON writes head with archived_events added as its last key,
|
||||||
@@ -283,77 +207,46 @@ func (x *ArchiveExport) writeJSON(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeRows writes the archived rows of each file to w, one per line,
|
// writeRows writes each archived row to w, oldest first, one per line,
|
||||||
// separated by commas, opening each file in turn and closing it once
|
// separated by commas.
|
||||||
// its rows are written.
|
|
||||||
func (x *ArchiveExport) writeRows(ctx context.Context, w io.Writer) error {
|
func (x *ArchiveExport) writeRows(ctx context.Context, w io.Writer) error {
|
||||||
sep := "\n"
|
if x.empty {
|
||||||
|
return nil
|
||||||
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
|
rows, err := x.tx.WithContext(ctx).
|
||||||
}
|
|
||||||
|
|
||||||
// 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()
|
Model(&archivedEvent{}).Order("id").Rows()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
for ; rows.Next(); sep = ",\n" {
|
for sep := "\n"; rows.Next(); sep = ",\n" {
|
||||||
var ev archivedEvent
|
var ev archivedEvent
|
||||||
|
|
||||||
err = f.tx.ScanRows(rows, &ev)
|
err = x.tx.ScanRows(rows, &ev)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = io.WriteString(w, sep)
|
_, err = io.WriteString(w, sep)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = writeRow(w, &ev, f.period)
|
err = writeRow(w, &ev)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return sep, rows.Err()
|
return rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeRow writes an archived row to w as a JSON object keyed by
|
// 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
|
// column name, its body in base64 when it is not valid UTF-8.
|
||||||
// the period of its file beside them unless that is "".
|
func writeRow(w io.Writer, ev *archivedEvent) error {
|
||||||
func writeRow(w io.Writer, ev *archivedEvent, period string) error {
|
|
||||||
row := map[string]any{
|
row := map[string]any{
|
||||||
"id": ev.ID,
|
"id": ev.ID,
|
||||||
"event_id": ev.EventID,
|
"event_id": ev.EventID,
|
||||||
@@ -371,10 +264,6 @@ func writeRow(w io.Writer, ev *archivedEvent, period string) error {
|
|||||||
row["body_encoding"] = "base64"
|
row["body_encoding"] = "base64"
|
||||||
}
|
}
|
||||||
|
|
||||||
if period != "" {
|
|
||||||
row["period"] = period
|
|
||||||
}
|
|
||||||
|
|
||||||
line, err := json.Marshal(row)
|
line, err := json.Marshal(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -31,8 +29,14 @@ const (
|
|||||||
exportTargetName = "Long-term archive"
|
exportTargetName = "Long-term archive"
|
||||||
)
|
)
|
||||||
|
|
||||||
// binaryBody is a body that is not valid UTF-8.
|
const (
|
||||||
const binaryBody = "\xff\xfe\x00\x01binary\x80"
|
// binaryBody is a body that is not valid UTF-8.
|
||||||
|
binaryBody = "\xff\xfe\x00\x01binary\x80"
|
||||||
|
|
||||||
|
// openedEventID is the event the snapshot tests archive before
|
||||||
|
// they open the export.
|
||||||
|
openedEventID = "opened"
|
||||||
|
)
|
||||||
|
|
||||||
// writeExportTo writes export to w as the archive of the export tests'
|
// writeExportTo writes export to w as the archive of the export tests'
|
||||||
// webhook and target, exported at 2026-10-02T12:03:04Z.
|
// webhook and target, exported at 2026-10-02T12:03:04Z.
|
||||||
@@ -55,41 +59,19 @@ func writeExportTo(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// exportArchive runs a whole export of the archive at path and returns
|
||||||
// its JSON, decompressed and parsed.
|
// its JSON, decompressed and parsed.
|
||||||
func exportArchive(t *testing.T, path string) map[string]any {
|
func exportArchive(t *testing.T, path string) map[string]any {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return writeExport(t, listExport(t, path))
|
export, err := delivery.OpenArchiveExport(
|
||||||
|
t.Context(), path, archiveTestLogger(),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, export.Close()) }()
|
||||||
|
|
||||||
|
return writeExport(t, export)
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeExport writes an opened export and returns its JSON,
|
// writeExport writes an opened export and returns its JSON,
|
||||||
@@ -259,202 +241,64 @@ func TestArchiveExport_Empty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// unlockHook is a sync.Locker that runs fn each time it is unlocked. An
|
// TestArchiveExport_ReadsOneSnapshot proves an export writes the
|
||||||
// export unlocks its lock right after it opens a file.
|
// archive as it was when it was opened, and holds up no archive
|
||||||
type unlockHook struct {
|
// write: a row written while the export is open is stored, and is not
|
||||||
sync.Mutex
|
// in the export. A write held up for the whole busy timeout would
|
||||||
|
// fail.
|
||||||
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) {
|
func TestArchiveExport_ReadsOneSnapshot(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "archive.db")
|
path := filepath.Join(t.TempDir(), "archive.db")
|
||||||
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||||
|
|
||||||
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "listed"}, 0))
|
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: openedEventID}, 0))
|
||||||
|
|
||||||
opened := &unlockHook{fn: func() {
|
export, err := delivery.OpenArchiveExport(
|
||||||
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "during"}, 0))
|
t.Context(), path, archiveTestLogger(),
|
||||||
}}
|
)
|
||||||
export := newExport(t, path, opened, func() (string, error) {
|
require.NoError(t, err)
|
||||||
return path, nil
|
|
||||||
})
|
|
||||||
|
|
||||||
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "before-open"}, 0))
|
defer func() { require.NoError(t, export.Close()) }()
|
||||||
|
|
||||||
|
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "during"}, 0))
|
||||||
|
|
||||||
assert.Equal(t,
|
assert.Equal(t,
|
||||||
[]string{"listed", "before-open"},
|
[]string{openedEventID}, exportedEventIDs(t, writeExport(t, export)),
|
||||||
exportedEventIDs(t, writeExport(t, export)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var stored int64
|
var stored int64
|
||||||
|
|
||||||
require.NoError(t, openArchiveDBForRead(t, path).
|
require.NoError(t, openArchiveDBForRead(t, path).
|
||||||
Model(&delivery.ExportArchivedEvent{}).Count(&stored).Error)
|
Model(&delivery.ExportArchivedEvent{}).Count(&stored).Error)
|
||||||
assert.Equal(t, int64(3), stored)
|
assert.Equal(t, int64(2), stored)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestArchiveExport_FindsFilesAfterRename proves that renaming the
|
// TestArchiveExport_SurvivesRename proves that renaming the archive
|
||||||
// archive after an export has listed it, as renaming its webhook or
|
// while an export of it is open, as renaming its webhook or target
|
||||||
// target does, loses no file: the export finds each file again by its
|
// does, leaves the export reading the same file.
|
||||||
// period under the new name. A file moved away by then is skipped.
|
func TestArchiveExport_SurvivesRename(t *testing.T) {
|
||||||
func TestArchiveExport_FindsFilesAfterRename(t *testing.T) {
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
dir := t.TempDir()
|
path := filepath.Join(t.TempDir(), "archive-old.db")
|
||||||
path := filepath.Join(dir, "archive-old.db")
|
|
||||||
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||||
|
|
||||||
for _, period := range []string{"", dayPeriod, hourPeriod} {
|
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: openedEventID}, 0))
|
||||||
require.NoError(t, w.WritePeriod(
|
|
||||||
delivery.ExportArchivedEvent{EventID: "in-" + period}, 0, period,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
current := path
|
export, err := delivery.OpenArchiveExport(
|
||||||
export := newExport(t, path, &sync.Mutex{}, func() (string, error) {
|
t.Context(), path, archiveTestLogger(),
|
||||||
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
path := filepath.Join(dir, "archive-wh.db")
|
defer func() { require.NoError(t, export.Close()) }()
|
||||||
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
|
||||||
random := make([]byte, 36<<10)
|
|
||||||
|
|
||||||
for hour := range 24 {
|
require.NoError(t, w.Rename("archive-new.db"))
|
||||||
_, _ = rand.Read(random)
|
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "after"}, 0))
|
||||||
|
require.NoFileExists(t, path)
|
||||||
|
|
||||||
require.NoError(t, w.WritePeriod(delivery.ExportArchivedEvent{
|
assert.Equal(t,
|
||||||
Body: base64.StdEncoding.EncodeToString(random),
|
[]string{openedEventID}, exportedEventIDs(t, writeExport(t, export)),
|
||||||
}, 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
|
// heapPeak is an io.Writer that discards what it is given and records
|
||||||
@@ -494,7 +338,12 @@ func exportHeapGrowth(t *testing.T, rows, bodySize int) uint64 {
|
|||||||
}, 0))
|
}, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
export := listExport(t, path)
|
export, err := delivery.OpenArchiveExport(
|
||||||
|
t.Context(), path, archiveTestLogger(),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, export.Close()) }()
|
||||||
|
|
||||||
runtime.GC()
|
runtime.GC()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -218,7 +218,6 @@ func TestStatArchive(t *testing.T) {
|
|||||||
|
|
||||||
got, err := delivery.StatArchive(path)
|
got, err := delivery.StatArchive(path)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, 1, got.Files)
|
|
||||||
assert.Equal(t, file.Size()+wal.Size(), got.Size)
|
assert.Equal(t, file.Size()+wal.Size(), got.Size)
|
||||||
assert.True(t, written.Equal(got.Written), got.Written)
|
assert.True(t, written.Equal(got.Written), got.Written)
|
||||||
|
|
||||||
@@ -721,41 +720,3 @@ func TestArchiveWriter_RenameMovesBackOnFailure(t *testing.T) {
|
|||||||
assert.NoFileExists(t, filepath.Join(dir, newName))
|
assert.NoFileExists(t, filepath.Join(dir, newName))
|
||||||
assert.Equal(t, oldPath, w.Path())
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -268,7 +268,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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -20,9 +21,7 @@ const (
|
|||||||
// 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
|
|
||||||
// a page loaded before the delete reaches this.
|
|
||||||
replayTargetDeleted noticeCode = "replay-target-deleted"
|
replayTargetDeleted noticeCode = "replay-target-deleted"
|
||||||
|
|
||||||
// replayTargetMissing reports a target id that names no row at
|
// replayTargetMissing reports a target id that names no row at
|
||||||
@@ -282,7 +281,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 {
|
||||||
@@ -329,25 +327,24 @@ func replayBody(body string) *string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// redirectToEventLog redirects a replay or resubmit back to the event
|
// redirectToEventLog redirects a replay or resubmit back to the event
|
||||||
// log it was triggered from, carrying the outcome as its notice. A
|
// log it was triggered from, carrying the outcome as its notice and
|
||||||
// Replay form carries the list it was pressed in as show, so a replay
|
// the page number the form submitted.
|
||||||
// returns to the Failed or Pending list; a Resubmit form carries none,
|
|
||||||
// so a resubmit returns to the full log, where its new event is the
|
|
||||||
// newest.
|
|
||||||
func redirectToEventLog(
|
func redirectToEventLog(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
code noticeCode,
|
code noticeCode,
|
||||||
) {
|
) {
|
||||||
location := withNotice("/hook/"+webhook.ID+"/events", code)
|
dest := withNotice("/hook/"+webhook.ID+"/events", 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"
|
||||||
@@ -472,66 +470,6 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -575,11 +513,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
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(
|
||||||
@@ -590,58 +524,3 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
|
|||||||
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,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)
|
|
||||||
}
|
|
||||||
@@ -24,10 +24,9 @@ import (
|
|||||||
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
|
||||||
|
|||||||
@@ -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--
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
|
||||||
)
|
|
||||||
|
|
||||||
// bodyView is how the pages would show body, stored whole.
|
|
||||||
func bodyView(body string) handlers.BodyView {
|
|
||||||
return handlers.NewBodyViewForTest([]byte(body), int64(len(body)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// lines is n lines of text, without a newline after the last.
|
|
||||||
func lines(n int) string {
|
|
||||||
return strings.TrimSuffix(strings.Repeat("line\n", n), "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewBodyView_FormatsValidJSON proves a JSON body is shown
|
|
||||||
// pretty-printed, whatever its content type, with its keys in
|
|
||||||
// the order they arrived.
|
|
||||||
func TestNewBodyView_FormatsValidJSON(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
v := bodyView(`{"b":1,"a":[true,null,"x"],"c":{}}`)
|
|
||||||
|
|
||||||
assert.Equal(t, []string{
|
|
||||||
`{`,
|
|
||||||
` "b": 1,`,
|
|
||||||
` "a": [`,
|
|
||||||
` true,`,
|
|
||||||
` null,`,
|
|
||||||
` "x"`,
|
|
||||||
` ],`,
|
|
||||||
` "c": {}`,
|
|
||||||
`}`,
|
|
||||||
}, strings.Split(v.Text, "\n"))
|
|
||||||
assert.False(t, v.Scroll)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewBodyView_FormatsNestedJSON proves a small document with a
|
|
||||||
// few levels of nesting is pretty-printed.
|
|
||||||
func TestNewBodyView_FormatsNestedJSON(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
v := bodyView(`{"data":[[1,2,3],[4,5,6]]}`)
|
|
||||||
|
|
||||||
assert.Equal(t, []string{
|
|
||||||
`{`,
|
|
||||||
` "data": [`,
|
|
||||||
` [`,
|
|
||||||
` 1,`,
|
|
||||||
` 2,`,
|
|
||||||
` 3`,
|
|
||||||
` ],`,
|
|
||||||
` [`,
|
|
||||||
` 4,`,
|
|
||||||
` 5,`,
|
|
||||||
` 6`,
|
|
||||||
` ]`,
|
|
||||||
` ]`,
|
|
||||||
`}`,
|
|
||||||
}, strings.Split(v.Text, "\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewBodyView_InvalidJSONAsReceived proves a body that is not
|
|
||||||
// a JSON document is shown exactly as it arrived.
|
|
||||||
func TestNewBodyView_InvalidJSONAsReceived(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, body := range []string{
|
|
||||||
`{"a":1,`,
|
|
||||||
`{"a":1} {"b":2}`,
|
|
||||||
"plain text\n indented",
|
|
||||||
} {
|
|
||||||
assert.Equal(t, body, bodyView(body).Text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewBodyView_DeepJSONAsReceived proves a JSON body nested
|
|
||||||
// more than 16 levels deep is shown as it arrived. 10 KB of nested
|
|
||||||
// arrays would indent to some 50 MB.
|
|
||||||
func TestNewBodyView_DeepJSONAsReceived(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
nested := func(depth int) string {
|
|
||||||
return strings.Repeat("[", depth) + "1" + strings.Repeat("]", depth)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.NotEqual(t, nested(16), bodyView(nested(16)).Text)
|
|
||||||
assert.Equal(t, nested(17), bodyView(nested(17)).Text)
|
|
||||||
|
|
||||||
body := strings.Repeat("[", 5000) + strings.Repeat("]", 5000)
|
|
||||||
|
|
||||||
assert.Equal(t, body, bodyView(body).Text)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewBodyView_GrowingJSONAsReceived proves a JSON body that
|
|
||||||
// pretty-printing would make more than four times its size plus 1 KiB
|
|
||||||
// is shown as it arrived, however shallow: each short element eight
|
|
||||||
// levels deep gets a line indented sixteen spaces.
|
|
||||||
func TestNewBodyView_GrowingJSONAsReceived(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
numbers := func(n int) string {
|
|
||||||
return strings.Repeat("[", 8) +
|
|
||||||
strings.TrimSuffix(strings.Repeat("1,", n), ",") +
|
|
||||||
strings.Repeat("]", 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.NotEqual(t, numbers(10), bodyView(numbers(10)).Text)
|
|
||||||
assert.Equal(t, numbers(1000), bodyView(numbers(1000)).Text)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewBodyView_ScrollsPast200Lines proves a body is shown at
|
|
||||||
// its full height up to 200 lines and in the scrolling box past
|
|
||||||
// them, counting the lines after formatting.
|
|
||||||
func TestNewBodyView_ScrollsPast200Lines(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
assert.False(t, bodyView(lines(200)).Scroll)
|
|
||||||
assert.True(t, bodyView(lines(201)).Scroll)
|
|
||||||
|
|
||||||
// A final newline ends the last line rather than starting another.
|
|
||||||
assert.False(t, bodyView(lines(200)+"\n").Scroll)
|
|
||||||
assert.True(t, bodyView(lines(201)+"\n").Scroll)
|
|
||||||
|
|
||||||
// The page shows a carriage return, a line feed, or the two
|
|
||||||
// together as one line break.
|
|
||||||
assert.True(t, bodyView(strings.Repeat("line\r", 400)).Scroll)
|
|
||||||
assert.False(t, bodyView(strings.Repeat("line\r\n", 200)).Scroll)
|
|
||||||
|
|
||||||
// One line as received, 201 once formatted: the brackets and
|
|
||||||
// 199 elements.
|
|
||||||
numbers := "[" + strings.TrimSuffix(strings.Repeat("1,", 199), ",") + "]"
|
|
||||||
|
|
||||||
assert.NotContains(t, numbers, "\n")
|
|
||||||
assert.True(t, bodyView(numbers).Scroll)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewBodyView_LargeBodyScrolls proves a body larger than the
|
|
||||||
// cap of the lists of events is shown in the scrolling box
|
|
||||||
// however few lines it has, on the event's own page as in the
|
|
||||||
// lists.
|
|
||||||
func TestNewBodyView_LargeBodyScrolls(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
assert.False(t, bodyView(strings.Repeat("x", bodyCap)).Scroll)
|
|
||||||
assert.True(t, bodyView(strings.Repeat("x", bodyCap+1)).Scroll)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewBodyView_BinaryNotShown proves a body that is not text
|
|
||||||
// is never shown: one that is not valid UTF-8, or that holds a
|
|
||||||
// control character other than tab, line feed and carriage return.
|
|
||||||
func TestNewBodyView_BinaryNotShown(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, body := range []string{
|
|
||||||
"\xff\xfe\xfd",
|
|
||||||
"a\x00b",
|
|
||||||
// A small protobuf message: valid UTF-8, but control bytes.
|
|
||||||
"\x08\x01\x12\x03abc",
|
|
||||||
"\x1b[31mred\x1b[0m",
|
|
||||||
"a\x7fb",
|
|
||||||
} {
|
|
||||||
v := bodyView(body)
|
|
||||||
|
|
||||||
assert.True(t, v.Binary, "%q", body)
|
|
||||||
assert.Empty(t, v.Text)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.False(t, bodyView("snow "+snowman).Binary)
|
|
||||||
assert.False(t, bodyView("a\tb\r\nc\n").Binary)
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
// HandleEventDetail shows one event on its own page: its details,
|
|
||||||
// its whole body and every delivery of it. The page reads the
|
|
||||||
// event's body whole, which the receiver caps at 1 MB.
|
|
||||||
func (h *Handlers) HandleEventDetail() http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
webhook, ok := h.ownedWebhook(w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !h.dbMgr.DBExists(webhook.ID) {
|
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
|
||||||
if err != nil {
|
|
||||||
h.serverError(w, r, "failed to get webhook database", err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var rows []eventLogRow
|
|
||||||
|
|
||||||
err = webhookDB.Model(&database.Event{}).
|
|
||||||
Select(eventColumns).
|
|
||||||
Where(
|
|
||||||
"id = ? AND webhook_id = ?",
|
|
||||||
chi.URLParam(r, "eventID"), webhook.ID,
|
|
||||||
).
|
|
||||||
Limit(1).
|
|
||||||
Find(&rows).Error
|
|
||||||
if err != nil {
|
|
||||||
h.serverError(w, r, "failed to load event", err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(rows) == 0 {
|
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
targets, err := h.loadTargetMap(webhook.ID)
|
|
||||||
if err != nil {
|
|
||||||
h.serverError(w, r, "failed to load targets", err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// The page shows every request header.
|
|
||||||
views, ok := h.eventLogViews(
|
|
||||||
w, r, webhookDB, webhook.ID, rows, targets, math.MaxInt,
|
|
||||||
)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
h.renderTemplate(w, r, "event_detail.html", map[string]any{
|
|
||||||
tmplKeyWebhook: &webhook,
|
|
||||||
"Event": views[0],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
|
||||||
)
|
|
||||||
|
|
||||||
// serveEventPage runs the real event page handler as the test user
|
|
||||||
// for the given webhook and event ids.
|
|
||||||
func serveEventPage(
|
|
||||||
t *testing.T,
|
|
||||||
h *handlers.Handlers,
|
|
||||||
sess *session.Session,
|
|
||||||
webhookID, eventID string,
|
|
||||||
) *httptest.ResponseRecorder {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
context.Background(),
|
|
||||||
http.MethodGet,
|
|
||||||
"/hook/"+webhookID+"/events/"+eventID,
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, c := range authenticatedCookies(
|
|
||||||
t, sess, deleteTestUserID, deleteTestUsername,
|
|
||||||
) {
|
|
||||||
req.AddCookie(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
rctx := chi.NewRouteContext()
|
|
||||||
rctx.URLParams.Add(paramSourceID, webhookID)
|
|
||||||
rctx.URLParams.Add(paramEventID, eventID)
|
|
||||||
|
|
||||||
req = req.WithContext(
|
|
||||||
context.WithValue(req.Context(), chi.RouteCtxKey, rctx),
|
|
||||||
)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.HandleEventDetail().ServeHTTP(w, req)
|
|
||||||
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleEventDetail_ShowsEventWholeWithDeliveries proves the
|
|
||||||
// event's page shows its details, its whole body even past the cap
|
|
||||||
// of the lists of events, pretty-printed and in the scrolling box,
|
|
||||||
// and each delivery with its status and attempts.
|
|
||||||
func TestHandleEventDetail_ShowsEventWholeWithDeliveries(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newRecentEventsFixture(t)
|
|
||||||
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
|
|
||||||
|
|
||||||
const sentinel = "TAIL-SENTINEL-5b2e"
|
|
||||||
|
|
||||||
body := `{"pad":"` + strings.Repeat("x", 2*bodyCap) +
|
|
||||||
`","tail":"` + sentinel + `"}`
|
|
||||||
event := f.event(t, contentTypeJSON, body, time.Now())
|
|
||||||
f.attempt(t, f.delivery(
|
|
||||||
t, event, target.ID, database.DeliveryStatusFailed,
|
|
||||||
), http.StatusBadGateway, time.Second)
|
|
||||||
|
|
||||||
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
page := w.Body.String()
|
|
||||||
|
|
||||||
assert.Contains(t, page, event.ID)
|
|
||||||
assert.Contains(t, page, contentTypeJSON)
|
|
||||||
assert.Contains(t, page, strconv.Itoa(len(body))+" bytes")
|
|
||||||
assert.Contains(t, page, "{\n "pad": "xxx")
|
|
||||||
assert.Contains(t, page, ""tail": ""+sentinel+""\n}")
|
|
||||||
assert.Contains(t, page, `style="max-height: 32rem; overflow-y: auto"`)
|
|
||||||
assert.NotContains(t, page, "Showing the first")
|
|
||||||
assert.Contains(t, page, target.Name)
|
|
||||||
assert.Contains(t, page, ">failed</span>")
|
|
||||||
assert.Contains(t, page, "Status: 502")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleEventDetail_ResubmitLinks proves a resubmitted copy's
|
|
||||||
// page links to its original's page, and the original's page says
|
|
||||||
// it was resubmitted.
|
|
||||||
func TestHandleEventDetail_ResubmitLinks(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newRecentEventsFixture(t)
|
|
||||||
original := f.event(t, contentTypeJSON, "{}", time.Now())
|
|
||||||
|
|
||||||
cp := &database.Event{
|
|
||||||
WebhookID: f.webhook.ID,
|
|
||||||
Method: http.MethodPost,
|
|
||||||
Body: "{}",
|
|
||||||
ContentType: contentTypeJSON,
|
|
||||||
ResubmittedFromID: &original.ID,
|
|
||||||
}
|
|
||||||
require.NoError(t, f.webhookDB.Omit(clause.Associations).Create(cp).Error)
|
|
||||||
|
|
||||||
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, cp.ID)
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
assert.Contains(
|
|
||||||
t, w.Body.String(),
|
|
||||||
`href="/hook/`+f.webhook.ID+`/events/`+original.ID+`"`,
|
|
||||||
)
|
|
||||||
|
|
||||||
w = serveEventPage(t, f.h, f.sess, f.webhook.ID, original.ID)
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
assert.Contains(t, w.Body.String(), "as 1 new event<")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleEventDetail_UnknownEventNotFound proves the page is a
|
|
||||||
// 404 for an event that does not exist and for one that belongs to
|
|
||||||
// another webhook.
|
|
||||||
func TestHandleEventDetail_UnknownEventNotFound(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
dbMgr *database.WebhookDBManager
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
mine := seedWebhook(t, db)
|
|
||||||
theirs := seedWebhook(t, db)
|
|
||||||
|
|
||||||
seedEventWithBody(t, dbMgr, mine.ID, "{}")
|
|
||||||
elsewhere := seedEventWithBody(t, dbMgr, theirs.ID, "{}")
|
|
||||||
|
|
||||||
for _, id := range []string{"no-such-event", elsewhere.ID} {
|
|
||||||
w := serveEventPage(t, h, sess, mine.ID, id)
|
|
||||||
assert.Equal(t, http.StatusNotFound, w.Code, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestEventLog_TimesCarryTheirZone proves that the event log shows
|
|
||||||
// when an event arrived, and that it and the event's page show when
|
|
||||||
// each delivery was created and each attempt recorded: each as how
|
|
||||||
// long ago, with the full UTC time on hover.
|
|
||||||
func TestEventLog_TimesCarryTheirZone(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newRecentEventsFixture(t)
|
|
||||||
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
|
|
||||||
|
|
||||||
now := time.Now().UTC().Truncate(time.Second)
|
|
||||||
receivedAt := now.Add(-3 * time.Hour)
|
|
||||||
createdAt := now.Add(-90 * time.Minute)
|
|
||||||
ranAt := now.Add(-30 * time.Minute)
|
|
||||||
|
|
||||||
event := f.event(t, contentTypeJSON, "{}", receivedAt)
|
|
||||||
dlv := f.deliveryQueuedAt(
|
|
||||||
t, event, target.ID, database.DeliveryStatusDelivered, createdAt,
|
|
||||||
)
|
|
||||||
f.attempt(t, dlv, http.StatusOK, ranAt.Sub(createdAt))
|
|
||||||
|
|
||||||
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
eventPage := w.Body.String()
|
|
||||||
eventLog := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
|
|
||||||
|
|
||||||
received := receivedAt.Format(time.DateTime)
|
|
||||||
assert.Contains(t, eventLog, `title="`+received+` UTC">3 hours ago</span>`)
|
|
||||||
assert.NotContains(t, eventLog, received+"</span>",
|
|
||||||
"an event's time must not be written without its zone")
|
|
||||||
assert.Contains(t, eventPage, received+" UTC")
|
|
||||||
|
|
||||||
for _, page := range []string{eventLog, eventPage} {
|
|
||||||
assert.Contains(t, page, `title="`+createdAt.Format(time.DateTime)+
|
|
||||||
` UTC">created 1 hour ago</span>`)
|
|
||||||
assert.Contains(t, page, `title="`+ranAt.Format(time.DateTime)+
|
|
||||||
` UTC">30 minutes ago</span>`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +1,50 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/dustin/go-humanize"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// maxRenderedBodyBytes caps how many bytes of a stored event
|
||||||
|
// body reach the event log page. Bodies come from the
|
||||||
|
// unauthenticated receiver under the 1 MB ingest cap and
|
||||||
|
// renderTemplate buffers a whole page before writing it, so
|
||||||
|
// an uncapped page of paginationPerPage events is tens of
|
||||||
|
// megabytes of resident memory per concurrent viewer.
|
||||||
|
const maxRenderedBodyBytes = 8192
|
||||||
|
|
||||||
// eventLogColumns is the event log's projection. The casts to
|
// eventLogColumns is the event log's projection. The casts to
|
||||||
// blob are load-bearing: they make substr and length count
|
// blob are load-bearing: they make substr and length count
|
||||||
// bytes rather than characters, so the cap bounds the page in
|
// bytes rather than characters, so the cap bounds the page in
|
||||||
// bytes whatever the payload's encoding. Cutting in SQLite
|
// bytes whatever the payload's encoding. Cutting in SQLite
|
||||||
// rather than in Go is the point of the projection — an
|
// rather than in Go is the point of the projection — an
|
||||||
// oversized body or set of request headers never becomes a Go
|
// oversized body never becomes a Go string at all.
|
||||||
// string at all.
|
|
||||||
const eventLogColumns = "id, created_at, method, content_type, " +
|
const eventLogColumns = "id, created_at, method, content_type, " +
|
||||||
"resubmitted_from_id, entrypoint_id, " +
|
"resubmitted_from_id, " +
|
||||||
"substr(cast(headers as blob), 1, ?) AS headers, " +
|
|
||||||
"length(cast(headers as blob)) AS headers_bytes, " +
|
|
||||||
"substr(cast(body as blob), 1, ?) AS body, " +
|
"substr(cast(body as blob), 1, ?) AS body, " +
|
||||||
"length(cast(body as blob)) AS body_bytes"
|
"length(cast(body as blob)) AS body_bytes"
|
||||||
|
|
||||||
// eventColumns is eventLogColumns for the event's own page, which
|
|
||||||
// shows the whole body and every request header.
|
|
||||||
const eventColumns = "id, created_at, method, content_type, " +
|
|
||||||
"resubmitted_from_id, entrypoint_id, headers, " +
|
|
||||||
"length(cast(headers as blob)) AS headers_bytes, " +
|
|
||||||
"cast(body as blob) AS body, " +
|
|
||||||
"length(cast(body as blob)) AS body_bytes"
|
|
||||||
|
|
||||||
// EventLogView is the display-safe projection of an event for
|
// EventLogView is the display-safe projection of an event for
|
||||||
// the event log page and the event's own page, alongside
|
// the event log page, alongside DeliveryView and TargetView.
|
||||||
// DeliveryView and TargetView.
|
// It carries a capped body plus the true stored size, so the
|
||||||
|
// page can mark a body as truncated without ever holding the
|
||||||
|
// whole thing.
|
||||||
type EventLogView struct {
|
type EventLogView struct {
|
||||||
ID string
|
ID string
|
||||||
|
CreatedAt time.Time
|
||||||
Method string
|
Method string
|
||||||
ContentType string
|
ContentType string
|
||||||
|
|
||||||
// Received is how long ago the event arrived, and ReceivedUTC
|
// Body holds at most maxRenderedBodyBytes bytes of the
|
||||||
// the full timestamp.
|
// stored body.
|
||||||
Received string
|
Body string
|
||||||
ReceivedUTC string
|
|
||||||
|
|
||||||
Body BodyView
|
// BodyBytes is the true size of the stored body.
|
||||||
|
BodyBytes int64
|
||||||
|
|
||||||
// Entrypoint names the entrypoint the event arrived at. A
|
// BodyTruncated reports that the stored body was larger
|
||||||
// resubmitted copy, even a copy of a copy, did not arrive; it
|
// than the cap, so the page owes the reader a marker.
|
||||||
// names the one the request it copies arrived at. The name is
|
BodyTruncated bool
|
||||||
// the entrypoint's description, "Entrypoint" when it has none,
|
|
||||||
// or "deleted entrypoint", never its URL, which is the
|
|
||||||
// entrypoint's secret.
|
|
||||||
Entrypoint string
|
|
||||||
|
|
||||||
// Headers is the event's request headers as text, one
|
|
||||||
// "Name: value" line per value, sorted by name. HeadersCut
|
|
||||||
// reports headers left out because they hold more than
|
|
||||||
// maxRenderedBodyBytes, stored or as text; only the event log
|
|
||||||
// leaves them out.
|
|
||||||
Headers string
|
|
||||||
HeadersCut bool
|
|
||||||
|
|
||||||
// ResubmittedFromID names the event this one was copied
|
// ResubmittedFromID names the event this one was copied
|
||||||
// from, empty for an event that arrived on the receiver.
|
// from, empty for an event that arrived on the receiver.
|
||||||
@@ -84,122 +65,55 @@ func (v EventLogView) ResubmittedFrom() bool {
|
|||||||
return v.ResubmittedFromID != ""
|
return v.ResubmittedFromID != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// eventLogRow is one row of the event log projection, or of
|
// BodyShownBytes is how many body bytes the page is actually
|
||||||
// eventColumns. In the event log its headers and body columns
|
// rendering, which the truncation marker reports beside the
|
||||||
// arrive already cut to the cap by SQLite, each with its true
|
// true size.
|
||||||
// size beside it.
|
func (v EventLogView) BodyShownBytes() int {
|
||||||
|
return len(v.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventLogRow is one row of the event log projection. Its
|
||||||
|
// body column arrives already cut to the cap by SQLite, with
|
||||||
|
// the true size beside it.
|
||||||
type eventLogRow struct {
|
type eventLogRow struct {
|
||||||
ID string
|
ID string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Method string
|
Method string
|
||||||
ContentType string
|
ContentType string
|
||||||
ResubmittedFromID *string
|
ResubmittedFromID *string
|
||||||
EntrypointID string
|
|
||||||
Headers string
|
|
||||||
HeadersBytes int64
|
|
||||||
Body []byte
|
Body []byte
|
||||||
BodyBytes int64
|
BodyBytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// view projects a loaded row of the webhook's events for
|
// view projects a loaded row for rendering.
|
||||||
// rendering. It shows the request headers when the row holds them
|
func (r *eventLogRow) view() EventLogView {
|
||||||
// whole and their text holds at most maxHeaderBytes.
|
body := r.Body
|
||||||
func (r *eventLogRow) view(
|
truncated := r.BodyBytes > int64(len(body))
|
||||||
webhookID string, maxHeaderBytes int,
|
|
||||||
) EventLogView {
|
// Only a cut body can have been left mid-sequence by
|
||||||
|
// this query. A whole body is passed through exactly as
|
||||||
|
// stored, however malformed.
|
||||||
|
if truncated {
|
||||||
|
body = trimPartialRune(body)
|
||||||
|
}
|
||||||
|
|
||||||
var from string
|
var from string
|
||||||
if r.ResubmittedFromID != nil {
|
if r.ResubmittedFromID != nil {
|
||||||
from = *r.ResubmittedFromID
|
from = *r.ResubmittedFromID
|
||||||
}
|
}
|
||||||
|
|
||||||
headers, fit := requestHeaderLines(r.Headers, maxHeaderBytes)
|
|
||||||
|
|
||||||
return EventLogView{
|
return EventLogView{
|
||||||
ID: r.ID,
|
ID: r.ID,
|
||||||
Method: r.Method,
|
CreatedAt: r.CreatedAt,
|
||||||
ContentType: r.ContentType,
|
Method: r.Method,
|
||||||
Received: humanize.Time(r.CreatedAt),
|
ContentType: r.ContentType,
|
||||||
ReceivedUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
|
Body: string(body),
|
||||||
Body: newBodyView(
|
BodyBytes: r.BodyBytes,
|
||||||
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
|
BodyTruncated: truncated,
|
||||||
),
|
|
||||||
Headers: strings.Join(headers, "\n"),
|
|
||||||
HeadersCut: !fit || r.HeadersBytes > int64(len(r.Headers)),
|
|
||||||
ResubmittedFromID: from,
|
ResubmittedFromID: from,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// requestHeaderLines turns an event's stored request headers, the
|
|
||||||
// JSON the receiver writes, into one "Name: value" line per value,
|
|
||||||
// sorted by name. Headers that do not parse, as when the event log
|
|
||||||
// has cut them, show as none. It reports false, with no lines, when
|
|
||||||
// the lines, each with the newline that follows it, would hold more
|
|
||||||
// than maxBytes: a header sent many times is stored with its name
|
|
||||||
// once but shown with it on every line.
|
|
||||||
func requestHeaderLines(headersJSON string, maxBytes int) ([]string, bool) {
|
|
||||||
var headers http.Header
|
|
||||||
|
|
||||||
if json.Unmarshal([]byte(headersJSON), &headers) != nil {
|
|
||||||
return nil, true
|
|
||||||
}
|
|
||||||
|
|
||||||
names := make([]string, 0, len(headers))
|
|
||||||
for name := range headers {
|
|
||||||
names = append(names, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
slices.Sort(names)
|
|
||||||
|
|
||||||
var lines []string
|
|
||||||
|
|
||||||
size := 0
|
|
||||||
|
|
||||||
for _, name := range names {
|
|
||||||
for _, value := range headers[name] {
|
|
||||||
line := name + ": " + value
|
|
||||||
|
|
||||||
size += len(line) + len("\n")
|
|
||||||
if size > maxBytes {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
lines = append(lines, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// entrypointNames maps each of the webhook's entrypoints to the name
|
|
||||||
// an event that arrived at it shows: its description, or "Entrypoint"
|
|
||||||
// when it has none, as the webhook page names it. A deleted
|
|
||||||
// entrypoint is left out.
|
|
||||||
func (h *Handlers) entrypointNames(
|
|
||||||
webhookID string,
|
|
||||||
) (map[string]string, error) {
|
|
||||||
var entrypoints []database.Entrypoint
|
|
||||||
|
|
||||||
err := h.db.DB().Where(
|
|
||||||
"webhook_id = ?", webhookID,
|
|
||||||
).Find(&entrypoints).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
names := make(map[string]string, len(entrypoints))
|
|
||||||
|
|
||||||
for i := range entrypoints {
|
|
||||||
name := entrypoints[i].Description
|
|
||||||
if name == "" {
|
|
||||||
name = "Entrypoint"
|
|
||||||
}
|
|
||||||
|
|
||||||
names[entrypoints[i].ID] = name
|
|
||||||
}
|
|
||||||
|
|
||||||
return names, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// trimPartialRune drops a trailing UTF-8 sequence that the
|
// trimPartialRune drops a trailing UTF-8 sequence that the
|
||||||
// byte-wise cut left incomplete, so a multi-byte rune severed
|
// byte-wise cut left incomplete, so a multi-byte rune severed
|
||||||
// at the cap does not surface as a mojibake tail.
|
// at the cap does not surface as a mojibake tail.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/session"
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
// bodyCap is the number of body bytes the lists of events are
|
// bodyCap is the number of body bytes the event log page is
|
||||||
// allowed to render for one event.
|
// allowed to render for one event.
|
||||||
const bodyCap = handlers.MaxRenderedBodyBytesForTest
|
const bodyCap = handlers.MaxRenderedBodyBytesForTest
|
||||||
|
|
||||||
@@ -75,7 +75,9 @@ func seedAndProject(
|
|||||||
wh := seedWebhook(t, db)
|
wh := seedWebhook(t, db)
|
||||||
seedEventWithBody(t, dbMgr, wh.ID, body)
|
seedEventWithBody(t, dbMgr, wh.ID, body)
|
||||||
|
|
||||||
views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh)
|
views := h.LoadEventLogViewsForTest(
|
||||||
|
httptest.NewRecorder(), *wh, 1,
|
||||||
|
)
|
||||||
require.Len(t, views, 1)
|
require.Len(t, views, 1)
|
||||||
|
|
||||||
return views[0]
|
return views[0]
|
||||||
@@ -83,7 +85,7 @@ func seedAndProject(
|
|||||||
|
|
||||||
// TestHandleSourceLogs_BoundsOversizeBody proves the rendered
|
// TestHandleSourceLogs_BoundsOversizeBody proves the rendered
|
||||||
// page is bounded by the cap rather than by the stored payload:
|
// page is bounded by the cap rather than by the stored payload:
|
||||||
// the body here is 16 times the cap, and the ingest path would
|
// the body here is 64 times the cap, and the ingest path would
|
||||||
// accept twice as much again.
|
// accept twice as much again.
|
||||||
func TestHandleSourceLogs_BoundsOversizeBody(t *testing.T) {
|
func TestHandleSourceLogs_BoundsOversizeBody(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -121,7 +123,7 @@ func TestHandleSourceLogs_BoundsOversizeBody(t *testing.T) {
|
|||||||
// The marker states the true stored size, not the cut one.
|
// The marker states the true stored size, not the cut one.
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, page,
|
t, page,
|
||||||
"Showing the first "+strconv.Itoa(bodyCap)+
|
"showing "+strconv.Itoa(bodyCap)+
|
||||||
" of "+strconv.Itoa(storedBytes)+" bytes",
|
" of "+strconv.Itoa(storedBytes)+" bytes",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -150,35 +152,34 @@ func TestHandleSourceLogs_SmallBodyRendersWhole(t *testing.T) {
|
|||||||
page := renderSourceLogsPage(t, h, sess, wh.ID)
|
page := renderSourceLogsPage(t, h, sess, wh.ID)
|
||||||
|
|
||||||
assert.Contains(t, page, ""kept"")
|
assert.Contains(t, page, ""kept"")
|
||||||
assert.NotContains(t, page, "Showing the first")
|
assert.NotContains(t, page, "Body truncated for display")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEventLogView_CutMidRune proves a multi-byte rune severed
|
// TestEventLogView_CutMidRune proves a multi-byte rune severed
|
||||||
// by the byte-wise cut is dropped rather than surfaced as a
|
// by the byte-wise cut is dropped rather than surfaced as a
|
||||||
// mojibake tail, which would also make the text look binary.
|
// mojibake tail.
|
||||||
func TestEventLogView_CutMidRune(t *testing.T) {
|
func TestEventLogView_CutMidRune(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
body := strings.Repeat(snowman, bodyCap)
|
body := strings.Repeat(snowman, 4096)
|
||||||
view := seedAndProject(t, body)
|
view := seedAndProject(t, body)
|
||||||
|
|
||||||
// bodyCap bytes hold bodyCap/3 whole snowmen and two bytes
|
// bodyCap bytes hold bodyCap/3 whole snowmen and two bytes
|
||||||
// of the next one; those two are dropped.
|
// of the next one; those two are dropped.
|
||||||
whole := bodyCap / len(snowman)
|
whole := bodyCap / len(snowman)
|
||||||
|
|
||||||
assert.True(t, view.Body.Cut)
|
assert.True(t, view.BodyTruncated)
|
||||||
assert.False(t, view.Body.Binary)
|
assert.Equal(t, int64(len(body)), view.BodyBytes)
|
||||||
assert.Equal(t, int64(len(body)), view.Body.Size)
|
assert.Equal(t, strings.Repeat(snowman, whole), view.Body)
|
||||||
assert.Equal(t, strings.Repeat(snowman, whole), view.Body.Text)
|
assert.True(t, utf8.ValidString(view.Body))
|
||||||
assert.True(t, utf8.ValidString(view.Body.Text))
|
assert.LessOrEqual(t, len(view.Body), bodyCap)
|
||||||
assert.Equal(t, len(view.Body.Text), view.Body.ShownBytes)
|
|
||||||
assert.LessOrEqual(t, view.Body.ShownBytes, bodyCap)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEventLogView_BinaryBodyNotShown proves a body that is not
|
// TestEventLogView_BinaryBodyLeftAsStored proves a binary
|
||||||
// text is left out rather than shown as replacement characters,
|
// payload is passed through byte for byte. Its tail is invalid
|
||||||
// whether it is cut or not.
|
// UTF-8 however the cut falls, so repairing it would misreport
|
||||||
func TestEventLogView_BinaryBodyNotShown(t *testing.T) {
|
// what the sender delivered.
|
||||||
|
func TestEventLogView_BinaryBodyLeftAsStored(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
raw := make([]byte, bodyCap+808)
|
raw := make([]byte, bodyCap+808)
|
||||||
@@ -187,21 +188,12 @@ func TestEventLogView_BinaryBodyNotShown(t *testing.T) {
|
|||||||
raw[i] = 0x80 | byte(i%0x40)
|
raw[i] = 0x80 | byte(i%0x40)
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, body := range map[string][]byte{
|
view := seedAndProject(t, string(raw))
|
||||||
"cut": raw,
|
|
||||||
"whole": raw[:2048],
|
|
||||||
"NUL": []byte("text\x00text"),
|
|
||||||
} {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
view := seedAndProject(t, string(body))
|
assert.True(t, view.BodyTruncated)
|
||||||
|
assert.Equal(t, int64(len(raw)), view.BodyBytes)
|
||||||
assert.True(t, view.Body.Binary)
|
assert.Equal(t, string(raw[:bodyCap]), view.Body)
|
||||||
assert.Empty(t, view.Body.Text)
|
assert.False(t, utf8.ValidString(view.Body))
|
||||||
assert.Equal(t, int64(len(body)), view.Body.Size)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTrimPartialRune covers the distinction the cut repair
|
// TestTrimPartialRune covers the distinction the cut repair
|
||||||
|
|||||||
@@ -1,332 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"slices"
|
|
||||||
"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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// arrivedAt is how a page names the entrypoint an event arrived at.
|
|
||||||
func arrivedAt(name string) string {
|
|
||||||
return `Arrived at <span class="text-gray-900">` + name + `</span>`
|
|
||||||
}
|
|
||||||
|
|
||||||
// copiedRequestArrivedAt is how a page names, for a resubmitted copy,
|
|
||||||
// the entrypoint the request it copies arrived at.
|
|
||||||
func copiedRequestArrivedAt(name string) string {
|
|
||||||
return `The request it copies arrived at <span class="text-gray-900">` +
|
|
||||||
name + `</span>`
|
|
||||||
}
|
|
||||||
|
|
||||||
// headerBox is how a page shows an event's request header lines: as
|
|
||||||
// one block of text in a single box.
|
|
||||||
func headerBox(lines ...string) string {
|
|
||||||
return `<pre class="rounded-md border border-gray-200 bg-white p-2 ` +
|
|
||||||
`text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap ` +
|
|
||||||
`break-all">` + strings.Join(lines, "\n") + `</pre>`
|
|
||||||
}
|
|
||||||
|
|
||||||
// showHeadersLink is the event log's link to an event's own page for
|
|
||||||
// request headers it leaves out.
|
|
||||||
func showHeadersLink(webhookID, eventID string) string {
|
|
||||||
return `<a href="/hook/` + webhookID + `/events/` + eventID +
|
|
||||||
`" class="btn-small">Show the request headers</a>`
|
|
||||||
}
|
|
||||||
|
|
||||||
// entrypoint records one of the fixture webhook's entrypoints.
|
|
||||||
func (f *recentEventsFixture) entrypoint(
|
|
||||||
t *testing.T, description string,
|
|
||||||
) *database.Entrypoint {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
ep := &database.Entrypoint{
|
|
||||||
WebhookID: f.webhook.ID,
|
|
||||||
Path: uuid.NewString(),
|
|
||||||
Description: description,
|
|
||||||
Active: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, f.db.DB().Omit(clause.Associations).Create(ep).Error)
|
|
||||||
|
|
||||||
return ep
|
|
||||||
}
|
|
||||||
|
|
||||||
// eventAt records an event that arrived at the entrypoint with the
|
|
||||||
// given request headers, stored as JSON as the receiver stores them.
|
|
||||||
func (f *recentEventsFixture) eventAt(
|
|
||||||
t *testing.T,
|
|
||||||
ep *database.Entrypoint,
|
|
||||||
headersJSON string,
|
|
||||||
receivedAt time.Time,
|
|
||||||
) *database.Event {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
event := &database.Event{
|
|
||||||
WebhookID: f.webhook.ID,
|
|
||||||
EntrypointID: ep.ID,
|
|
||||||
Method: http.MethodPost,
|
|
||||||
Headers: headersJSON,
|
|
||||||
Body: "{}",
|
|
||||||
BodyBytes: 2,
|
|
||||||
ContentType: contentTypeJSON,
|
|
||||||
}
|
|
||||||
event.CreatedAt = receivedAt
|
|
||||||
|
|
||||||
require.NoError(t, f.webhookDB.Omit(
|
|
||||||
clause.Associations,
|
|
||||||
).Create(event).Error)
|
|
||||||
|
|
||||||
return event
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders proves two
|
|
||||||
// events that arrived at two entrypoints each show their own
|
|
||||||
// entrypoint and request headers, in the event log and on their own
|
|
||||||
// pages, with the headers sorted by name, escaped and keeping their
|
|
||||||
// whitespace, and never the entrypoint's URL.
|
|
||||||
func TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newRecentEventsFixture(t)
|
|
||||||
billing := f.entrypoint(t, "Billing sender")
|
|
||||||
unnamed := f.entrypoint(t, "")
|
|
||||||
|
|
||||||
// Stored in reverse name order.
|
|
||||||
older := f.eventAt(t, billing,
|
|
||||||
`{"X-Shop-Event":["order.created"],`+
|
|
||||||
`"User-Agent":["shop/1 build\t7"],"Accept":["*/*"]}`,
|
|
||||||
time.Now().Add(-time.Minute))
|
|
||||||
newer := f.eventAt(t, unnamed,
|
|
||||||
`{"X-Shop-Event":["order.paid"],"X-Note":["<b>hi</b>"]}`,
|
|
||||||
time.Now())
|
|
||||||
|
|
||||||
olderShows := func(t *testing.T, page string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
assert.Contains(t, page, arrivedAt("Billing sender"))
|
|
||||||
assert.Contains(t, page, headerBox(
|
|
||||||
"Accept: */*",
|
|
||||||
"User-Agent: shop/1 build\t7",
|
|
||||||
"X-Shop-Event: order.created",
|
|
||||||
), "headers are sorted by name")
|
|
||||||
assert.NotContains(t, page, "order.paid")
|
|
||||||
assert.NotContains(t, page, billing.Path)
|
|
||||||
}
|
|
||||||
|
|
||||||
newerShows := func(t *testing.T, page string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
assert.Contains(t, page, arrivedAt("Entrypoint"))
|
|
||||||
assert.Contains(t, page, headerBox(
|
|
||||||
"X-Note: <b>hi</b>",
|
|
||||||
"X-Shop-Event: order.paid",
|
|
||||||
))
|
|
||||||
assert.NotContains(t, page, "<b>hi</b>")
|
|
||||||
assert.NotContains(t, page, "order.created")
|
|
||||||
assert.NotContains(t, page, unnamed.Path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The log lists the newer event first, so everything between
|
|
||||||
// the two events' first mentions belongs to the newer one.
|
|
||||||
_, rest, found := strings.Cut(renderSourceLogsPage(
|
|
||||||
t, f.h, f.sess, f.webhook.ID,
|
|
||||||
), newer.ID)
|
|
||||||
require.True(t, found)
|
|
||||||
|
|
||||||
newerPart, olderPart, found := strings.Cut(rest, older.ID)
|
|
||||||
require.True(t, found)
|
|
||||||
|
|
||||||
newerShows(t, newerPart)
|
|
||||||
olderShows(t, olderPart)
|
|
||||||
|
|
||||||
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, newer.ID)
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
newerShows(t, w.Body.String())
|
|
||||||
|
|
||||||
w = serveEventPage(t, f.h, f.sess, f.webhook.ID, older.ID)
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
olderShows(t, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEventRequest_DeletedEntrypoint proves an event whose entrypoint
|
|
||||||
// has since been deleted says so in the event log and on its own page.
|
|
||||||
func TestEventRequest_DeletedEntrypoint(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newRecentEventsFixture(t)
|
|
||||||
ep := f.entrypoint(t, "Retired sender")
|
|
||||||
event := f.eventAt(t, ep, `{}`, time.Now())
|
|
||||||
|
|
||||||
require.NoError(t, f.db.DB().Delete(ep).Error)
|
|
||||||
|
|
||||||
page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
|
|
||||||
assert.Contains(t, page, arrivedAt("deleted entrypoint"))
|
|
||||||
assert.NotContains(t, page, "Retired sender")
|
|
||||||
|
|
||||||
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
assert.Contains(t, w.Body.String(), arrivedAt("deleted entrypoint"))
|
|
||||||
assert.NotContains(t, w.Body.String(), "Retired sender")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEventRequest_ResubmittedCopy proves a resubmitted copy and a copy
|
|
||||||
// of that copy each say the request they copy arrived at the
|
|
||||||
// entrypoint, in the event log and on their own pages, and never that
|
|
||||||
// they did.
|
|
||||||
func TestEventRequest_ResubmittedCopy(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newRecentEventsFixture(t)
|
|
||||||
ep := f.entrypoint(t, "Billing sender")
|
|
||||||
original := f.eventAt(t, ep, `{}`, time.Now().Add(-2*time.Minute))
|
|
||||||
copied := f.eventAt(t, ep, `{}`, time.Now().Add(-time.Minute))
|
|
||||||
copyOfCopy := f.eventAt(t, ep, `{}`, time.Now())
|
|
||||||
|
|
||||||
require.NoError(t, f.webhookDB.Model(copied).Update(
|
|
||||||
"resubmitted_from_id", original.ID,
|
|
||||||
).Error)
|
|
||||||
require.NoError(t, f.webhookDB.Model(copyOfCopy).Update(
|
|
||||||
"resubmitted_from_id", copied.ID,
|
|
||||||
).Error)
|
|
||||||
|
|
||||||
// The log lists the newest event first, and each event's Resubmit
|
|
||||||
// form comes before its entrypoint, so cutting the page at the
|
|
||||||
// copy's and the original's forms leaves each event's entrypoint
|
|
||||||
// in its own part.
|
|
||||||
copyOfCopyPart, rest, found := strings.Cut(
|
|
||||||
renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID),
|
|
||||||
"/events/"+copied.ID+"/resubmit",
|
|
||||||
)
|
|
||||||
require.True(t, found)
|
|
||||||
|
|
||||||
copyPart, originalPart, found := strings.Cut(
|
|
||||||
rest, "/events/"+original.ID+"/resubmit",
|
|
||||||
)
|
|
||||||
require.True(t, found)
|
|
||||||
|
|
||||||
for _, part := range []string{copyOfCopyPart, copyPart} {
|
|
||||||
assert.Contains(t, part, copiedRequestArrivedAt("Billing sender"))
|
|
||||||
assert.NotContains(t, part, arrivedAt("Billing sender"))
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Contains(t, originalPart, arrivedAt("Billing sender"))
|
|
||||||
assert.NotContains(t, originalPart,
|
|
||||||
copiedRequestArrivedAt("Billing sender"))
|
|
||||||
|
|
||||||
for _, event := range []*database.Event{copied, copyOfCopy} {
|
|
||||||
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
assert.Contains(t, w.Body.String(),
|
|
||||||
copiedRequestArrivedAt("Billing sender"))
|
|
||||||
assert.NotContains(t, w.Body.String(), arrivedAt("Billing sender"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEventRequest_HeadersOverTheLimit proves the event log leaves out
|
|
||||||
// request headers that hold more than it shows of a body, whether
|
|
||||||
// stored or as lines, and links to the event's own page, which shows
|
|
||||||
// them all.
|
|
||||||
func TestEventRequest_HeadersOverTheLimit(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// The receiver stores each "<" as six bytes of JSON, so this
|
|
||||||
// header is over the limit stored but not as a line.
|
|
||||||
const lessThans = bodyCap/6 + 1
|
|
||||||
|
|
||||||
// A header sent many times is stored with its name once, and
|
|
||||||
// shown with it on every line.
|
|
||||||
repeatedName := "X-Repeated-" + strings.Repeat("r", 1000)
|
|
||||||
|
|
||||||
tests := map[string]struct {
|
|
||||||
headers http.Header
|
|
||||||
line string
|
|
||||||
}{
|
|
||||||
"stored": {
|
|
||||||
headers: http.Header{"X-Long": {strings.Repeat("<", lessThans)}},
|
|
||||||
line: "X-Long: " + strings.Repeat("<", lessThans),
|
|
||||||
},
|
|
||||||
"as lines": {
|
|
||||||
headers: http.Header{repeatedName: slices.Repeat([]string{""}, 41)},
|
|
||||||
line: repeatedName + ": ",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, tc := range tests {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
headersJSON, err := json.Marshal(tc.headers)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
f := newRecentEventsFixture(t)
|
|
||||||
ep := f.entrypoint(t, "Billing sender")
|
|
||||||
event := f.eventAt(t, ep, string(headersJSON), time.Now())
|
|
||||||
|
|
||||||
page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
|
|
||||||
assert.Contains(t, page, showHeadersLink(f.webhook.ID, event.ID))
|
|
||||||
assert.NotContains(t, page, tc.line)
|
|
||||||
assert.Less(t, len(page), 4*bodyCap)
|
|
||||||
|
|
||||||
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
assert.Contains(t, w.Body.String(), tc.line)
|
|
||||||
assert.NotContains(t, w.Body.String(), "Show the request headers")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEventRequest_ManyShortHeaderLines proves that for many short
|
|
||||||
// request header lines the event log writes no more than its limit,
|
|
||||||
// apart from escaping: lines that fill the limit show as one block of
|
|
||||||
// text, and one line more is left out with a link to the event's own
|
|
||||||
// page.
|
|
||||||
func TestEventRequest_ManyShortHeaderLines(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Each "A: " line and the newline after it hold four bytes, so
|
|
||||||
// this many lines fill the limit exactly. Each line in its own
|
|
||||||
// element would make the page many times the limit.
|
|
||||||
const fill = bodyCap / len("A: \n")
|
|
||||||
|
|
||||||
tests := map[string]struct {
|
|
||||||
lines int
|
|
||||||
shown bool
|
|
||||||
}{
|
|
||||||
"filling the limit": {lines: fill, shown: true},
|
|
||||||
"one over the limit": {lines: fill + 1, shown: false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, tc := range tests {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
headersJSON, err := json.Marshal(http.Header{
|
|
||||||
"A": slices.Repeat([]string{""}, tc.lines),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
f := newRecentEventsFixture(t)
|
|
||||||
ep := f.entrypoint(t, "Billing sender")
|
|
||||||
event := f.eventAt(t, ep, string(headersJSON), time.Now())
|
|
||||||
|
|
||||||
page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
|
|
||||||
box := headerBox(slices.Repeat([]string{"A: "}, tc.lines)...)
|
|
||||||
link := showHeadersLink(f.webhook.ID, event.ID)
|
|
||||||
|
|
||||||
assert.Equal(t, tc.shown, strings.Contains(page, box))
|
|
||||||
assert.Equal(t, !tc.shown, strings.Contains(page, link))
|
|
||||||
assert.Less(t, len(page), 4*bodyCap)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,16 +19,10 @@ func (s *Handlers) SetLogForTest(log *slog.Logger) {
|
|||||||
s.log = log
|
s.log = log
|
||||||
}
|
}
|
||||||
|
|
||||||
// MaxRenderedBodyBytesForTest exposes the body cap of the lists
|
// MaxRenderedBodyBytesForTest exposes the event log's body cap
|
||||||
// of events to the handlers_test package.
|
// to the handlers_test package.
|
||||||
const MaxRenderedBodyBytesForTest = maxRenderedBodyBytes
|
const MaxRenderedBodyBytesForTest = maxRenderedBodyBytes
|
||||||
|
|
||||||
// NewBodyViewForTest exposes newBodyView for use in the
|
|
||||||
// handlers_test package.
|
|
||||||
func NewBodyViewForTest(body []byte, size int64) BodyView {
|
|
||||||
return newBodyView("/hook/w/events/e", body, size)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MaxRenderedResponseBytesForTest exposes the event log's
|
// MaxRenderedResponseBytesForTest exposes the event log's
|
||||||
// delivery response cap to the handlers_test package.
|
// delivery response cap to the handlers_test package.
|
||||||
const MaxRenderedResponseBytesForTest = maxRenderedResponseBytes
|
const MaxRenderedResponseBytesForTest = maxRenderedResponseBytes
|
||||||
@@ -51,6 +45,12 @@ const (
|
|||||||
SidecarLeftMsgForTest = sidecarLeftMsg
|
SidecarLeftMsgForTest = sidecarLeftMsg
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// PageOrFirstForTest exposes pageOrFirst for use in the handlers_test
|
||||||
|
// package.
|
||||||
|
func PageOrFirstForTest(s string) int {
|
||||||
|
return pageOrFirst(s)
|
||||||
|
}
|
||||||
|
|
||||||
// DummyVerificationsForTest reports how many equivalent-cost
|
// DummyVerificationsForTest reports how many equivalent-cost
|
||||||
// verifications were charged for usernames that do not exist. It
|
// verifications were charged for usernames that do not exist. It
|
||||||
// lets a test prove the anti-enumeration path ran without timing
|
// lets a test prove the anti-enumeration path ran without timing
|
||||||
@@ -73,9 +73,10 @@ func TrimPartialRuneForTest(b []byte) []byte {
|
|||||||
func (s *Handlers) LoadEventLogViewsForTest(
|
func (s *Handlers) LoadEventLogViewsForTest(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
|
page int,
|
||||||
) []EventLogView {
|
) []EventLogView {
|
||||||
views, _, _ := s.loadEventsWithDeliveries(
|
views, _, _ := s.loadEventsWithDeliveries(
|
||||||
w, newRequestForTest(), webhook, nil, nil,
|
w, newRequestForTest(), webhook, nil, page,
|
||||||
)
|
)
|
||||||
|
|
||||||
return views
|
return views
|
||||||
@@ -160,16 +161,7 @@ func (s *Handlers) BuildHTTPTargetConfigForTest(
|
|||||||
// buildDatabaseTargetConfig for use in the handlers_test
|
// buildDatabaseTargetConfig for use in the handlers_test
|
||||||
// package.
|
// package.
|
||||||
func BuildDatabaseTargetConfigForTest(
|
func BuildDatabaseTargetConfigForTest(
|
||||||
expiry, rotation string,
|
expiry string,
|
||||||
) (string, string, error) {
|
) (string, string, error) {
|
||||||
return buildDatabaseTargetConfig(expiry, rotation)
|
return buildDatabaseTargetConfig(expiry)
|
||||||
}
|
|
||||||
|
|
||||||
// ArchiveFileViewForTest exposes archiveFileView, which describes a
|
|
||||||
// database target's archive files as the target list shows them at
|
|
||||||
// now.
|
|
||||||
func (s *Handlers) ArchiveFileViewForTest(
|
|
||||||
webhook *database.Webhook, target *database.Target, now time.Time,
|
|
||||||
) *ArchiveFileView {
|
|
||||||
return s.archiveFileView(webhook, target, now)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,10 @@ import (
|
|||||||
const (
|
const (
|
||||||
// maxBodyShift is the bit shift for 1 MB body limit.
|
// maxBodyShift is the bit shift for 1 MB body limit.
|
||||||
maxBodyShift = 20
|
maxBodyShift = 20
|
||||||
// recentEventLimit is the number of most recent events that a
|
// recentEventLimit is the number of recent events to show.
|
||||||
// webhook's page and its event log show.
|
|
||||||
recentEventLimit = 50
|
recentEventLimit = 50
|
||||||
|
// paginationPerPage is the number of items per page.
|
||||||
|
paginationPerPage = 25
|
||||||
|
|
||||||
// tmplKeyError is the template data key for an error message.
|
// tmplKeyError is the template data key for an error message.
|
||||||
tmplKeyError = "Error"
|
tmplKeyError = "Error"
|
||||||
@@ -56,20 +57,19 @@ var errVerificationBusy = errors.New(
|
|||||||
type HandlersParams struct {
|
type HandlersParams struct {
|
||||||
fx.In
|
fx.In
|
||||||
|
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
Database *database.Database
|
Database *database.Database
|
||||||
WebhookDBMgr *database.WebhookDBManager
|
WebhookDBMgr *database.WebhookDBManager
|
||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
Session *session.Session
|
Session *session.Session
|
||||||
Middleware *middleware.Middleware
|
Middleware *middleware.Middleware
|
||||||
Notifier delivery.Notifier
|
Notifier delivery.Notifier
|
||||||
Archives delivery.Archives
|
Archives delivery.Archives
|
||||||
CircuitBreakers delivery.CircuitBreakers
|
SSRFGuard *delivery.Guard
|
||||||
SSRFGuard *delivery.Guard
|
Metrics *metrics.Set
|
||||||
Metrics *metrics.Set
|
Registry *prometheus.Registry
|
||||||
Registry *prometheus.Registry
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handlers provides HTTP handler methods for all application
|
// Handlers provides HTTP handler methods for all application
|
||||||
@@ -84,7 +84,6 @@ type Handlers struct {
|
|||||||
mw *middleware.Middleware
|
mw *middleware.Middleware
|
||||||
notifier delivery.Notifier
|
notifier delivery.Notifier
|
||||||
archives delivery.Archives
|
archives delivery.Archives
|
||||||
breakers delivery.CircuitBreakers
|
|
||||||
mtr *metrics.Set
|
mtr *metrics.Set
|
||||||
templates map[string]*template.Template
|
templates map[string]*template.Template
|
||||||
|
|
||||||
@@ -99,9 +98,7 @@ type Handlers struct {
|
|||||||
// Interleaved, one could rename an archive between another's
|
// Interleaved, one could rename an archive between another's
|
||||||
// rename and save, leaving the file named for one edit and the
|
// rename and save, leaving the file named for one edit and the
|
||||||
// stored names from the other. An archive download holds it while
|
// stored names from the other. An archive download holds it while
|
||||||
// it reads the stored names and lists the files they give, and
|
// it reads the stored names and opens the file they give.
|
||||||
// again for each file while it finds the file under the names
|
|
||||||
// stored then and opens it.
|
|
||||||
renameMu sync.Mutex
|
renameMu sync.Mutex
|
||||||
|
|
||||||
// dummyVerifications counts the equivalent-cost verifications
|
// dummyVerifications counts the equivalent-cost verifications
|
||||||
@@ -113,25 +110,22 @@ type Handlers struct {
|
|||||||
// parsePageTemplate parses a page-specific template set from the
|
// parsePageTemplate parses a page-specific template set from the
|
||||||
// embedded FS. Each page template is combined with the shared
|
// embedded FS. Each page template is combined with the shared
|
||||||
// base, htmlheader, navbar and notice templates, and with any further
|
// base, htmlheader, navbar and notice templates, and with any further
|
||||||
// files the page includes. The set is named after the page file, so
|
// files the page includes. The page file must be listed first so that
|
||||||
// the page's root action ({{template "base" .}}) is its entry point.
|
// its root action ({{template "base" .}}) becomes the template set's
|
||||||
//
|
// entry point.
|
||||||
// The page file is parsed last because a later definition of a name
|
|
||||||
// replaces an earlier one: the page's {{define "title"}} must replace
|
|
||||||
// the {{block "title"}} fallback in htmlheader.html.
|
|
||||||
func parsePageTemplate(
|
func parsePageTemplate(
|
||||||
pageFile string, included ...string,
|
pageFile string, included ...string,
|
||||||
) *template.Template {
|
) *template.Template {
|
||||||
files := append([]string{
|
files := append([]string{
|
||||||
|
pageFile,
|
||||||
"base.html",
|
"base.html",
|
||||||
"htmlheader.html",
|
"htmlheader.html",
|
||||||
"navbar.html",
|
"navbar.html",
|
||||||
"notice.html",
|
"notice.html",
|
||||||
}, included...)
|
}, included...)
|
||||||
files = append(files, pageFile)
|
|
||||||
|
|
||||||
return template.Must(
|
return template.Must(
|
||||||
template.New(pageFile).ParseFS(templates.Templates, files...),
|
template.ParseFS(templates.Templates, files...),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,31 +145,21 @@ func New(
|
|||||||
s.mw = params.Middleware
|
s.mw = params.Middleware
|
||||||
s.notifier = params.Notifier
|
s.notifier = params.Notifier
|
||||||
s.archives = params.Archives
|
s.archives = params.Archives
|
||||||
s.breakers = params.CircuitBreakers
|
|
||||||
s.mtr = params.Metrics
|
s.mtr = params.Metrics
|
||||||
s.ssrf = params.SSRFGuard
|
s.ssrf = params.SSRFGuard
|
||||||
|
|
||||||
// Parse all page templates once at startup
|
// Parse all page templates once at startup
|
||||||
s.templates = map[string]*template.Template{
|
s.templates = map[string]*template.Template{
|
||||||
"login.html": parsePageTemplate("login.html"),
|
"login.html": parsePageTemplate("login.html"),
|
||||||
"profile.html": parsePageTemplate("profile.html"),
|
"profile.html": parsePageTemplate("profile.html"),
|
||||||
"settings.html": parsePageTemplate("settings.html"),
|
"settings.html": parsePageTemplate("settings.html"),
|
||||||
"sources_list.html": parsePageTemplate("sources_list.html"),
|
"sources_list.html": parsePageTemplate("sources_list.html"),
|
||||||
"sources_new.html": parsePageTemplate("sources_new.html"),
|
"sources_new.html": parsePageTemplate("sources_new.html"),
|
||||||
"source_detail.html": parsePageTemplate(
|
"source_detail.html": parsePageTemplate("source_detail.html", "webhook_stats.html"),
|
||||||
"source_detail.html", "webhook_stats.html", "event_body.html",
|
"source_edit.html": parsePageTemplate("source_edit.html"),
|
||||||
),
|
"source_logs.html": parsePageTemplate("source_logs.html"),
|
||||||
"source_edit.html": parsePageTemplate("source_edit.html"),
|
"target_edit.html": parsePageTemplate("target_edit.html"),
|
||||||
"source_logs.html": parsePageTemplate(
|
"error.html": parsePageTemplate("error.html"),
|
||||||
"source_logs.html", "event_request.html", "event_body.html",
|
|
||||||
"delivery_row.html", "delivery_attempts.html",
|
|
||||||
),
|
|
||||||
"event_detail.html": parsePageTemplate(
|
|
||||||
"event_detail.html", "event_request.html", "event_body.html",
|
|
||||||
"delivery_row.html", "delivery_attempts.html",
|
|
||||||
),
|
|
||||||
"target_edit.html": parsePageTemplate("target_edit.html"),
|
|
||||||
"error.html": parsePageTemplate("error.html"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
@@ -402,7 +386,7 @@ func (s *Handlers) pageData(
|
|||||||
// partial body and the status before a mid-render error can be
|
// partial body and the status before a mid-render error can be
|
||||||
// reported, leaving no way to serve a 500. Buffering makes a page's
|
// reported, leaving no way to serve a 500. Buffering makes a page's
|
||||||
// rendered size resident memory per concurrent viewer, so every page
|
// rendered size resident memory per concurrent viewer, so every page
|
||||||
// owes it a bound: the lists of events cap each stored body at
|
// owes it a bound: the event log caps each stored body at
|
||||||
// maxRenderedBodyBytes for exactly this reason.
|
// maxRenderedBodyBytes for exactly this reason.
|
||||||
func (s *Handlers) executeTemplate(
|
func (s *Handlers) executeTemplate(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -182,40 +181,6 @@ func (r *recordingArchives) Renames() []archiveRename {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// testCircuitBreakers is a delivery.CircuitBreakers that reports, for
|
|
||||||
// each target, the circuit state and cooldown a test gave it with Set,
|
|
||||||
// and a closed breaker for any other target.
|
|
||||||
type testCircuitBreakers struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
states map[string]delivery.CircuitState
|
|
||||||
cooldowns map[string]time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set makes the target's breaker read as state, with cooldown left.
|
|
||||||
func (b *testCircuitBreakers) Set(
|
|
||||||
targetID string, state delivery.CircuitState, cooldown time.Duration,
|
|
||||||
) {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
|
|
||||||
if b.states == nil {
|
|
||||||
b.states = map[string]delivery.CircuitState{}
|
|
||||||
b.cooldowns = map[string]time.Duration{}
|
|
||||||
}
|
|
||||||
|
|
||||||
b.states[targetID] = state
|
|
||||||
b.cooldowns[targetID] = cooldown
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *testCircuitBreakers) StateAndCooldown(
|
|
||||||
targetID string,
|
|
||||||
) (delivery.CircuitState, time.Duration) {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
|
|
||||||
return b.states[targetID], b.cooldowns[targetID]
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTestApp returns an app whose RequireStart fails the test when
|
// newTestApp returns an app whose RequireStart fails the test when
|
||||||
// starting takes longer than fx's default start timeout of 15s. That
|
// starting takes longer than fx's default start timeout of 15s. That
|
||||||
// limit catches a start that hangs, not a busy host: measured with make
|
// limit catches a start that hangs, not a busy host: measured with make
|
||||||
@@ -266,12 +231,6 @@ func newTestAppWithConfig(
|
|||||||
func(r *recordingArchives) delivery.Archives {
|
func(r *recordingArchives) delivery.Archives {
|
||||||
return r
|
return r
|
||||||
},
|
},
|
||||||
func() *testCircuitBreakers {
|
|
||||||
return &testCircuitBreakers{}
|
|
||||||
},
|
|
||||||
func(b *testCircuitBreakers) delivery.CircuitBreakers {
|
|
||||||
return b
|
|
||||||
},
|
|
||||||
metrics.NewRegistry,
|
metrics.NewRegistry,
|
||||||
metrics.New,
|
metrics.New,
|
||||||
middleware.New,
|
middleware.New,
|
||||||
@@ -477,37 +436,23 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
|
|||||||
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
|
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// Empty expiry and rotation: the keep-forever, one-file default,
|
// Empty expiry: the keep-forever default, empty config.
|
||||||
// empty config.
|
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("")
|
||||||
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("", "")
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Empty(t, errMsg)
|
assert.Empty(t, errMsg)
|
||||||
assert.Empty(t, cfg)
|
assert.Empty(t, cfg)
|
||||||
|
|
||||||
// Explicit never is stored as config.
|
// Explicit never is stored as config.
|
||||||
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("never", "")
|
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("never")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Empty(t, errMsg)
|
assert.Empty(t, errMsg)
|
||||||
assert.JSONEq(t, `{"expiry":"never"}`, cfg)
|
assert.JSONEq(t, `{"expiry":"never"}`, cfg)
|
||||||
|
|
||||||
// A positive duration is stored as config.
|
// A positive duration is stored as config.
|
||||||
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("720h", "")
|
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("720h")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Empty(t, errMsg)
|
assert.Empty(t, errMsg)
|
||||||
assert.JSONEq(t, `{"expiry":"720h"}`, cfg)
|
assert.JSONEq(t, `{"expiry":"720h"}`, cfg)
|
||||||
|
|
||||||
// A rotation is stored as config, with or without an expiry.
|
|
||||||
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("", "daily")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, errMsg)
|
|
||||||
assert.JSONEq(t, `{"rotation":"daily"}`, cfg)
|
|
||||||
|
|
||||||
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest(
|
|
||||||
"720h", "hourly",
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, errMsg)
|
|
||||||
assert.JSONEq(t, `{"expiry":"720h","rotation":"hourly"}`, cfg)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
|
func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
|
||||||
@@ -516,7 +461,7 @@ func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, bad := range []string{"nonsense", "7d", "-5h"} {
|
for _, bad := range []string{"nonsense", "7d", "-5h"} {
|
||||||
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest(bad, "")
|
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest(bad)
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
@@ -526,20 +471,3 @@ func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
|
|||||||
assert.Empty(t, cfg)
|
assert.Empty(t, cfg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildDatabaseTargetConfig_RejectsBadRotation(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, bad := range []string{"weekly", "Daily", " none"} {
|
|
||||||
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("", bad)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(
|
|
||||||
t, errMsg, "Invalid archive rotation",
|
|
||||||
"rotation %q should be refused", bad,
|
|
||||||
)
|
|
||||||
assert.Empty(t, cfg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ const (
|
|||||||
webhookSaved noticeCode = "webhook-saved"
|
webhookSaved noticeCode = "webhook-saved"
|
||||||
webhookDeleted noticeCode = "webhook-deleted"
|
webhookDeleted noticeCode = "webhook-deleted"
|
||||||
entrypointAdded noticeCode = "entrypoint-added"
|
entrypointAdded noticeCode = "entrypoint-added"
|
||||||
entrypointSaved noticeCode = "entrypoint-saved"
|
|
||||||
entrypointDeleted noticeCode = "entrypoint-deleted"
|
entrypointDeleted noticeCode = "entrypoint-deleted"
|
||||||
entrypointActivated noticeCode = "entrypoint-activated"
|
entrypointActivated noticeCode = "entrypoint-activated"
|
||||||
entrypointDeactivated noticeCode = "entrypoint-deactivated"
|
entrypointDeactivated noticeCode = "entrypoint-deactivated"
|
||||||
@@ -49,7 +48,6 @@ func noticeFor(r *http.Request) *notice {
|
|||||||
webhookSaved: {Text: "Webhook saved."},
|
webhookSaved: {Text: "Webhook saved."},
|
||||||
webhookDeleted: {Text: "Webhook deleted."},
|
webhookDeleted: {Text: "Webhook deleted."},
|
||||||
entrypointAdded: {Text: "Entrypoint added."},
|
entrypointAdded: {Text: "Entrypoint added."},
|
||||||
entrypointSaved: {Text: "Entrypoint description saved."},
|
|
||||||
entrypointDeleted: {Text: "Entrypoint deleted."},
|
entrypointDeleted: {Text: "Entrypoint deleted."},
|
||||||
entrypointActivated: {Text: "Entrypoint activated."},
|
entrypointActivated: {Text: "Entrypoint activated."},
|
||||||
entrypointDeactivated: {Text: "Entrypoint deactivated."},
|
entrypointDeactivated: {Text: "Entrypoint deactivated."},
|
||||||
@@ -66,8 +64,7 @@ func noticeFor(r *http.Request) *notice {
|
|||||||
},
|
},
|
||||||
replayTargetDeleted: {
|
replayTargetDeleted: {
|
||||||
Text: "Not replayed: the target this delivery was for " +
|
Text: "Not replayed: the target this delivery was for " +
|
||||||
"has been deleted. Use Resubmit to send the event " +
|
"has been deleted. Recreate the target, then replay.",
|
||||||
"to the webhook's currently active targets.",
|
|
||||||
Failed: true,
|
Failed: true,
|
||||||
},
|
},
|
||||||
replayTargetMissing: {
|
replayTargetMissing: {
|
||||||
@@ -96,7 +93,7 @@ func noticeFor(r *http.Request) *notice {
|
|||||||
},
|
},
|
||||||
resubmitNoTargets: {
|
resubmitNoTargets: {
|
||||||
Text: "Resubmitted: a new event was created, but this " +
|
Text: "Resubmitted: a new event was created, but this " +
|
||||||
"webhook has no active targets, so nothing was queued.",
|
"source has no active targets, so nothing was queued.",
|
||||||
},
|
},
|
||||||
}[noticeCode(r.URL.Query().Get(noticeParam))]
|
}[noticeCode(r.URL.Query().Get(noticeParam))]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"html/template"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
|
||||||
"sneak.berlin/go/webhooker/templates"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestEveryPageRendersItsOwnTitle renders each page template and checks
|
|
||||||
// the browser tab title is the one the page declares, not the
|
|
||||||
// "Webhooker" fallback in htmlheader.html. A page that fails to render
|
|
||||||
// shows the error page's title instead, and fails here too.
|
|
||||||
func TestEveryPageRendersItsOwnTitle(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var h *handlers.Handlers
|
|
||||||
|
|
||||||
var sess *session.Session
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
// A pointer, as in the handlers: some pages call
|
|
||||||
// Webhook.RetentionLabel, a pointer method.
|
|
||||||
webhook := &database.Webhook{Name: "orders", RetentionDays: 14}
|
|
||||||
webhook.ID = testWebhookID
|
|
||||||
|
|
||||||
pages := []struct {
|
|
||||||
page string
|
|
||||||
data map[string]any
|
|
||||||
title string
|
|
||||||
}{
|
|
||||||
{"login.html", map[string]any{}, "Sign in - Webhooker"},
|
|
||||||
{"profile.html", map[string]any{}, "Profile - Webhooker"},
|
|
||||||
{"settings.html", map[string]any{}, "Settings - Webhooker"},
|
|
||||||
{"sources_list.html", map[string]any{}, "Webhooks - Webhooker"},
|
|
||||||
{"sources_new.html", map[string]any{}, "New Webhook - Webhooker"},
|
|
||||||
{
|
|
||||||
"source_detail.html",
|
|
||||||
map[string]any{dataKeyWebhook: webhook},
|
|
||||||
"orders - Webhooker",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source_edit.html",
|
|
||||||
map[string]any{dataKeyWebhook: webhook},
|
|
||||||
"Edit orders - Webhooker",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source_logs.html",
|
|
||||||
map[string]any{
|
|
||||||
dataKeyWebhook: webhook,
|
|
||||||
dataKeyEvents: []handlers.EventLogView{},
|
|
||||||
"TotalEvents": int64(0),
|
|
||||||
},
|
|
||||||
"Full Event Log - orders - Webhooker",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"event_detail.html",
|
|
||||||
map[string]any{dataKeyWebhook: webhook},
|
|
||||||
"Event - orders - Webhooker",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"target_edit.html",
|
|
||||||
map[string]any{
|
|
||||||
dataKeyWebhook: webhook,
|
|
||||||
"Target": map[string]any{"Name": "alerts", "Type": "slack"},
|
|
||||||
},
|
|
||||||
"Edit alerts - Webhooker",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"error.html",
|
|
||||||
map[string]any{"StatusText": http.StatusText(http.StatusNotFound)},
|
|
||||||
"Not Found - Webhooker",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, p := range pages {
|
|
||||||
body := renderPage(t, h, sess, p.page, p.data)
|
|
||||||
|
|
||||||
_, afterOpen, _ := strings.Cut(body, "<title>")
|
|
||||||
title, _, _ := strings.Cut(afterOpen, "</title>")
|
|
||||||
|
|
||||||
assert.Equal(t, p.title, title, p.page)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTitleFallbackIsWebhooker checks the title htmlheader.html gives a
|
|
||||||
// page that declares none. Every page declares one, so it is checked on
|
|
||||||
// htmlheader.html alone.
|
|
||||||
func TestTitleFallbackIsWebhooker(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
header := template.Must(
|
|
||||||
template.ParseFS(templates.Templates, "htmlheader.html"),
|
|
||||||
)
|
|
||||||
|
|
||||||
var buf strings.Builder
|
|
||||||
|
|
||||||
require.NoError(t, header.ExecuteTemplate(&buf, "htmlheader", nil))
|
|
||||||
assert.Contains(t, buf.String(), "<title>Webhooker</title>")
|
|
||||||
}
|
|
||||||
@@ -12,12 +12,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// recentEventColumns is the recent events list's projection. It
|
// recentEventColumns is the recent events list's projection. It
|
||||||
// reads the body cut to maxRenderedBodyBytes, as eventLogColumns
|
// leaves out the body, for the reason maxRenderedBodyBytes gives,
|
||||||
// does, and its size from body_bytes, recorded when the event was
|
// and reads its size from body_bytes, recorded when the event was
|
||||||
// stored.
|
// stored.
|
||||||
const recentEventColumns = "id, created_at, method, content_type, " +
|
const recentEventColumns = "id, created_at, method, content_type, " +
|
||||||
"resubmitted_from_id, body_bytes, " +
|
"resubmitted_from_id, body_bytes"
|
||||||
"substr(cast(body as blob), 1, ?) AS body"
|
|
||||||
|
|
||||||
// recentAttemptColumns is the part of a recorded attempt the list
|
// recentAttemptColumns is the part of a recorded attempt the list
|
||||||
// uses. The event log's deliveryResultColumns also reads response
|
// uses. The event log's deliveryResultColumns also reads response
|
||||||
@@ -51,9 +50,6 @@ type RecentEventView struct {
|
|||||||
// unless the webhook has exactly one HTTP target.
|
// unless the webhook has exactly one HTTP target.
|
||||||
Status string
|
Status string
|
||||||
StatusClass string
|
StatusClass string
|
||||||
|
|
||||||
// Body is what the row shows when it is expanded.
|
|
||||||
Body BodyView
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// recentEventRow is one row of recentEventColumns.
|
// recentEventRow is one row of recentEventColumns.
|
||||||
@@ -64,7 +60,6 @@ type recentEventRow struct {
|
|||||||
ContentType string
|
ContentType string
|
||||||
ResubmittedFromID *string
|
ResubmittedFromID *string
|
||||||
BodyBytes uint64
|
BodyBytes uint64
|
||||||
Body []byte
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// recentAttemptRow is one row of recentAttemptColumns. CreatedAt is
|
// recentAttemptRow is one row of recentAttemptColumns. CreatedAt is
|
||||||
@@ -105,7 +100,7 @@ func loadRecentEvents(
|
|||||||
var rows []recentEventRow
|
var rows []recentEventRow
|
||||||
|
|
||||||
err := webhookDB.Model(&database.Event{}).
|
err := webhookDB.Model(&database.Event{}).
|
||||||
Select(recentEventColumns, maxRenderedBodyBytes).
|
Select(recentEventColumns).
|
||||||
Where("webhook_id = ?", webhookID).
|
Where("webhook_id = ?", webhookID).
|
||||||
Order("created_at DESC").
|
Order("created_at DESC").
|
||||||
Limit(recentEventLimit).
|
Limit(recentEventLimit).
|
||||||
@@ -150,7 +145,7 @@ func loadRecentEvents(
|
|||||||
views := make([]RecentEventView, len(rows))
|
views := make([]RecentEventView, len(rows))
|
||||||
for i := range rows {
|
for i := range rows {
|
||||||
views[i] = rows[i].view(
|
views[i] = rows[i].view(
|
||||||
webhookID, byEvent[rows[i].ID], attempts, statusTargetID,
|
byEvent[rows[i].ID], attempts, statusTargetID,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,20 +182,14 @@ func loadRecentAttempts(
|
|||||||
return byDelivery, nil
|
return byDelivery, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// view projects a loaded row of the webhook's events for
|
// view projects a loaded row for rendering. deliveries is the
|
||||||
// rendering. deliveries is the event's deliveries, oldest first,
|
// event's deliveries, oldest first, and attempts their recorded
|
||||||
// and attempts their recorded attempts keyed by delivery ID.
|
// attempts keyed by delivery ID.
|
||||||
func (r *recentEventRow) view(
|
func (r *recentEventRow) view(
|
||||||
webhookID string,
|
|
||||||
deliveries []database.Delivery,
|
deliveries []database.Delivery,
|
||||||
attempts map[string][]recentAttemptRow,
|
attempts map[string][]recentAttemptRow,
|
||||||
statusTargetID string,
|
statusTargetID string,
|
||||||
) RecentEventView {
|
) RecentEventView {
|
||||||
//nolint:gosec // body_bytes is at most the receiver's 1 MB cap
|
|
||||||
body := newBodyView(
|
|
||||||
"/hook/"+webhookID+"/events/"+r.ID, r.Body, int64(r.BodyBytes),
|
|
||||||
)
|
|
||||||
|
|
||||||
v := RecentEventView{
|
v := RecentEventView{
|
||||||
Method: r.Method,
|
Method: r.Method,
|
||||||
ContentType: r.ContentType,
|
ContentType: r.ContentType,
|
||||||
@@ -208,7 +197,6 @@ func (r *recentEventRow) view(
|
|||||||
ReceivedUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
|
ReceivedUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
|
||||||
Size: humanize.Bytes(r.BodyBytes),
|
Size: humanize.Bytes(r.BodyBytes),
|
||||||
ProcessingTime: processingTime(deliveries, attempts),
|
ProcessingTime: processingTime(deliveries, attempts),
|
||||||
Body: body,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.ResubmittedFromID != nil {
|
if r.ResubmittedFromID != nil {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -302,73 +301,6 @@ func TestHandleSourceDetail_NoStatusWithoutSingleHTTPTarget(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleSourceDetail_RecentEventsLinkAndExpand proves each row
|
|
||||||
// links to its event's own page and expands to show its body, and
|
|
||||||
// that only the newest row starts expanded.
|
|
||||||
func TestHandleSourceDetail_RecentEventsLinkAndExpand(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newRecentEventsFixture(t)
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
older := f.event(
|
|
||||||
t, contentTypeJSON, `{"which":"older"}`, now.Add(-time.Minute),
|
|
||||||
)
|
|
||||||
newer := f.event(t, contentTypeJSON, `{"which":"newer"}`, now)
|
|
||||||
|
|
||||||
body := f.render(t)
|
|
||||||
|
|
||||||
for _, e := range []*database.Event{older, newer} {
|
|
||||||
assert.Contains(
|
|
||||||
t, body, `href="/hook/`+f.webhook.ID+`/events/`+e.ID+`"`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, 2, strings.Count(body, `<div x-show="open" x-cloak class="mt-3">`))
|
|
||||||
assert.Equal(t, 1, strings.Count(body, " data-open>"))
|
|
||||||
|
|
||||||
open := strings.Index(body, " data-open>")
|
|
||||||
newerBody := strings.Index(body, ""which": "newer"")
|
|
||||||
olderBody := strings.Index(body, ""which": "older"")
|
|
||||||
|
|
||||||
assert.Less(t, open, newerBody, "the newest row is not the open one")
|
|
||||||
assert.Less(t, newerBody, olderBody)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceDetail_RecentEventBodyCut proves a body up to
|
|
||||||
// the cap is shown whole and pretty-printed, and a larger one only
|
|
||||||
// its first bodyCap bytes, as received, with links to the whole
|
|
||||||
// body on the event's page and to the download.
|
|
||||||
func TestHandleSourceDetail_RecentEventBodyCut(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newRecentEventsFixture(t)
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
// A JSON document of n bytes.
|
|
||||||
document := func(n int) string {
|
|
||||||
return `{"pad":"` + strings.Repeat("x", n-len(`{"pad":""}`)) + `"}`
|
|
||||||
}
|
|
||||||
|
|
||||||
whole := f.event(
|
|
||||||
t, contentTypeJSON, document(bodyCap), now.Add(-time.Minute),
|
|
||||||
)
|
|
||||||
cut := f.event(t, contentTypeJSON, document(bodyCap+1), now)
|
|
||||||
|
|
||||||
body := f.render(t)
|
|
||||||
eventURL := `href="/hook/` + f.webhook.ID + `/events/`
|
|
||||||
|
|
||||||
assert.Equal(t, 1, strings.Count(body, "{\n "pad": "))
|
|
||||||
assert.Contains(t, body, "{"pad":"xxx")
|
|
||||||
assert.Contains(
|
|
||||||
t, body,
|
|
||||||
"Showing the first "+strconv.Itoa(bodyCap)+" of "+
|
|
||||||
strconv.Itoa(bodyCap+1)+" bytes, unformatted.",
|
|
||||||
)
|
|
||||||
assert.Contains(t, body, eventURL+cut.ID+`/body"`)
|
|
||||||
assert.NotContains(t, body, eventURL+whole.ID+`/body"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleWebhook_RecordsBodySize proves the receiver records the
|
// TestHandleWebhook_RecordsBodySize proves the receiver records the
|
||||||
// body's size in bytes, not characters, with the event it stores.
|
// body's size in bytes, not characters, with the event it stores.
|
||||||
func TestHandleWebhook_RecordsBodySize(t *testing.T) {
|
func TestHandleWebhook_RecordsBodySize(t *testing.T) {
|
||||||
|
|||||||
@@ -1,209 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"html"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
// submitCreateForm posts the new webhook form and returns the
|
|
||||||
// recorder.
|
|
||||||
func submitCreateForm(
|
|
||||||
env *sourceTestEnv, form url.Values,
|
|
||||||
) *httptest.ResponseRecorder {
|
|
||||||
req := formRequest("/hooks/new", env.cookies, form, nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
env.handlers.HandleSourceCreateSubmit().ServeHTTP(w, req)
|
|
||||||
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertNothingCreated checks that the main database holds no webhook,
|
|
||||||
// entrypoint or target.
|
|
||||||
func assertNothingCreated(t *testing.T, db *database.Database) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
for _, model := range []any{
|
|
||||||
&database.Webhook{}, &database.Entrypoint{}, &database.Target{},
|
|
||||||
} {
|
|
||||||
var count int64
|
|
||||||
|
|
||||||
require.NoError(t, db.DB().Model(model).Count(&count).Error)
|
|
||||||
assert.Zerof(t, count, "%T rows were created", model)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceCreateSubmit_CreatesRequestedTargets submits the new
|
|
||||||
// webhook form with the HTTP target URL filled in or empty, and with
|
|
||||||
// the archive checkbox off or on with each pruning choice. The webhook
|
|
||||||
// gets an HTTP target only for a URL and a database target only for a
|
|
||||||
// checked archive. The pruning choice is always submitted, as the
|
|
||||||
// browser submits it while it is hidden, and is ignored when archive
|
|
||||||
// is off.
|
|
||||||
func TestHandleSourceCreateSubmit_CreatesRequestedTargets(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
|
|
||||||
// Each value the archive pruning choice submits, after an empty
|
|
||||||
// one that stands for the archive checkbox left off.
|
|
||||||
expiries := []string{
|
|
||||||
"", "never", "1h", "12h", "24h", "720h", "2160h", "8760h",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, httpURL := range []string{"", editOriginalURL} {
|
|
||||||
for _, expiry := range expiries {
|
|
||||||
name := "url=" + httpURL + " archive=" + expiry
|
|
||||||
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("name", name)
|
|
||||||
form.Set("http_url", httpURL)
|
|
||||||
form.Set("archive_expiry", "720h")
|
|
||||||
|
|
||||||
if expiry != "" {
|
|
||||||
form.Set("archive", "on")
|
|
||||||
form.Set("archive_expiry", expiry)
|
|
||||||
}
|
|
||||||
|
|
||||||
w := submitCreateForm(env, form)
|
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
|
||||||
|
|
||||||
var webhook database.Webhook
|
|
||||||
|
|
||||||
require.NoError(t, env.db.DB().
|
|
||||||
Where("name = ?", name).First(&webhook).Error)
|
|
||||||
|
|
||||||
byType := map[database.TargetType]database.Target{}
|
|
||||||
for _, target := range targetsForWebhook(t, env.db, webhook.ID) {
|
|
||||||
byType[target.Type] = target
|
|
||||||
}
|
|
||||||
|
|
||||||
wantCount := 0
|
|
||||||
|
|
||||||
if httpURL != "" {
|
|
||||||
wantCount++
|
|
||||||
|
|
||||||
assert.Equal(t, "HTTP", byType[database.TargetTypeHTTP].Name)
|
|
||||||
assert.JSONEq(t, `{"url":"`+httpURL+`"}`,
|
|
||||||
byType[database.TargetTypeHTTP].Config)
|
|
||||||
}
|
|
||||||
|
|
||||||
if expiry != "" {
|
|
||||||
wantCount++
|
|
||||||
|
|
||||||
assert.Equal(t, "Archive",
|
|
||||||
byType[database.TargetTypeDatabase].Name)
|
|
||||||
assert.JSONEq(t, `{"expiry":"`+expiry+`"}`,
|
|
||||||
byType[database.TargetTypeDatabase].Config)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Len(t, byType, wantCount)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceCreateSubmit_RefusedFormKeepsEveryValue refuses the
|
|
||||||
// new webhook form for an invalid HTTP target URL and for an invalid
|
|
||||||
// retention, each with archive on. Nothing is created, and the form
|
|
||||||
// comes back with the reason and every value entered: name,
|
|
||||||
// description, retention, URL, the checked archive box and the pruning
|
|
||||||
// and rotation choices.
|
|
||||||
func TestHandleSourceCreateSubmit_RefusedFormKeepsEveryValue(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const badURL = "Invalid target URL"
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
retention string
|
|
||||||
httpURL string
|
|
||||||
reason string
|
|
||||||
}{
|
|
||||||
{"blocked url", "7", editBlockedURL, badURL},
|
|
||||||
{"unsupported scheme", "7", "ftp://93.184.216.34/hook", badURL},
|
|
||||||
{"bad retention", "-5", editOriginalURL, "Retention must be"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("name", "kept name")
|
|
||||||
form.Set("description", "kept description")
|
|
||||||
form.Set("retention_days", tc.retention)
|
|
||||||
form.Set("http_url", tc.httpURL)
|
|
||||||
form.Set("archive", "on")
|
|
||||||
form.Set("archive_expiry", "2160h")
|
|
||||||
form.Set("archive_rotation", "hourly")
|
|
||||||
|
|
||||||
w := submitCreateForm(env, form)
|
|
||||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
||||||
|
|
||||||
page := w.Body.String()
|
|
||||||
assert.Contains(t, page, tc.reason)
|
|
||||||
assert.Contains(t, page, `value="kept name"`)
|
|
||||||
assert.Contains(t, page, `>kept description</textarea>`)
|
|
||||||
assert.Contains(t, page, `value="`+tc.retention+`"`)
|
|
||||||
assert.Contains(t, page,
|
|
||||||
`value="`+html.EscapeString(tc.httpURL)+`"`)
|
|
||||||
assert.Contains(t, page, `name="archive" value="on" checked`)
|
|
||||||
assert.Contains(t, page, `x-data="collapsible" data-open`)
|
|
||||||
assert.Contains(t, page, `<option value="2160h" selected>`)
|
|
||||||
assert.Contains(t, page, `<option value="hourly" selected>`)
|
|
||||||
|
|
||||||
assertNothingCreated(t, env.db)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// errInjectedTargetCreate is the failure a test makes the insert of a
|
|
||||||
// target report.
|
|
||||||
var errInjectedTargetCreate = errors.New("injected target create failure")
|
|
||||||
|
|
||||||
// TestHandleSourceCreateSubmit_FailedTargetInsertCreatesNothing makes
|
|
||||||
// inserting a target fail after the webhook and its entrypoint were
|
|
||||||
// inserted, and checks that neither is left behind.
|
|
||||||
func TestHandleSourceCreateSubmit_FailedTargetInsertCreatesNothing(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
|
|
||||||
require.NoError(t, env.db.DB().Callback().Create().
|
|
||||||
Before("gorm:create").
|
|
||||||
Register("test:fail_target_create", func(tx *gorm.DB) {
|
|
||||||
if tx.Statement.Table == "targets" {
|
|
||||||
_ = tx.AddError(errInjectedTargetCreate)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("name", "rolled back")
|
|
||||||
form.Set("archive", "on")
|
|
||||||
form.Set("archive_expiry", "never")
|
|
||||||
|
|
||||||
w := submitCreateForm(env, form)
|
|
||||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
|
||||||
|
|
||||||
assertNothingCreated(t, env.db)
|
|
||||||
}
|
|
||||||
@@ -86,13 +86,12 @@ var errInjectedDelete = errors.New("injected delete failure")
|
|||||||
// save of an existing row.
|
// save of an existing row.
|
||||||
var errInjectedSave = errors.New("injected save failure")
|
var errInjectedSave = errors.New("injected save failure")
|
||||||
|
|
||||||
// seedEntrypoint inserts an active entrypoint for a webhook and
|
// seedEntrypoint inserts an entrypoint for a webhook.
|
||||||
// returns it.
|
|
||||||
func seedEntrypoint(
|
func seedEntrypoint(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
db *database.Database,
|
db *database.Database,
|
||||||
webhookID string,
|
webhookID string,
|
||||||
) *database.Entrypoint {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
ep := &database.Entrypoint{
|
ep := &database.Entrypoint{
|
||||||
@@ -105,8 +104,6 @@ func seedEntrypoint(
|
|||||||
t,
|
t,
|
||||||
db.DB().Omit(clause.Associations).Create(ep).Error,
|
db.DB().Omit(clause.Associations).Create(ep).Error,
|
||||||
)
|
)
|
||||||
|
|
||||||
return ep
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// countRows counts the live (not soft-deleted) rows of a model
|
// countRows counts the live (not soft-deleted) rows of a model
|
||||||
|
|||||||
@@ -233,13 +233,8 @@ func TestHandleSourceDetail_RendersNamedTargetFields(
|
|||||||
assert.Contains(t, body, "1 configured")
|
assert.Contains(t, body, "1 configured")
|
||||||
assert.NotContains(t, body, "sekrit")
|
assert.NotContains(t, body, "sekrit")
|
||||||
|
|
||||||
// The database type is called an archive: on its badge, in the
|
assert.Contains(t, body, "Archive Expiry")
|
||||||
// add target form's type list and in its settings.
|
assert.Contains(t, body, "720h")
|
||||||
list := targetList(t, body)
|
|
||||||
assert.Contains(t, list, "t-database archive Active")
|
|
||||||
assert.Contains(t, list, "Archive expiry: 30 days")
|
|
||||||
assert.Contains(t, list, "Archive rotation: none")
|
|
||||||
assert.Contains(t, body, `<option value="database">Archive</option>`)
|
|
||||||
|
|
||||||
// An unknown type gets the neutral placeholder, never the
|
// An unknown type gets the neutral placeholder, never the
|
||||||
// stored blob.
|
// stored blob.
|
||||||
@@ -280,99 +275,3 @@ func TestHandleSourceDetail_FitsWideAndNarrowWindows(t *testing.T) {
|
|||||||
`<div class="flex flex-wrap justify-between items-center gap-2 mt-2">`,
|
`<div class="flex flex-wrap justify-between items-center gap-2 mt-2">`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleSourceDetail_DeletePromptsNameWhatIsLost checks that each
|
|
||||||
// delete prompt on the webhook page names the webhook, entrypoint or
|
|
||||||
// target and says what deleting it loses, that the webhook's gives its
|
|
||||||
// number of stored events (5 received, 2 removed by retention, so 3,
|
|
||||||
// the statistics pane's "Within retention" figure), and that an
|
|
||||||
// entrypoint with no description is named by its URL. The template
|
|
||||||
// writes the slashes after http: as \/, which the browser reads as /.
|
|
||||||
func TestHandleSourceDetail_DeletePromptsNameWhatIsLost(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)
|
|
||||||
|
|
||||||
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, database.AddEventTotals(
|
|
||||||
webhookDB, database.EventTotals{Events: 5, EventsRemoved: 2},
|
|
||||||
))
|
|
||||||
|
|
||||||
unnamed := seedEntrypoint(t, db, wh.ID)
|
|
||||||
require.NoError(t, db.DB().Omit(clause.Associations).Create(
|
|
||||||
&database.Entrypoint{
|
|
||||||
WebhookID: wh.ID,
|
|
||||||
Path: "described-" + wh.ID,
|
|
||||||
Description: "Stripe",
|
|
||||||
Active: true,
|
|
||||||
},
|
|
||||||
).Error)
|
|
||||||
seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
|
||||||
|
|
||||||
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
|
||||||
|
|
||||||
assert.Contains(t, body,
|
|
||||||
`Delete webhook "delete-me"?\n\n`+
|
|
||||||
`This deletes its stored events (3) and their deliveries. `+
|
|
||||||
`Any archive files it wrote are kept.`)
|
|
||||||
assert.Contains(t, body,
|
|
||||||
`Delete entrypoint "Stripe"?\n\n`+
|
|
||||||
`Senders using its URL get an error from now on, `+
|
|
||||||
`and the URL cannot be restored.`)
|
|
||||||
assert.Contains(t, body,
|
|
||||||
`Delete entrypoint "http:\/\/example.com/h/`+
|
|
||||||
unnamed.Path+`"?`)
|
|
||||||
assert.Contains(t, body,
|
|
||||||
`Delete target "t-log"?\n\n`+
|
|
||||||
`Nothing more is delivered to it. `+
|
|
||||||
`Its past deliveries stay in the event log.`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceDetail_DeletePromptKeepsQuotesInName checks that a
|
|
||||||
// webhook name with quotes, a backslash, a closing script tag and a
|
|
||||||
// newline reaches its delete prompt escaped for the script, which the
|
|
||||||
// browser reads back as the name typed: each quote and angle bracket
|
|
||||||
// as a \u escape, the slash as \/, the newline as \n and the backslash
|
|
||||||
// doubled. An unescaped newline would break the prompt's script, and
|
|
||||||
// the form would then submit without asking.
|
|
||||||
func TestHandleSourceDetail_DeletePromptKeepsQuotesInName(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
wh := &database.Webhook{
|
|
||||||
UserID: deleteTestUserID,
|
|
||||||
Name: "Bob's \"best\" \\ hook</script>\nline two",
|
|
||||||
}
|
|
||||||
require.NoError(
|
|
||||||
t, db.DB().Omit(clause.Associations).Create(wh).Error,
|
|
||||||
)
|
|
||||||
|
|
||||||
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
|
||||||
|
|
||||||
assert.Contains(t, body,
|
|
||||||
"Delete webhook "Bob\\u0027s \\u0022best\\u0022 \\\\ hook"+
|
|
||||||
"\\u003c\\/script\\u003e\\nline two"?")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -94,44 +94,6 @@ func TestHandleSourceLogs_NamesDeletedTarget(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleSourceLogs_OffersNoReplayForDeletedTarget proves a
|
|
||||||
// finished delivery offers Replay while its target lives and not
|
|
||||||
// once the target is deleted. A replay to a deleted target is always
|
|
||||||
// refused, and recreating the target makes a new one that the old
|
|
||||||
// delivery does not name.
|
|
||||||
func TestHandleSourceLogs_OffersNoReplayForDeletedTarget(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 := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
|
||||||
|
|
||||||
_, failed := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
|
|
||||||
replayForm := `action="/hook/` + wh.ID + `/deliveries/` +
|
|
||||||
failed.ID + `/replay"`
|
|
||||||
|
|
||||||
before := renderSourceLogsPage(t, h, sess, wh.ID)
|
|
||||||
assert.Contains(t, before, replayForm)
|
|
||||||
|
|
||||||
deleteTargetThroughHandler(t, h, sess, wh.ID, tgt.ID)
|
|
||||||
|
|
||||||
after := renderSourceLogsPage(t, h, sess, wh.ID)
|
|
||||||
assert.NotContains(t, after, replayForm)
|
|
||||||
assert.NotContains(t, after, ">Replay<")
|
|
||||||
assert.Contains(t, after, tgt.Name+deletedMarker)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceLogs_MasksDeletedTargetConfig proves that
|
// TestHandleSourceLogs_MasksDeletedTargetConfig proves that
|
||||||
// naming a deleted target does not widen what the page shows of
|
// naming a deleted target does not widen what the page shows of
|
||||||
// it: its stored configuration stays masked by exactly the rules
|
// it: its stored configuration stays masked by exactly the rules
|
||||||
|
|||||||
@@ -2,13 +2,9 @@ package handlers_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -157,201 +153,3 @@ func TestHandleSourceLogs_MasksSlackWebhookURL(t *testing.T) {
|
|||||||
assert.Contains(t, body, tgt.Name)
|
assert.Contains(t, body, tgt.Name)
|
||||||
assert.Contains(t, body, "delivered")
|
assert.Contains(t, body, "delivered")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleSourceLogs_ShowsFiftyNewestEvents proves the event log
|
|
||||||
// holds the 50 newest events, newest first, and not one more, and says
|
|
||||||
// how many events there are in all.
|
|
||||||
func TestHandleSourceLogs_ShowsFiftyNewestEvents(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newRecentEventsFixture(t)
|
|
||||||
base := time.Now().Add(-time.Hour)
|
|
||||||
|
|
||||||
for i := range 51 {
|
|
||||||
f.event(
|
|
||||||
t, fmt.Sprintf("application/x-log-%02d", i), "{}",
|
|
||||||
base.Add(time.Duration(i)*time.Second),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
body := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
|
|
||||||
|
|
||||||
assert.Equal(t, 50, strings.Count(body, `role="button"`))
|
|
||||||
assert.NotContains(t, body, "application/x-log-00")
|
|
||||||
assert.Contains(t, body, "application/x-log-01")
|
|
||||||
assert.Less(
|
|
||||||
t,
|
|
||||||
strings.Index(body, "application/x-log-50"),
|
|
||||||
strings.Index(body, "application/x-log-49"),
|
|
||||||
)
|
|
||||||
assert.Contains(t, body, "50 most recent of 51 events")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceLogs_ShowsEventsByDeliveryStatus proves that the
|
|
||||||
// Failed list holds exactly the events with a failed delivery, the
|
|
||||||
// Pending list exactly those with a delivery pending or retrying, each
|
|
||||||
// once, and any other show value every event; that each link, and the
|
|
||||||
// line beside the heading, counts the events its list holds; and that
|
|
||||||
// the shown link is marked.
|
|
||||||
func TestHandleSourceLogs_ShowsEventsByDeliveryStatus(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newRecentEventsFixture(t)
|
|
||||||
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
const (
|
|
||||||
failed = database.DeliveryStatusFailed
|
|
||||||
delivered = database.DeliveryStatusDelivered
|
|
||||||
pending = database.DeliveryStatusPending
|
|
||||||
retrying = database.DeliveryStatusRetrying
|
|
||||||
)
|
|
||||||
|
|
||||||
// Each event is named by its content type. The first failed and
|
|
||||||
// was then replayed and delivered. The second failed, and so did
|
|
||||||
// its replay, and the fifth has one delivery pending and another
|
|
||||||
// retrying: each must still be listed and counted once.
|
|
||||||
events := []struct {
|
|
||||||
contentType string
|
|
||||||
deliveries []database.DeliveryStatus
|
|
||||||
}{
|
|
||||||
{"application/x-failed", []database.DeliveryStatus{failed, delivered}},
|
|
||||||
{"application/x-failed-twice", []database.DeliveryStatus{failed, failed}},
|
|
||||||
{"application/x-pending", []database.DeliveryStatus{pending}},
|
|
||||||
{"application/x-retrying", []database.DeliveryStatus{retrying}},
|
|
||||||
{"application/x-pending-retrying", []database.DeliveryStatus{pending, retrying}},
|
|
||||||
{"application/x-delivered", []database.DeliveryStatus{delivered}},
|
|
||||||
{"application/x-no-delivery", nil},
|
|
||||||
}
|
|
||||||
|
|
||||||
all := make([]string, len(events))
|
|
||||||
|
|
||||||
for i, e := range events {
|
|
||||||
event := f.event(
|
|
||||||
t, e.contentType, "{}", now.Add(time.Duration(i)*time.Second),
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, status := range e.deliveries {
|
|
||||||
f.delivery(t, event, target.ID, status)
|
|
||||||
}
|
|
||||||
|
|
||||||
all[i] = e.contentType
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
query string
|
|
||||||
current string
|
|
||||||
heading string
|
|
||||||
listed []string
|
|
||||||
}{
|
|
||||||
{"", "All", "7 total events", all},
|
|
||||||
{"?show=failed", "Failed (2)", "2 events with a failed delivery",
|
|
||||||
[]string{"application/x-failed", "application/x-failed-twice"}},
|
|
||||||
{"?show=pending", "Pending (3)",
|
|
||||||
"3 events with a delivery pending or retrying", []string{
|
|
||||||
"application/x-pending", "application/x-retrying",
|
|
||||||
"application/x-pending-retrying",
|
|
||||||
}},
|
|
||||||
{"?show=unknown", "All", "7 total events", all},
|
|
||||||
} {
|
|
||||||
body := renderSourceLogsPageWithQuery(
|
|
||||||
t, f.h, f.sess, f.webhook.ID, tc.query,
|
|
||||||
)
|
|
||||||
|
|
||||||
// One row per listed event, so with each listed event shown
|
|
||||||
// no event is listed twice.
|
|
||||||
assert.Equal(t, len(tc.listed),
|
|
||||||
strings.Count(body, `role="button"`), tc.query)
|
|
||||||
|
|
||||||
for _, contentType := range all {
|
|
||||||
assert.Equal(t,
|
|
||||||
slices.Contains(tc.listed, contentType),
|
|
||||||
strings.Contains(body, ">"+contentType+"<"),
|
|
||||||
tc.query+" "+contentType)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Contains(t, body, ">"+tc.heading+"<", tc.query)
|
|
||||||
assert.Contains(t, body, "Failed (2)", tc.query)
|
|
||||||
assert.Contains(t, body, "Pending (3)", tc.query)
|
|
||||||
assert.Equal(t, 1, strings.Count(body, "aria-current"), tc.query)
|
|
||||||
assert.Contains(t, body,
|
|
||||||
`aria-current="page">`+tc.current+"</a>", tc.query)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceLogs_FilteredListShowsFiftyNewest proves a filtered
|
|
||||||
// list holds the 50 newest matching events, as the full log does,
|
|
||||||
// while its link and heading count every matching event.
|
|
||||||
func TestHandleSourceLogs_FilteredListShowsFiftyNewest(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newRecentEventsFixture(t)
|
|
||||||
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
|
|
||||||
base := time.Now().Add(-time.Hour)
|
|
||||||
|
|
||||||
for i := range 51 {
|
|
||||||
event := f.event(
|
|
||||||
t, fmt.Sprintf("application/x-failed-%02d", i), "{}",
|
|
||||||
base.Add(time.Duration(i)*time.Second),
|
|
||||||
)
|
|
||||||
f.delivery(t, event, target.ID, database.DeliveryStatusFailed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The newest event has no failed delivery.
|
|
||||||
f.event(t, "application/x-no-delivery", "{}", time.Now())
|
|
||||||
|
|
||||||
body := renderSourceLogsPageWithQuery(
|
|
||||||
t, f.h, f.sess, f.webhook.ID, "?show=failed",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t, 50, strings.Count(body, `role="button"`))
|
|
||||||
assert.NotContains(t, body, "application/x-failed-00")
|
|
||||||
assert.NotContains(t, body, "application/x-no-delivery")
|
|
||||||
assert.Contains(t, body, "Failed (51)")
|
|
||||||
assert.Contains(t, body,
|
|
||||||
"50 most recent of 51 events with a failed delivery")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceLogs_EmptyFilteredList proves an empty filtered list
|
|
||||||
// says that no event matches rather than that none was recorded.
|
|
||||||
func TestHandleSourceLogs_EmptyFilteredList(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newRecentEventsFixture(t)
|
|
||||||
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
|
|
||||||
|
|
||||||
f.delivery(
|
|
||||||
t, f.event(t, contentTypeJSON, "{}", time.Now()),
|
|
||||||
target.ID, database.DeliveryStatusDelivered,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Contains(t, renderSourceLogsPageWithQuery(
|
|
||||||
t, f.h, f.sess, f.webhook.ID, "?show=failed",
|
|
||||||
), "No event has a failed delivery.")
|
|
||||||
assert.Contains(t, renderSourceLogsPageWithQuery(
|
|
||||||
t, f.h, f.sess, f.webhook.ID, "?show=pending",
|
|
||||||
), "No event has a delivery pending or retrying.")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceLogs_OnlyNewestStartsExpanded proves that of the
|
|
||||||
// events in the log only the newest starts expanded.
|
|
||||||
func TestHandleSourceLogs_OnlyNewestStartsExpanded(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newRecentEventsFixture(t)
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
f.event(t, "application/x-older", "{}", now.Add(-time.Minute))
|
|
||||||
f.event(t, "application/x-newer", "{}", now)
|
|
||||||
|
|
||||||
body := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
|
|
||||||
|
|
||||||
assert.Equal(t, 1, strings.Count(body, " data-open>"))
|
|
||||||
|
|
||||||
open := strings.Index(body, " data-open>")
|
|
||||||
newer := strings.Index(body, "application/x-newer")
|
|
||||||
older := strings.Index(body, "application/x-older")
|
|
||||||
|
|
||||||
assert.Less(t, open, newer, "the newest event is not the open one")
|
|
||||||
assert.Less(t, newer, older)
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -509,51 +509,6 @@ func TestHandleSourceEditSubmit_InvalidRetentionIsRejected(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleSourceEditSubmit_RefusedFormComesBack refuses an edit for
|
|
||||||
// each reason the form can give and checks that the form comes back
|
|
||||||
// with the reason and the name, description and retention submitted,
|
|
||||||
// that the page still reports the stored retention, and that nothing
|
|
||||||
// is saved.
|
|
||||||
func TestHandleSourceEditSubmit_RefusedFormComesBack(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
|
|
||||||
refused := func(name, retention, reason string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
wh := seedWebhookWithRetention(t, env.db, 30)
|
|
||||||
|
|
||||||
submitted := wh
|
|
||||||
submitted.Name = name
|
|
||||||
submitted.Description = "a description worth keeping"
|
|
||||||
|
|
||||||
w := submitEdit(t, env, submitted, retention)
|
|
||||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
||||||
|
|
||||||
page := w.Body.String()
|
|
||||||
assert.Contains(t, page, `class="alert-error">`+reason)
|
|
||||||
assert.Contains(t, page, `name="name" value="`+name+`"`)
|
|
||||||
assert.Contains(t, page, ">a description worth keeping</textarea>")
|
|
||||||
assert.Contains(
|
|
||||||
t, page, `name="retention_days" value="`+retention+`"`,
|
|
||||||
)
|
|
||||||
assert.Contains(t, page, "Currently 30 days.")
|
|
||||||
|
|
||||||
var stored database.Webhook
|
|
||||||
|
|
||||||
require.NoError(
|
|
||||||
t, env.db.DB().First(&stored, "id = ?", wh.ID).Error,
|
|
||||||
)
|
|
||||||
assert.Equal(t, wh.Name, stored.Name)
|
|
||||||
assert.Empty(t, stored.Description)
|
|
||||||
assert.Equal(t, 30, stored.RetentionDays)
|
|
||||||
}
|
|
||||||
|
|
||||||
refused("", "45", "Name is required")
|
|
||||||
refused("kept-name", "nonsense", "Retention must be")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleSourceEditSubmit_EmptyRetentionLeavesValueUnchanged(
|
func TestHandleSourceEditSubmit_EmptyRetentionLeavesValueUnchanged(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
@@ -854,10 +809,6 @@ func TestHandleSourceEditSubmit_ArchiveNameTaken(t *testing.T) {
|
|||||||
w := submitEdit(t, env, wh, "")
|
w := submitEdit(t, env, wh, "")
|
||||||
require.Equal(t, http.StatusConflict, w.Code)
|
require.Equal(t, http.StatusConflict, w.Code)
|
||||||
assert.Contains(t, w.Body.String(), "archive-taken.db")
|
assert.Contains(t, w.Body.String(), "archive-taken.db")
|
||||||
assert.Contains(
|
|
||||||
t, w.Body.String(), `name="name" value="`+renamedWebhookName+`"`,
|
|
||||||
"the form comes back with the name submitted",
|
|
||||||
)
|
|
||||||
|
|
||||||
var stored database.Webhook
|
var stored database.Webhook
|
||||||
|
|
||||||
|
|||||||
@@ -27,11 +27,13 @@ func (h *Handlers) HandleTargetDownload() http.HandlerFunc {
|
|||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := context.WithoutCancel(r.Context())
|
ctx := context.WithoutCancel(r.Context())
|
||||||
|
|
||||||
webhook, target, export, ok := h.listTargetArchive(w, r)
|
webhook, target, export, ok := h.openTargetArchive(ctx, w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defer func() { _ = export.Close() }()
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/gzip")
|
w.Header().Set("Content-Type", "application/gzip")
|
||||||
@@ -79,15 +81,16 @@ func (d downloadWriter) Write(b []byte) (int, error) {
|
|||||||
return d.w.Write(b)
|
return d.w.Write(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// listTargetArchive lists the archive files of the request's database
|
// openTargetArchive opens the archive of the request's database target
|
||||||
// target for export. It reports false once it has written the response.
|
// for export, with its reads under ctx. It reports false once it has
|
||||||
|
// written the response.
|
||||||
//
|
//
|
||||||
// It holds renameMu, which every archive rename runs under, while it
|
// It holds renameMu, which every archive rename runs under, while it
|
||||||
// reads the stored names and lists the files, so the files it lists
|
// reads the stored names and opens the file, so the file it opens is
|
||||||
// are the ones those names give. It lets go before the export is
|
// the one those names give. It lets go before the export is streamed:
|
||||||
// streamed, which takes renameMu again for each file only while it
|
// once the file is open, a rename does not affect the export.
|
||||||
// finds the file under the names stored then and opens it.
|
func (h *Handlers) openTargetArchive(
|
||||||
func (h *Handlers) listTargetArchive(
|
ctx context.Context,
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
) (database.Webhook, *database.Target, *delivery.ArchiveExport, bool) {
|
) (database.Webhook, *database.Target, *delivery.ArchiveExport, bool) {
|
||||||
@@ -105,43 +108,14 @@ func (h *Handlers) listTargetArchive(
|
|||||||
return database.Webhook{}, nil, nil, false
|
return database.Webhook{}, nil, nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
export, err := delivery.NewArchiveExport(
|
export, err := delivery.OpenArchiveExport(
|
||||||
delivery.ArchivePath(h.dbMgr, &webhook, target),
|
ctx, delivery.ArchivePath(h.dbMgr, &webhook, target), h.log,
|
||||||
&h.renameMu,
|
|
||||||
func() (string, error) {
|
|
||||||
return h.storedArchivePath(webhook.ID, target.ID)
|
|
||||||
},
|
|
||||||
h.log,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to list archive for export", err)
|
h.serverError(w, r, "failed to open archive for export", err)
|
||||||
|
|
||||||
return database.Webhook{}, nil, nil, false
|
return database.Webhook{}, nil, nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
return webhook, target, export, true
|
return webhook, target, export, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// storedArchivePath returns the path delivery.ArchivePath gives a
|
|
||||||
// database target under the names stored for it and its webhook now.
|
|
||||||
// Its caller holds renameMu. A webhook or target deleted since is still
|
|
||||||
// found, since deleting one leaves its archive files under their names.
|
|
||||||
func (h *Handlers) storedArchivePath(
|
|
||||||
webhookID, targetID string,
|
|
||||||
) (string, error) {
|
|
||||||
var webhook database.Webhook
|
|
||||||
|
|
||||||
err := h.db.DB().Unscoped().First(&webhook, "id = ?", webhookID).Error
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
var target database.Target
|
|
||||||
|
|
||||||
err = h.db.DB().Unscoped().First(&target, "id = ?", targetID).Error
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return delivery.ArchivePath(h.dbMgr, &webhook, &target), nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -87,7 +85,7 @@ func TestHandleTargetDownload(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleTargetDownload_WaitsForRename proves a download reads the
|
// TestHandleTargetDownload_WaitsForRename proves a download reads the
|
||||||
// target's names and lists its archive under the lock a rename holds:
|
// target's names and opens its archive under the lock a rename holds:
|
||||||
// started while an edit is renaming the archive, it waits, and is
|
// started while an edit is renaming the archive, it waits, and is
|
||||||
// named for the target's new name.
|
// named for the target's new name.
|
||||||
func TestHandleTargetDownload_WaitsForRename(t *testing.T) {
|
func TestHandleTargetDownload_WaitsForRename(t *testing.T) {
|
||||||
@@ -150,17 +148,18 @@ func (s *stalledWriter) Write(b []byte) (int, error) {
|
|||||||
return s.ResponseRecorder.Write(b)
|
return s.ResponseRecorder.Write(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// startStalledDownload starts a download of the target and returns once
|
// TestHandleTargetDownload_StreamsWithoutTheLock proves a download
|
||||||
// it is stalled at its first write, which comes before it opens any
|
// lets go of the rename lock once its archive is open: while the
|
||||||
// archive file. Closing the writer's resume lets it go on; the returned
|
// download is stalled writing, an edit can still rename the target.
|
||||||
// channel is closed when it has finished.
|
func TestHandleTargetDownload_StreamsWithoutTheLock(t *testing.T) {
|
||||||
func startStalledDownload(
|
t.Parallel()
|
||||||
t *testing.T, env *sourceTestEnv, webhookID, targetID string,
|
|
||||||
) (*stalledWriter, <-chan struct{}) {
|
env := setupSourceTest(t)
|
||||||
t.Helper()
|
wh := seedWebhookWithRetention(t, env.db, 7)
|
||||||
|
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
t.Context(), http.MethodGet, downloadPath(webhookID, targetID), nil,
|
t.Context(), http.MethodGet, downloadPath(wh.ID, archive.ID), nil,
|
||||||
)
|
)
|
||||||
for _, c := range env.cookies {
|
for _, c := range env.cookies {
|
||||||
req.AddCookie(c)
|
req.AddCookie(c)
|
||||||
@@ -180,21 +179,6 @@ func startStalledDownload(
|
|||||||
|
|
||||||
<-sw.writing
|
<-sw.writing
|
||||||
|
|
||||||
return sw, downloaded
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleTargetDownload_StreamsWithoutTheLock proves a download
|
|
||||||
// lets go of the rename lock once it has listed its archive: while the
|
|
||||||
// download is stalled writing, an edit can still rename the target.
|
|
||||||
func TestHandleTargetDownload_StreamsWithoutTheLock(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
wh := seedWebhookWithRetention(t, env.db, 7)
|
|
||||||
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
|
||||||
|
|
||||||
sw, downloaded := startStalledDownload(t, env, wh.ID, archive.ID)
|
|
||||||
|
|
||||||
edited := make(chan *httptest.ResponseRecorder, 1)
|
edited := make(chan *httptest.ResponseRecorder, 1)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
@@ -213,62 +197,6 @@ func TestHandleTargetDownload_StreamsWithoutTheLock(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, sw.Code)
|
assert.Equal(t, http.StatusOK, sw.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleTargetDownload_FindsFilesAfterRename proves a download
|
|
||||||
// finds each of the target's files again under the names stored when
|
|
||||||
// it reaches the file: the target is renamed while the download is
|
|
||||||
// stalled before it has opened any file, and the rows of both its files
|
|
||||||
// are in the download.
|
|
||||||
func TestHandleTargetDownload_FindsFilesAfterRename(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
wh := seedWebhookWithRetention(t, env.db, 7)
|
|
||||||
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
|
||||||
oldPath := delivery.ArchivePath(env.dbMgr, &wh, archive)
|
|
||||||
|
|
||||||
month := func(path string) string {
|
|
||||||
return strings.TrimSuffix(path, ".db") + "-2026-10.db"
|
|
||||||
}
|
|
||||||
|
|
||||||
seedArchive(t, oldPath, 1, 16)
|
|
||||||
seedArchive(t, month(oldPath), 1, 16)
|
|
||||||
|
|
||||||
sw, downloaded := startStalledDownload(t, env, wh.ID, archive.ID)
|
|
||||||
|
|
||||||
require.Equal(t,
|
|
||||||
http.StatusSeeOther, renameTarget(env, wh.ID, archive.ID).Code,
|
|
||||||
)
|
|
||||||
|
|
||||||
// The test's archives record a rename without moving any file, so
|
|
||||||
// the files are moved here, as the delivery engine moves them.
|
|
||||||
var renamed database.Target
|
|
||||||
|
|
||||||
require.NoError(t, env.db.DB().First(&renamed, "id = ?", archive.ID).Error)
|
|
||||||
|
|
||||||
newPath := delivery.ArchivePath(env.dbMgr, &wh, &renamed)
|
|
||||||
|
|
||||||
require.NoError(t, os.Rename(oldPath, newPath))
|
|
||||||
require.NoError(t, os.Rename(month(oldPath), month(newPath)))
|
|
||||||
|
|
||||||
close(sw.resume)
|
|
||||||
<-downloaded
|
|
||||||
require.Equal(t, http.StatusOK, sw.Code)
|
|
||||||
|
|
||||||
zr, err := gzip.NewReader(sw.Body)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var (
|
|
||||||
got map[string]json.RawMessage
|
|
||||||
events []map[string]any
|
|
||||||
)
|
|
||||||
|
|
||||||
require.NoError(t, json.NewDecoder(zr).Decode(&got))
|
|
||||||
require.NoError(t, json.Unmarshal(got["archived_events"], &events))
|
|
||||||
require.Len(t, events, 2)
|
|
||||||
assert.NotContains(t, events[0], "period")
|
|
||||||
assert.Equal(t, "2026-10", events[1]["period"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedArchive writes rows to the archive file at path, each with a
|
// seedArchive writes rows to the archive file at path, each with a
|
||||||
// body of bodySize random bytes, which do not compress. Its table has
|
// body of bodySize random bytes, which do not compress. Its table has
|
||||||
// only the columns the test fills; an export writes the others empty.
|
// only the columns the test fills; an export writes the others empty.
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
@@ -14,13 +13,10 @@ import (
|
|||||||
const targetEditTemplate = "target_edit.html"
|
const targetEditTemplate = "target_edit.html"
|
||||||
|
|
||||||
// tmplKeyTarget is the template data key for the target being
|
// tmplKeyTarget is the template data key for the target being
|
||||||
// edited, tmplKeyTargetForm for the values its form shows, and
|
// edited, and tmplKeyMaxTimeout for the timeout ceiling the form
|
||||||
// tmplKeyMaxTimeout for the timeout ceiling the form tells the user
|
// tells the user about.
|
||||||
// about. The add target form on the webhook page takes its values
|
|
||||||
// under the same key as the edit form.
|
|
||||||
const (
|
const (
|
||||||
tmplKeyTarget = "Target"
|
tmplKeyTarget = "Target"
|
||||||
tmplKeyTargetForm = "TargetForm"
|
|
||||||
tmplKeyMaxTimeout = "MaxTimeout"
|
tmplKeyMaxTimeout = "MaxTimeout"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,19 +28,20 @@ const configUnreadableMessage = "The stored configuration for this " +
|
|||||||
"target could not be read. Enter the values below; saving " +
|
"target could not be read. Enter the values below; saving " +
|
||||||
"replaces the stored configuration."
|
"replaces the stored configuration."
|
||||||
|
|
||||||
// targetEditView is the display model for the target edit page: the
|
// targetEditView is the display model for the target edit page.
|
||||||
// target's row fields as stored. The values the form shows, the
|
|
||||||
// UNMASKED configuration among them, come separately, as a
|
|
||||||
// targetFormInput.
|
|
||||||
//
|
//
|
||||||
// It deliberately omits database.Target's raw Config blob: the form
|
// It carries the target's row fields alongside its UNMASKED
|
||||||
// renders named fields, and giving the template the blob as well
|
// configuration, and deliberately omits database.Target's raw
|
||||||
// would put an unreviewed second path to the credential on the page.
|
// Config blob: the form renders named fields, and giving the
|
||||||
|
// template the blob as well would put an unreviewed second path to
|
||||||
|
// the credential on the page.
|
||||||
type targetEditView struct {
|
type targetEditView struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Type database.TargetType
|
Type database.TargetType
|
||||||
Active bool
|
Active bool
|
||||||
|
MaxRetries int
|
||||||
|
Config delivery.TargetConfigForm
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleTargetEdit shows the form to edit a target.
|
// HandleTargetEdit shows the form to edit a target.
|
||||||
@@ -76,19 +73,7 @@ func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
|
|||||||
msg = configUnreadableMessage
|
msg = configUnreadableMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
form := targetFormInput{
|
h.renderTargetEdit(w, r, webhook, target, cfg, msg)
|
||||||
Name: target.Name,
|
|
||||||
URL: cfg.URL,
|
|
||||||
Headers: cfg.Headers,
|
|
||||||
Timeout: cfg.Timeout,
|
|
||||||
MaxRetries: strconv.Itoa(target.MaxRetries),
|
|
||||||
Expiry: cfg.Expiry,
|
|
||||||
Rotation: cfg.Rotation,
|
|
||||||
}
|
|
||||||
|
|
||||||
h.renderTargetEdit(
|
|
||||||
w, r, webhook, target, form, msg, http.StatusOK,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,12 +101,11 @@ func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyTargetEdit validates and saves target edits. A refused save
|
// applyTargetEdit validates and saves target edits.
|
||||||
// shows the edit form again with the values submitted and the reason.
|
|
||||||
//
|
//
|
||||||
// The submission goes through setTargetFromForm, as a new target
|
// The submitted configuration goes through buildTargetConfig, the
|
||||||
// does, so an edited destination is SSRF-validated exactly as a new
|
// same builder the create path uses, so an edited destination is
|
||||||
// one is.
|
// SSRF-validated exactly as a new one is.
|
||||||
//
|
//
|
||||||
// The target's type is not editable. Each type stores a different
|
// The target's type is not editable. Each type stores a different
|
||||||
// configuration shape and its delivery history is recorded against
|
// configuration shape and its delivery history is recorded against
|
||||||
@@ -134,13 +118,16 @@ func (h *Handlers) applyTargetEdit(
|
|||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
target *database.Target,
|
target *database.Target,
|
||||||
) {
|
) {
|
||||||
in := targetFormInputFrom(r)
|
name := r.PostFormValue("name")
|
||||||
|
if name == "" {
|
||||||
|
http.Error(w, "Name is required", http.StatusBadRequest)
|
||||||
|
|
||||||
// edited is the target as the submission leaves it; target stays
|
return
|
||||||
// as stored, for the page shown again when the save is refused.
|
}
|
||||||
edited := *target
|
|
||||||
|
|
||||||
errMsg, err := h.setTargetFromForm(r.Context(), &edited, in)
|
configJSON, errMsg, err := h.buildTargetConfig(
|
||||||
|
r.Context(), target.Type, targetFormInputFrom(r),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to encode target config", err)
|
h.serverError(w, r, "failed to encode target config", err)
|
||||||
|
|
||||||
@@ -148,26 +135,45 @@ func (h *Handlers) applyTargetEdit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if errMsg != "" {
|
if errMsg != "" {
|
||||||
h.renderTargetEdit(
|
http.Error(w, errMsg, http.StatusBadRequest)
|
||||||
w, r, webhook, target, in, errMsg, http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retries are offered only by the forms for target types that
|
||||||
|
// retry, so an absent field means "this form does not edit
|
||||||
|
// retries" rather than "set them to zero". Reading it
|
||||||
|
// unconditionally would silently disable retries on any target
|
||||||
|
// saved from a form that does not render the input.
|
||||||
|
//
|
||||||
|
// A field that IS submitted but does not parse is a 400, through
|
||||||
|
// the same validator the create path uses. It is rejected before
|
||||||
|
// anything is written, so a typo cannot destroy the retry count
|
||||||
|
// the target is already delivering with.
|
||||||
|
if r.PostForm.Has("max_retries") {
|
||||||
|
retries, ok := targetMaxRetries(w, r, target.MaxRetries)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
target.MaxRetries = retries
|
||||||
|
}
|
||||||
|
|
||||||
|
oldName := target.Name
|
||||||
|
target.Name = name
|
||||||
|
target.Config = configJSON
|
||||||
|
|
||||||
// A new name renames the archive file before it is saved (see
|
// A new name renames the archive file before it is saved (see
|
||||||
// delivery.Engine.Rename). If either step fails, it goes back to
|
// delivery.Engine.Rename). If either step fails, it goes back to
|
||||||
// the name that is still stored.
|
// the name that is still stored.
|
||||||
err = h.renameTargetArchive(
|
err = h.renameTargetArchive(target, webhook.Name, oldName, name)
|
||||||
target, webhook.Name, target.Name, edited.Name,
|
|
||||||
)
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = h.db.DB().Save(&edited).Error
|
err = h.db.DB().Save(target).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
restoreErr := h.renameTargetArchive(
|
restoreErr := h.renameTargetArchive(
|
||||||
target, webhook.Name, edited.Name, target.Name,
|
target, webhook.Name, name, oldName,
|
||||||
)
|
)
|
||||||
if restoreErr != nil {
|
if restoreErr != nil {
|
||||||
h.log.Error(
|
h.log.Error(
|
||||||
@@ -178,8 +184,8 @@ func (h *Handlers) applyTargetEdit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, delivery.ErrArchiveNameTaken) {
|
if errors.Is(err, delivery.ErrArchiveNameTaken) {
|
||||||
h.renderTargetEdit(
|
http.Error(
|
||||||
w, r, webhook, target, in,
|
w,
|
||||||
"Not saved: "+err.Error()+
|
"Not saved: "+err.Error()+
|
||||||
". Move that archive out of the data directory, "+
|
". Move that archive out of the data directory, "+
|
||||||
"its .db together with any -wal and -shm beside "+
|
"its .db together with any -wal and -shm beside "+
|
||||||
@@ -216,17 +222,15 @@ func (h *Handlers) renameTargetArchive(
|
|||||||
return h.archives.Rename(target.ID, webhookName, newName)
|
return h.archives.Rename(target.ID, webhookName, newName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderTargetEdit renders the target edit page for the target as
|
// renderTargetEdit renders the target edit page with an optional
|
||||||
// stored, its form showing form's values, with an optional error
|
// error message.
|
||||||
// message above it.
|
|
||||||
func (h *Handlers) renderTargetEdit(
|
func (h *Handlers) renderTargetEdit(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
target *database.Target,
|
target *database.Target,
|
||||||
form targetFormInput,
|
cfg delivery.TargetConfigForm,
|
||||||
errMsg string,
|
errMsg string,
|
||||||
status int,
|
|
||||||
) {
|
) {
|
||||||
// The template calls Webhook methods, which take pointer
|
// The template calls Webhook methods, which take pointer
|
||||||
// receivers; html/template cannot address a value stored in a
|
// receivers; html/template cannot address a value stored in a
|
||||||
@@ -234,21 +238,18 @@ func (h *Handlers) renderTargetEdit(
|
|||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
tmplKeyWebhook: &webhook,
|
tmplKeyWebhook: &webhook,
|
||||||
tmplKeyTarget: targetEditView{
|
tmplKeyTarget: targetEditView{
|
||||||
ID: target.ID,
|
ID: target.ID,
|
||||||
Name: target.Name,
|
Name: target.Name,
|
||||||
Type: target.Type,
|
Type: target.Type,
|
||||||
Active: target.Active,
|
Active: target.Active,
|
||||||
|
MaxRetries: target.MaxRetries,
|
||||||
|
Config: cfg,
|
||||||
},
|
},
|
||||||
tmplKeyTargetForm: form,
|
tmplKeyMaxTimeout: delivery.MaxTargetTimeoutSeconds,
|
||||||
tmplKeyMaxTimeout: delivery.MaxTargetTimeoutSeconds,
|
tmplKeyError: errMsg,
|
||||||
tmplKeyError: errMsg,
|
|
||||||
tmplKeyArchiveExpiryChoices: archiveExpiryOptions(form.Expiry),
|
|
||||||
tmplKeyArchiveRotationChoices: archiveRotationOptions(
|
|
||||||
form.Rotation,
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplateStatus(w, r, targetEditTemplate, data, status)
|
h.renderTemplate(w, r, targetEditTemplate, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ownedTarget resolves the request's sourceID and targetID
|
// ownedTarget resolves the request's sourceID and targetID
|
||||||
|
|||||||
@@ -418,30 +418,6 @@ func TestHandleTargetEdit_PrefillsTheStoredValuesUnmasked(
|
|||||||
assert.Contains(t, page, "original-name")
|
assert.Contains(t, page, "original-name")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleTargetEdit_CallsTheDatabaseTypeArchive pins the names the
|
|
||||||
// edit page of a database target gives its type and its settings to
|
|
||||||
// the ones its badge and the add target form use.
|
|
||||||
func TestHandleTargetEdit_CallsTheDatabaseTypeArchive(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
webhook := seedWebhookWithRetention(t, env.db, 30)
|
|
||||||
target := seedTarget(t, env.db, webhook.ID, database.TargetTypeDatabase)
|
|
||||||
|
|
||||||
w := serveTarget(
|
|
||||||
env, http.MethodGet,
|
|
||||||
"/hook/"+webhook.ID+"/targets/"+target.ID+"/edit",
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
page := w.Body.String()
|
|
||||||
|
|
||||||
assert.Contains(t, page, "Type: archive.")
|
|
||||||
assert.Contains(t, page, `class="label">Archive expiry</label>`)
|
|
||||||
assert.Contains(t, page, `class="label">Archive rotation</label>`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleTargetEditSubmit_Rejects covers every submission that
|
// TestHandleTargetEditSubmit_Rejects covers every submission that
|
||||||
// must not reach storage.
|
// must not reach storage.
|
||||||
//
|
//
|
||||||
@@ -589,78 +565,6 @@ func assertEditRejectsTimeout(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleTargetEditSubmit_RefusedFormComesBack refuses an edit of
|
|
||||||
// a target of each type and checks that the edit form comes back with
|
|
||||||
// the reason and every value submitted, and that nothing is saved.
|
|
||||||
func TestHandleTargetEditSubmit_RefusedFormComesBack(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
|
|
||||||
// fields is what the operator submitted, as a query string.
|
|
||||||
cases := []struct {
|
|
||||||
targetType database.TargetType
|
|
||||||
fields string
|
|
||||||
reason string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
database.TargetTypeHTTP,
|
|
||||||
"name=edited&url=" + editBlockedURL +
|
|
||||||
"&headers=X-Edited:+kept&timeout=12&max_retries=3",
|
|
||||||
"Invalid target URL",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
database.TargetTypeSlack,
|
|
||||||
"name=edited&url=" + editOriginalURL + "&max_retries=25",
|
|
||||||
"Invalid delivery attempts",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
database.TargetTypeDatabase,
|
|
||||||
"name=edited&expiry=7d&rotation=daily",
|
|
||||||
"Invalid archive expiry",
|
|
||||||
},
|
|
||||||
{database.TargetTypeLog, "name=", "Name is required"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(string(tc.targetType), func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
webhook := seedWebhookWithRetention(t, env.db, 30)
|
|
||||||
target := seedTarget(t, env.db, webhook.ID, tc.targetType)
|
|
||||||
|
|
||||||
form, err := url.ParseQuery(tc.fields)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
w := submitTargetEdit(env, webhook.ID, target.ID, form)
|
|
||||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
||||||
|
|
||||||
page := w.Body.String()
|
|
||||||
assert.Contains(t, page, `class="alert-error">`+tc.reason)
|
|
||||||
|
|
||||||
// headers is the form's one textarea, and expiry and
|
|
||||||
// rotation its selects; every other field is an input.
|
|
||||||
for field := range form {
|
|
||||||
shown := `name="` + field + `" value="` + form.Get(field) + `"`
|
|
||||||
|
|
||||||
switch field {
|
|
||||||
case "headers":
|
|
||||||
shown = ">" + form.Get(field) + "</textarea>"
|
|
||||||
case "expiry", "rotation":
|
|
||||||
shown = `<option value="` + form.Get(field) + `" selected>`
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Contains(t, page, shown)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t, target.Name, storedTarget(t, env, target.ID).Name,
|
|
||||||
"a refused edit must save nothing",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleTargetEdit_Scoping keeps the edit routes scoped the way
|
// TestHandleTargetEdit_Scoping keeps the edit routes scoped the way
|
||||||
// the delete and toggle routes are: ownership is decided by the
|
// the delete and toggle routes are: ownership is decided by the
|
||||||
// webhook, and the target is then scoped to it.
|
// webhook, and the target is then scoped to it.
|
||||||
@@ -796,10 +700,6 @@ func TestHandleTargetEditSubmit_RenamesArchive(t *testing.T) {
|
|||||||
w = submitTargetEdit(env, wh.ID, archive.ID, again)
|
w = submitTargetEdit(env, wh.ID, archive.ID, again)
|
||||||
require.Equal(t, http.StatusConflict, w.Code)
|
require.Equal(t, http.StatusConflict, w.Code)
|
||||||
assert.Contains(t, w.Body.String(), "archive-taken.db")
|
assert.Contains(t, w.Body.String(), "archive-taken.db")
|
||||||
assert.Contains(
|
|
||||||
t, w.Body.String(), `name="name" value="Again"`,
|
|
||||||
"the form comes back with the name submitted",
|
|
||||||
)
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, renamedTargetName, storedTarget(t, env, archive.ID).Name,
|
t, renamedTargetName, storedTarget(t, env, archive.ID).Name,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,14 +2,11 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/dustin/go-humanize"
|
||||||
"gorm.io/gorm"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
)
|
)
|
||||||
@@ -18,122 +15,23 @@ import (
|
|||||||
type TargetRowView struct {
|
type TargetRowView struct {
|
||||||
delivery.TargetView
|
delivery.TargetView
|
||||||
|
|
||||||
// Deliveries counts the target's delivered and failed deliveries,
|
// Archive is a database target's archive file, and nil for a target
|
||||||
// and is nil when the webhook's event database could not be read.
|
// of any other type.
|
||||||
Deliveries *TargetDeliveries
|
|
||||||
|
|
||||||
// Archive is a database target's archive files, and nil for a
|
|
||||||
// target of any other type.
|
|
||||||
Archive *ArchiveFileView
|
Archive *ArchiveFileView
|
||||||
|
|
||||||
// Paused is set while the target's circuit breaker is turning its
|
|
||||||
// deliveries away, and nil otherwise.
|
|
||||||
Paused *PausedView
|
|
||||||
}
|
|
||||||
|
|
||||||
// PausedView is a target's circuit breaker turning deliveries away.
|
|
||||||
// While the breaker is open, Until is a time in UTC, and Relative how
|
|
||||||
// long that is from now: on the target's row, when the cooldown ends;
|
|
||||||
// on a delivery, the earliest it can be tried next. While it is
|
|
||||||
// half-open both are empty: the cooldown has ended, and the target's
|
|
||||||
// deliveries are held while one delivery tests whether the target has
|
|
||||||
// recovered.
|
|
||||||
type PausedView struct {
|
|
||||||
Until string
|
|
||||||
Relative string
|
|
||||||
}
|
|
||||||
|
|
||||||
// pausedView reads the target's circuit breaker for its row, and
|
|
||||||
// returns nil when the breaker lets the target's deliveries through.
|
|
||||||
func (h *Handlers) pausedView(targetID string) *PausedView {
|
|
||||||
state, cooldown := h.breakers.StateAndCooldown(targetID)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case state == delivery.CircuitHalfOpen:
|
|
||||||
return &PausedView{}
|
|
||||||
case state == delivery.CircuitOpen && cooldown > 0:
|
|
||||||
return newPausedView(time.Now().Add(cooldown))
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// deliveryPausedView reads the circuit breaker of a retrying delivery's
|
|
||||||
// target. While it is open, it says the earliest the delivery can be
|
|
||||||
// tried next: the later of the cooldown's end and the end of the
|
|
||||||
// delivery's own backoff after its last attempt. It is only the
|
|
||||||
// earliest: when the cooldown ends, one of the target's waiting
|
|
||||||
// deliveries is sent to test it while the others wait at least one more
|
|
||||||
// cooldown. Otherwise it returns nil, half-open included, since the
|
|
||||||
// delivery may then be the one being sent to test the target.
|
|
||||||
func (h *Handlers) deliveryPausedView(
|
|
||||||
targetID string, attempts []deliveryResultRow,
|
|
||||||
) *PausedView {
|
|
||||||
state, cooldown := h.breakers.StateAndCooldown(targetID)
|
|
||||||
if state != delivery.CircuitOpen || cooldown <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
next := time.Now().Add(cooldown)
|
|
||||||
|
|
||||||
if len(attempts) > 0 {
|
|
||||||
last := attempts[len(attempts)-1]
|
|
||||||
|
|
||||||
backoffEnd := last.CreatedAt.Add(delivery.Backoff(last.AttemptNum))
|
|
||||||
if backoffEnd.After(next) {
|
|
||||||
next = backoffEnd
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return newPausedView(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
// newPausedView is a PausedView of deliveries paused until the given
|
|
||||||
// time. A time not on the current UTC day is written with its date.
|
|
||||||
func newPausedView(until time.Time) *PausedView {
|
|
||||||
until = until.UTC()
|
|
||||||
|
|
||||||
layout := time.TimeOnly
|
|
||||||
if until.Format(time.DateOnly) != time.Now().UTC().Format(time.DateOnly) {
|
|
||||||
layout = time.DateTime
|
|
||||||
}
|
|
||||||
|
|
||||||
return &PausedView{
|
|
||||||
Until: until.Format(layout) + " UTC",
|
|
||||||
Relative: humanize.Time(until),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TargetDeliveries is how many of a target's deliveries became
|
|
||||||
// delivered and how many failed: in total, which retention does not
|
|
||||||
// reduce, and in the last 24 hours. Deliveries still pending or
|
|
||||||
// retrying count in neither.
|
|
||||||
type TargetDeliveries struct {
|
|
||||||
Delivered int64
|
|
||||||
Failed int64
|
|
||||||
|
|
||||||
DeliveredLast24Hours int64
|
|
||||||
FailedLast24Hours int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArchiveFileView is what a database target's row shows about its
|
// ArchiveFileView is what a database target's row shows about its
|
||||||
// archive files.
|
// archive file.
|
||||||
type ArchiveFileView struct {
|
type ArchiveFileView struct {
|
||||||
// Name is the name of the file an event received now goes to.
|
|
||||||
Name string
|
Name string
|
||||||
|
|
||||||
// Note says that file does not exist yet, or that the files could
|
// Note stands in for the size and the last write when there are
|
||||||
// not be read, and is empty otherwise.
|
// none to show, and is empty when there are.
|
||||||
Note string
|
Note string
|
||||||
|
|
||||||
// Files counts the target's archive files, and is 0 when there are
|
// Size is the size on disk. Written is how long ago the file was
|
||||||
// none, or they could not be read, and so no size or last write to
|
// last written, and WrittenUTC the full time the page shows on
|
||||||
// show.
|
// hover.
|
||||||
Files int
|
|
||||||
|
|
||||||
// Size is the size on disk of all the files together. Written is
|
|
||||||
// how long ago the latest of them was last written, and WrittenUTC
|
|
||||||
// the full time the page shows on hover.
|
|
||||||
Size string
|
Size string
|
||||||
Written string
|
Written string
|
||||||
WrittenUTC string
|
WrittenUTC string
|
||||||
@@ -146,145 +44,47 @@ func (h *Handlers) targetRows(
|
|||||||
) []TargetRowView {
|
) []TargetRowView {
|
||||||
views := delivery.NewTargetViews(targets)
|
views := delivery.NewTargetViews(targets)
|
||||||
rows := make([]TargetRowView, len(views))
|
rows := make([]TargetRowView, len(views))
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
deliveries, err := h.loadTargetDeliveries(webhook.ID)
|
|
||||||
if err != nil {
|
|
||||||
h.log.Error(
|
|
||||||
"failed to read target delivery counts",
|
|
||||||
"webhook_id", webhook.ID,
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTargetViews returns one view per target, in order.
|
// NewTargetViews returns one view per target, in order.
|
||||||
for i := range views {
|
for i := range views {
|
||||||
rows[i].TargetView = views[i]
|
rows[i].TargetView = views[i]
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
counts := deliveries[targets[i].ID]
|
|
||||||
rows[i].Deliveries = &counts
|
|
||||||
}
|
|
||||||
|
|
||||||
if targets[i].Type == database.TargetTypeDatabase {
|
if targets[i].Type == database.TargetTypeDatabase {
|
||||||
rows[i].Archive = h.archiveFileView(webhook, &targets[i], now)
|
rows[i].Archive = h.archiveFileView(webhook, &targets[i])
|
||||||
}
|
}
|
||||||
|
|
||||||
rows[i].Paused = h.pausedView(targets[i].ID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadTargetDeliveries reads the delivery counts of a webhook's targets
|
// archiveFileView describes a database target's archive file from the
|
||||||
// from its event database, keyed by target. A target with no deliveries
|
// file's metadata alone; the archive is never opened. The file is found
|
||||||
// is left out, and so is every target when the event database does not
|
// by the name the archive writer uses, so it follows a rename of the
|
||||||
// exist yet, since opening it would create it.
|
// webhook or the target.
|
||||||
func (h *Handlers) loadTargetDeliveries(
|
|
||||||
webhookID string,
|
|
||||||
) (map[string]TargetDeliveries, error) {
|
|
||||||
if !h.dbMgr.DBExists(webhookID) {
|
|
||||||
return map[string]TargetDeliveries{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
webhookDB, err := h.dbMgr.GetDB(webhookID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return readTargetDeliveries(webhookDB, time.Now())
|
|
||||||
}
|
|
||||||
|
|
||||||
// readTargetDeliveries counts each target's deliveries that became
|
|
||||||
// delivered and those that failed: in total from the targets' running
|
|
||||||
// totals, and in the 24 hours before now from the deliveries' status
|
|
||||||
// index. Each is one query for all the targets, and neither reads every
|
|
||||||
// stored delivery.
|
|
||||||
func readTargetDeliveries(
|
|
||||||
db *gorm.DB, now time.Time,
|
|
||||||
) (map[string]TargetDeliveries, error) {
|
|
||||||
var totals []database.TargetTotals
|
|
||||||
|
|
||||||
err := db.Find(&totals).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("reading target totals: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
lastDay, err := finishedByTarget(db, now.Add(-longWindow))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
byTarget := make(map[string]TargetDeliveries, len(totals))
|
|
||||||
|
|
||||||
for _, total := range totals {
|
|
||||||
byTarget[total.TargetID] = TargetDeliveries{
|
|
||||||
Delivered: total.Delivered,
|
|
||||||
Failed: total.Failed,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, finished := range lastDay {
|
|
||||||
counts := byTarget[finished.TargetID]
|
|
||||||
counts.DeliveredLast24Hours = finished.Delivered
|
|
||||||
counts.FailedLast24Hours = finished.Failed
|
|
||||||
byTarget[finished.TargetID] = counts
|
|
||||||
}
|
|
||||||
|
|
||||||
return byTarget, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// archiveFileView describes a database target's archive files from
|
|
||||||
// their metadata alone; the archive is never opened. It names the file
|
|
||||||
// an event received at now goes to. The files are found by the name
|
|
||||||
// the archive writer uses, so they follow a rename of the webhook or
|
|
||||||
// the target.
|
|
||||||
func (h *Handlers) archiveFileView(
|
func (h *Handlers) archiveFileView(
|
||||||
webhook *database.Webhook, target *database.Target, now time.Time,
|
webhook *database.Webhook, target *database.Target,
|
||||||
) *ArchiveFileView {
|
) *ArchiveFileView {
|
||||||
current, err := delivery.ArchivePathAt(h.dbMgr, webhook, target, now)
|
path := delivery.ArchivePath(h.dbMgr, webhook, target)
|
||||||
if err != nil {
|
view := &ArchiveFileView{Name: filepath.Base(path)}
|
||||||
return h.archiveUnreadable(&ArchiveFileView{}, target, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
view := &ArchiveFileView{Name: filepath.Base(current)}
|
file, err := delivery.StatArchive(path)
|
||||||
|
|
||||||
_, statErr := os.Stat(current)
|
|
||||||
if errors.Is(statErr, fs.ErrNotExist) {
|
|
||||||
view.Note = "not created yet"
|
|
||||||
}
|
|
||||||
|
|
||||||
files, err := delivery.StatArchive(
|
|
||||||
delivery.ArchivePath(h.dbMgr, webhook, target),
|
|
||||||
)
|
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, fs.ErrNotExist):
|
case errors.Is(err, fs.ErrNotExist):
|
||||||
// No files: no size or last write to show.
|
view.Note = "not created yet"
|
||||||
case err != nil:
|
case err != nil:
|
||||||
return h.archiveUnreadable(view, target, err)
|
h.log.Error(
|
||||||
|
"failed to read archive file metadata",
|
||||||
|
"target_id", target.ID,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
view.Note = "could not be read"
|
||||||
default:
|
default:
|
||||||
view.Files = files.Files
|
view.Size = humanize.Bytes(uint64(file.Size)) //nolint:gosec // never negative
|
||||||
view.Size = humanize.Bytes(uint64(files.Size)) //nolint:gosec // never negative
|
view.Written = humanize.Time(file.Written)
|
||||||
view.Written = humanize.Time(files.Written)
|
view.WrittenUTC = file.Written.UTC().Format(time.DateTime) + " UTC"
|
||||||
view.WrittenUTC = files.Written.UTC().Format(time.DateTime) + " UTC"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
// archiveUnreadable logs why a database target's archive files could
|
|
||||||
// not be described, and returns view saying so.
|
|
||||||
func (h *Handlers) archiveUnreadable(
|
|
||||||
view *ArchiveFileView, target *database.Target, err error,
|
|
||||||
) *ArchiveFileView {
|
|
||||||
h.log.Error(
|
|
||||||
"failed to read archive file metadata",
|
|
||||||
"target_id", target.ID,
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
|
|
||||||
view.Note = "could not be read"
|
|
||||||
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package handlers_test
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -13,7 +12,6 @@ import (
|
|||||||
"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/handlers"
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,10 +42,10 @@ func TestHandleSourceDetail_ShowsArchiveFile(t *testing.T) {
|
|||||||
path := delivery.ArchivePath(dbMgr, wh, archive)
|
path := delivery.ArchivePath(dbMgr, wh, archive)
|
||||||
|
|
||||||
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
||||||
assert.Equal(t, 1, strings.Count(body, "Archive file:"))
|
assert.Equal(t, 1, strings.Count(body, "Archive File:"))
|
||||||
assert.Contains(t, body, filepath.Base(path))
|
assert.Contains(t, body, filepath.Base(path))
|
||||||
assert.Contains(t, body, "not created yet")
|
assert.Contains(t, body, "not created yet")
|
||||||
assert.NotContains(t, body, "Archive size:")
|
assert.NotContains(t, body, "Archive Size:")
|
||||||
|
|
||||||
seedArchive(t, path, 1, 100)
|
seedArchive(t, path, 1, 100)
|
||||||
|
|
||||||
@@ -58,7 +56,7 @@ func TestHandleSourceDetail_ShowsArchiveFile(t *testing.T) {
|
|||||||
assert.Contains(t, body, filepath.Base(path))
|
assert.Contains(t, body, filepath.Base(path))
|
||||||
assert.NotContains(t, body, "not created yet")
|
assert.NotContains(t, body, "not created yet")
|
||||||
assert.Regexp(t,
|
assert.Regexp(t,
|
||||||
`Archive size:</span>\s*<span>[1-9][0-9.]* [kM]?B</span>`, body,
|
`Archive Size:</span>\s*<span>[1-9][0-9.]* [kM]?B</span>`, body,
|
||||||
)
|
)
|
||||||
assert.Contains(t, body,
|
assert.Contains(t, body,
|
||||||
`title="`+file.ModTime().UTC().Format(time.DateTime)+` UTC"`,
|
`title="`+file.ModTime().UTC().Format(time.DateTime)+` UTC"`,
|
||||||
@@ -69,106 +67,5 @@ func TestHandleSourceDetail_ShowsArchiveFile(t *testing.T) {
|
|||||||
body = renderSourceDetailPage(t, h, sess, wh.ID)
|
body = renderSourceDetailPage(t, h, sess, wh.ID)
|
||||||
assert.Contains(t, body, filepath.Base(path))
|
assert.Contains(t, body, filepath.Base(path))
|
||||||
assert.Contains(t, body, "not created yet")
|
assert.Contains(t, body, "not created yet")
|
||||||
assert.NotContains(t, body, "Archive size:")
|
assert.NotContains(t, body, "Archive Size:")
|
||||||
}
|
|
||||||
|
|
||||||
// targetList returns the text of the targets section in a rendered
|
|
||||||
// webhook page, from its heading to the next heading, with the markup
|
|
||||||
// taken out and each run of space made one space. Each target's row
|
|
||||||
// then reads as its name, type, state and buttons, followed by the
|
|
||||||
// lines below them.
|
|
||||||
func targetList(t *testing.T, page string) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
_, list, found := strings.Cut(page, ">Targets</h2>")
|
|
||||||
require.True(t, found, "the page has no targets section")
|
|
||||||
|
|
||||||
list, _, _ = strings.Cut(list, "<h2")
|
|
||||||
list = regexp.MustCompile(`<[^>]*>`).ReplaceAllString(list, " ")
|
|
||||||
|
|
||||||
return strings.Join(strings.Fields(list), " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceDetail_ShowsTargetDeliveries checks each target row's
|
|
||||||
// delivered and failed deliveries, in total and in the last 24 hours,
|
|
||||||
// for the history seedStatsHistory builds, before and after the real
|
|
||||||
// retention reaper removes the oldest event. The http target has one
|
|
||||||
// delivered, one of them in the last 24 hours, and three failed, one of
|
|
||||||
// them in the last 24 hours and one of them the oldest event's, which
|
|
||||||
// retention removes without changing the total. The active log target
|
|
||||||
// has two failed, both in the last 24 hours, and its pending and
|
|
||||||
// retrying deliveries count in neither. The four inactive log targets
|
|
||||||
// have none.
|
|
||||||
func TestHandleSourceDetail_ShowsTargetDeliveries(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)
|
|
||||||
|
|
||||||
hist := seedStatsHistory(t, h, sess, db, dbMgr)
|
|
||||||
|
|
||||||
const (
|
|
||||||
httpRow = "Delivered: 1 in total, 1 in the last 24 hours " +
|
|
||||||
"Failed: 3 in total, 1 in the last 24 hours"
|
|
||||||
activeLogRow = "t-log log Active Edit Deactivate Delete " +
|
|
||||||
"Delivered: 0 in total, 0 in the last 24 hours " +
|
|
||||||
"Failed: 2 in total, 2 in the last 24 hours"
|
|
||||||
inactiveLogRow = "t-log log Inactive Edit Activate Delete " +
|
|
||||||
"Delivered: 0 in total, 0 in the last 24 hours " +
|
|
||||||
"Failed: 0 in total, 0 in the last 24 hours"
|
|
||||||
)
|
|
||||||
|
|
||||||
list := targetList(t, renderSourceDetailPage(t, h, sess, hist.webhook.ID))
|
|
||||||
assert.Equal(t, 1, strings.Count(list, httpRow))
|
|
||||||
assert.Equal(t, 1, strings.Count(list, activeLogRow))
|
|
||||||
assert.Equal(t, 4, strings.Count(list, inactiveLogRow))
|
|
||||||
|
|
||||||
statsPrune(t, db, dbMgr, log, hist.webhookDB)
|
|
||||||
|
|
||||||
list = targetList(t, renderSourceDetailPage(t, h, sess, hist.webhook.ID))
|
|
||||||
assert.Equal(t, 1, strings.Count(list, httpRow))
|
|
||||||
assert.Equal(t, 1, strings.Count(list, activeLogRow))
|
|
||||||
assert.Equal(t, 4, strings.Count(list, inactiveLogRow))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceDetail_TargetDeliveriesUnreadable checks that when the
|
|
||||||
// webhook's event database cannot be read, each target's row says so
|
|
||||||
// instead of showing zeros.
|
|
||||||
func TestHandleSourceDetail_TargetDeliveriesUnreadable(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)
|
|
||||||
seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
|
||||||
|
|
||||||
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t,
|
|
||||||
webhookDB.Migrator().DropTable(&database.TargetTotals{}))
|
|
||||||
|
|
||||||
list := targetList(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
|
||||||
assert.Contains(t, list, "t-log log Active Edit Deactivate Delete "+
|
|
||||||
"The delivery counts could not be read.")
|
|
||||||
assert.NotContains(t, list, "Delivered:")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,220 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"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/delivery"
|
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
|
||||||
)
|
|
||||||
|
|
||||||
// cooldownEnds is how the pages write the end of a paused target's
|
|
||||||
// breaker's cooldown: the time in UTC, with its date when that falls on
|
|
||||||
// another UTC day, then how long that is from now.
|
|
||||||
const cooldownEnds = `(\d{4}-\d\d-\d\d )?\d\d:\d\d:\d\d UTC ` +
|
|
||||||
`\(\d+ seconds from now\)`
|
|
||||||
|
|
||||||
// TestPausedTarget_ShownUntilBreakerCloses takes an http target's
|
|
||||||
// circuit breaker from open through half-open to closed.
|
|
||||||
//
|
|
||||||
// Open, the target's row on the webhook page says its deliveries are
|
|
||||||
// paused and until when, and each retrying delivery says it is waiting
|
|
||||||
// and why in the event log and on the event's page, with the earliest
|
|
||||||
// it can be tried next: the later of the cooldown's end and the end of
|
|
||||||
// its own backoff, with the date when that is another UTC day.
|
|
||||||
// Half-open, the row says deliveries are held while one delivery tests
|
|
||||||
// the target, with no time, and no delivery says it is waiting. Closed,
|
|
||||||
// the pages say neither. The delivered delivery and the log target are
|
|
||||||
// shown as before throughout.
|
|
||||||
func TestPausedTarget_ShownUntilBreakerCloses(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
dbMgr *database.WebhookDBManager
|
|
||||||
breakers *testCircuitBreakers
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db, &dbMgr, &breakers)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
wh := seedWebhook(t, db)
|
|
||||||
target := seedTarget(t, db, wh.ID, database.TargetTypeHTTP)
|
|
||||||
seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
|
||||||
|
|
||||||
retrying := seedStoredEvent(t, dbMgr, wh.ID, `{"n":1}`)
|
|
||||||
addDelivery(t, dbMgr, wh.ID, retrying.ID, target.ID,
|
|
||||||
database.DeliveryStatusRetrying)
|
|
||||||
|
|
||||||
delivered := seedStoredEvent(t, dbMgr, wh.ID, `{"n":2}`)
|
|
||||||
addDelivery(t, dbMgr, wh.ID, delivered.ID, target.ID,
|
|
||||||
database.DeliveryStatusDelivered)
|
|
||||||
|
|
||||||
// This delivery's 18th attempt failed a minute ago, so its own
|
|
||||||
// backoff ends over a day from now: long after the cooldown, and on
|
|
||||||
// another UTC day, so the page shows the date.
|
|
||||||
backedOff := seedStoredEvent(t, dbMgr, wh.ID, `{"n":3}`)
|
|
||||||
backedOffID := addDelivery(t, dbMgr, wh.ID, backedOff.ID, target.ID,
|
|
||||||
database.DeliveryStatusRetrying)
|
|
||||||
|
|
||||||
failedAt := time.Now().Add(-time.Minute).Truncate(time.Second)
|
|
||||||
addFailedAttempt(t, dbMgr, wh.ID, backedOffID, 18, failedAt)
|
|
||||||
|
|
||||||
backoffEnds := failedAt.Add(delivery.Backoff(18)).UTC().
|
|
||||||
Format("2006-01-02 15:04:05") + " UTC (1 day from now)"
|
|
||||||
|
|
||||||
const waiting = "waiting: target paused after repeated failures, " +
|
|
||||||
"next try no earlier than "
|
|
||||||
|
|
||||||
breakers.Set(target.ID, delivery.CircuitOpen, 30*time.Second)
|
|
||||||
|
|
||||||
list := targetList(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
|
||||||
assert.Regexp(t, "t-http http Active Edit Deactivate Delete "+
|
|
||||||
"Deliveries paused: after repeated failures, until "+cooldownEnds+
|
|
||||||
", then one waiting delivery is sent to test the target while "+
|
|
||||||
"the others wait at least one more cooldown", list)
|
|
||||||
assert.Equal(t, 1, strings.Count(list, "Deliveries paused"))
|
|
||||||
|
|
||||||
log := renderSourceLogsPage(t, h, sess, wh.ID)
|
|
||||||
assert.Equal(t, 2, strings.Count(log, "t-http: waiting"))
|
|
||||||
assert.Contains(t, log, "t-http: delivered")
|
|
||||||
assert.Regexp(t, waiting+cooldownEnds, log)
|
|
||||||
assert.Contains(t, log, waiting+backoffEnds)
|
|
||||||
// No delivery shows as retrying; the Pending link's title says it.
|
|
||||||
assert.NotRegexp(t, `t-http: retrying|>retrying<`, log)
|
|
||||||
|
|
||||||
page := eventPage(t, h, sess, wh.ID, retrying.ID)
|
|
||||||
assert.Regexp(t, waiting+cooldownEnds, page)
|
|
||||||
assert.NotContains(t, page, "retrying")
|
|
||||||
|
|
||||||
page = eventPage(t, h, sess, wh.ID, backedOff.ID)
|
|
||||||
assert.Contains(t, page, waiting+backoffEnds)
|
|
||||||
assert.NotContains(t, page, "seconds from now")
|
|
||||||
|
|
||||||
breakers.Set(target.ID, delivery.CircuitHalfOpen, 0)
|
|
||||||
|
|
||||||
list = targetList(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
|
||||||
assert.Contains(t, list, "t-http http Active Edit Deactivate Delete "+
|
|
||||||
"Deliveries paused: held while one delivery tests whether the "+
|
|
||||||
"target has recovered")
|
|
||||||
// Not the whole list: the add target form above the rows says UTC.
|
|
||||||
assert.NotContains(t, targetRow(list, "t-http", "t-log"), "UTC")
|
|
||||||
|
|
||||||
assertRetryingNotWaiting(t, h, sess, wh.ID, retrying, backedOff)
|
|
||||||
|
|
||||||
breakers.Set(target.ID, delivery.CircuitClosed, 0)
|
|
||||||
|
|
||||||
list = targetList(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
|
||||||
assert.NotContains(t, list, "Deliveries paused")
|
|
||||||
|
|
||||||
assertRetryingNotWaiting(t, h, sess, wh.ID, retrying, backedOff)
|
|
||||||
}
|
|
||||||
|
|
||||||
// targetRow returns the row of the target named name in a targetList:
|
|
||||||
// from its name to the name of the target listed after it, next.
|
|
||||||
func targetRow(list, name, next string) string {
|
|
||||||
_, row, _ := strings.Cut(list, name+" ")
|
|
||||||
row, _, _ = strings.Cut(row, next+" ")
|
|
||||||
|
|
||||||
return row
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertRetryingNotWaiting checks that the event log and each event's
|
|
||||||
// page show the http target's delivery of the event as retrying, and
|
|
||||||
// none of them as waiting.
|
|
||||||
func assertRetryingNotWaiting(
|
|
||||||
t *testing.T,
|
|
||||||
h *handlers.Handlers,
|
|
||||||
sess *session.Session,
|
|
||||||
webhookID string,
|
|
||||||
events ...*database.Event,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
log := renderSourceLogsPage(t, h, sess, webhookID)
|
|
||||||
assert.Equal(t, len(events), strings.Count(log, "t-http: retrying"))
|
|
||||||
assert.NotContains(t, log, "waiting")
|
|
||||||
|
|
||||||
for _, event := range events {
|
|
||||||
page := eventPage(t, h, sess, webhookID, event.ID)
|
|
||||||
assert.Contains(t, page, ">retrying</span>")
|
|
||||||
assert.NotContains(t, page, "waiting")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// addDelivery records a delivery of the event to the target, with the
|
|
||||||
// given status, in the webhook's own database, and returns its ID.
|
|
||||||
func addDelivery(
|
|
||||||
t *testing.T,
|
|
||||||
dbMgr *database.WebhookDBManager,
|
|
||||||
webhookID, eventID, targetID string,
|
|
||||||
status database.DeliveryStatus,
|
|
||||||
) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
webhookDB, err := dbMgr.GetDB(webhookID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
dlv := &database.Delivery{
|
|
||||||
EventID: eventID,
|
|
||||||
TargetID: targetID,
|
|
||||||
Status: status,
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, webhookDB.Omit(clause.Associations).Create(
|
|
||||||
dlv,
|
|
||||||
).Error)
|
|
||||||
|
|
||||||
return dlv.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
// addFailedAttempt records the delivery's failed attempt attemptNum,
|
|
||||||
// made at the given time.
|
|
||||||
func addFailedAttempt(
|
|
||||||
t *testing.T,
|
|
||||||
dbMgr *database.WebhookDBManager,
|
|
||||||
webhookID, deliveryID string,
|
|
||||||
attemptNum int,
|
|
||||||
at time.Time,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
webhookDB, err := dbMgr.GetDB(webhookID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.NoError(t, webhookDB.Omit(clause.Associations).Create(
|
|
||||||
&database.DeliveryResult{
|
|
||||||
BaseModel: database.BaseModel{CreatedAt: at},
|
|
||||||
DeliveryID: deliveryID,
|
|
||||||
AttemptNum: attemptNum,
|
|
||||||
Error: "connection refused",
|
|
||||||
},
|
|
||||||
).Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// eventPage runs the real event page handler and returns the
|
|
||||||
// rendered HTML.
|
|
||||||
func eventPage(
|
|
||||||
t *testing.T,
|
|
||||||
h *handlers.Handlers,
|
|
||||||
sess *session.Session,
|
|
||||||
webhookID, eventID string,
|
|
||||||
) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
w := serveEventPage(t, h, sess, webhookID, eventID)
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
return w.Body.String()
|
|
||||||
}
|
|
||||||
@@ -44,9 +44,9 @@ func TestTargetRefusal_PrivateDestinationSaysHowToAllowIt(
|
|||||||
form.Set("type", string(targetType))
|
form.Set("type", string(targetType))
|
||||||
form.Set("url", editBlockedURL)
|
form.Set("url", editBlockedURL)
|
||||||
|
|
||||||
// A refused add shows the webhook page again, and a
|
// A refused add shows the webhook page again, where
|
||||||
// refused edit the edit page, where the hint is
|
// the hint is HTML-escaped; a refused edit answers in
|
||||||
// HTML-escaped.
|
// plain text.
|
||||||
added := serveTarget(
|
added := serveTarget(
|
||||||
env, http.MethodPost, targetsPath, form,
|
env, http.MethodPost, targetsPath, form,
|
||||||
)
|
)
|
||||||
@@ -76,8 +76,7 @@ func TestTargetRefusal_PrivateDestinationSaysHowToAllowIt(
|
|||||||
)
|
)
|
||||||
assert.Equal(t, http.StatusBadRequest, edited.Code)
|
assert.Equal(t, http.StatusBadRequest, edited.Code)
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, edited.Body.String(),
|
t, edited.Body.String(), privateRefusalHint,
|
||||||
html.EscapeString(privateRefusalHint),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -25,14 +26,14 @@ var (
|
|||||||
// errRetriesInvalid signals a max_retries form value that is not
|
// errRetriesInvalid signals a max_retries form value that is not
|
||||||
// a non-negative whole number.
|
// a non-negative whole number.
|
||||||
errRetriesInvalid = errors.New(
|
errRetriesInvalid = errors.New(
|
||||||
"must be a whole number",
|
"retries must be a whole number of attempts",
|
||||||
)
|
)
|
||||||
|
|
||||||
// errRetriesTooLarge signals a max_retries form value that is a
|
// errRetriesTooLarge signals a max_retries form value that is a
|
||||||
// whole number but above maxTargetRetries. It is distinguished
|
// whole number but above maxTargetRetries. It is distinguished
|
||||||
// from errRetriesInvalid so the message can name the ceiling
|
// from errRetriesInvalid so the message can name the ceiling
|
||||||
// instead of implying the input was not a number.
|
// instead of implying the input was not a number.
|
||||||
errRetriesTooLarge = errors.New("out of range")
|
errRetriesTooLarge = errors.New("retries out of range")
|
||||||
)
|
)
|
||||||
|
|
||||||
// parseMaxRetries interprets a max_retries form value.
|
// parseMaxRetries interprets a max_retries form value.
|
||||||
@@ -82,9 +83,38 @@ func retriesErrorMessage(err error) string {
|
|||||||
if errors.Is(err, errRetriesTooLarge) {
|
if errors.Is(err, errRetriesTooLarge) {
|
||||||
return errRetriesTooLarge.Error() +
|
return errRetriesTooLarge.Error() +
|
||||||
": at most " + strconv.Itoa(maxTargetRetries) +
|
": at most " + strconv.Itoa(maxTargetRetries) +
|
||||||
" attempts"
|
" retries"
|
||||||
}
|
}
|
||||||
|
|
||||||
return errRetriesInvalid.Error() +
|
return errRetriesInvalid.Error() +
|
||||||
", or 0 for fire-and-forget"
|
", or 0 for fire-and-forget"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// targetMaxRetries reads and validates max_retries from a target edit
|
||||||
|
// submission, answering the request with a 400 and reporting false
|
||||||
|
// when the value is set but invalid.
|
||||||
|
//
|
||||||
|
// It and the create path (newTarget) both use parseMaxRetries and
|
||||||
|
// retriesErrorMessage, so the two cannot come to disagree about what a
|
||||||
|
// valid retry count is. The wording matches the timeout control on
|
||||||
|
// the same submission.
|
||||||
|
func targetMaxRetries(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
fallback int,
|
||||||
|
) (int, bool) {
|
||||||
|
retries, err := parseMaxRetries(
|
||||||
|
r.PostFormValue("max_retries"), fallback,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(
|
||||||
|
w,
|
||||||
|
"Invalid max retries: "+retriesErrorMessage(err),
|
||||||
|
http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return retries, true
|
||||||
|
}
|
||||||
|
|||||||
@@ -383,3 +383,20 @@ func TestTargetRetries_CreateAndEditAgreeOnEveryCase(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPageOrFirst_CoercesRatherThanRejects pins the one place a
|
||||||
|
// non-numeric form value legitimately falls back. A page number says
|
||||||
|
// where to send the browser after an action that has already
|
||||||
|
// happened, so it is not configuration and rejecting it would report
|
||||||
|
// a failure that did not occur.
|
||||||
|
func TestPageOrFirst_CoercesRatherThanRejects(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, s := range []string{"", "abc", "0", "-1", "2.7", " "} {
|
||||||
|
assert.Equal(t, 1, handlers.PageOrFirstForTest(s),
|
||||||
|
"%q should fall back to the first page", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, 4, handlers.PageOrFirstForTest("4"))
|
||||||
|
assert.Equal(t, 4, handlers.PageOrFirstForTest(" 4 "))
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import (
|
|||||||
const (
|
const (
|
||||||
dataKeyWebhook = "Webhook"
|
dataKeyWebhook = "Webhook"
|
||||||
dataKeyError = "Error"
|
dataKeyError = "Error"
|
||||||
dataKeyEvents = "Events"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// testWebhookID is the identifier given to the webhook under test on
|
// testWebhookID is the identifier given to the webhook under test on
|
||||||
@@ -145,10 +144,9 @@ func TestEventLogPageIsCalledFullEventLog(t *testing.T) {
|
|||||||
t.Cleanup(app.RequireStop)
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
// A pointer, as in the handlers: source_detail.html calls
|
// A pointer, as in the handlers: source_detail.html calls
|
||||||
// Webhook.RetentionLabel, a pointer method. The webhook page only
|
// Webhook.RetentionLabel, a pointer method. Both pages only range
|
||||||
// ranges over its lists, and a list left out renders as empty, so
|
// over their lists, and a list left out renders as empty, so the
|
||||||
// its lists are left out. The event log also counts its events, so
|
// lists are left out.
|
||||||
// it gets an empty list.
|
|
||||||
webhook := &database.Webhook{Name: "wh", RetentionDays: 14}
|
webhook := &database.Webhook{Name: "wh", RetentionDays: 14}
|
||||||
webhook.ID = testWebhookID
|
webhook.ID = testWebhookID
|
||||||
|
|
||||||
@@ -171,7 +169,6 @@ func TestEventLogPageIsCalledFullEventLog(t *testing.T) {
|
|||||||
|
|
||||||
logBody := renderPage(t, h, sess, "source_logs.html", map[string]any{
|
logBody := renderPage(t, h, sess, "source_logs.html", map[string]any{
|
||||||
dataKeyWebhook: webhook,
|
dataKeyWebhook: webhook,
|
||||||
dataKeyEvents: []handlers.EventLogView{},
|
|
||||||
"TotalEvents": int64(0),
|
"TotalEvents": int64(0),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -199,6 +196,8 @@ func TestCreateFormRetentionCopyMatchesBehaviour(t *testing.T) {
|
|||||||
t.Cleanup(app.RequireStop)
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
body := renderPage(t, h, sess, "sources_new.html", map[string]any{
|
body := renderPage(t, h, sess, "sources_new.html", map[string]any{
|
||||||
|
"Name": "",
|
||||||
|
"Description": "",
|
||||||
"DefaultRetentionDays": database.DefaultRetentionDays,
|
"DefaultRetentionDays": database.DefaultRetentionDays,
|
||||||
dataKeyError: "",
|
dataKeyError: "",
|
||||||
})
|
})
|
||||||
@@ -320,9 +319,9 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
|
|||||||
"Entrypoints": handlers.NewEntrypointViews(
|
"Entrypoints": handlers.NewEntrypointViews(
|
||||||
[]database.Entrypoint{entrypoint},
|
[]database.Entrypoint{entrypoint},
|
||||||
),
|
),
|
||||||
"Targets": delivery.NewTargetViews(nil),
|
"Targets": delivery.NewTargetViews(nil),
|
||||||
dataKeyEvents: []database.Event{},
|
"Events": []database.Event{},
|
||||||
"BaseURL": "https://hooks.example.com",
|
"BaseURL": "https://hooks.example.com",
|
||||||
})
|
})
|
||||||
|
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
@@ -354,14 +353,15 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
|
|||||||
// and target_http gives up once the attempt number reaches
|
// and target_http gives up once the attempt number reaches
|
||||||
// max_retries), and 0 is special-cased to a single fire-and-forget
|
// max_retries), and 0 is special-cased to a single fire-and-forget
|
||||||
// attempt with no circuit breaker.
|
// attempt with no circuit breaker.
|
||||||
const maxRetriesHelp = "How many times each delivery is attempted in " +
|
const maxRetriesHelp = "This is the total number of delivery attempts, " +
|
||||||
"all, the first attempt included. 0 means a single attempt with no " +
|
"not retries on top of the first: a value of 3 makes three attempts " +
|
||||||
"retries and no circuit breaker."
|
"in all. 0 means a single attempt with no retries and no circuit " +
|
||||||
|
"breaker."
|
||||||
|
|
||||||
// TestTargetFormMaxRetriesCopyMatchesBehaviour pins the max_retries
|
// TestTargetFormMaxRetriesCopyMatchesBehaviour pins the max_retries
|
||||||
// label, "Delivery attempts", and help text on both the create form
|
// help text on both the create form (the add-target form on the webhook
|
||||||
// (the add-target form on the webhook detail page) and the edit form,
|
// detail page) and the edit form, so the copy cannot drift back to
|
||||||
// so the copy cannot drift back to calling the number a retry count.
|
// calling the number a retry count.
|
||||||
func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
|
func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -386,66 +386,40 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
|
|||||||
"Entrypoints": handlers.NewEntrypointViews(
|
"Entrypoints": handlers.NewEntrypointViews(
|
||||||
[]database.Entrypoint{entrypoint},
|
[]database.Entrypoint{entrypoint},
|
||||||
),
|
),
|
||||||
"Targets": delivery.NewTargetViews(nil),
|
"Targets": delivery.NewTargetViews(nil),
|
||||||
dataKeyEvents: []database.Event{},
|
"Events": []database.Event{},
|
||||||
"BaseURL": "https://hooks.example.com",
|
"BaseURL": "https://hooks.example.com",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Contains(t, createBody, "Delivery attempts:</label>")
|
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, createBody, maxRetriesHelp,
|
t, createBody, maxRetriesHelp,
|
||||||
"the add-target form must explain max_retries as total attempts",
|
"the add-target form must explain max_retries as total attempts",
|
||||||
)
|
)
|
||||||
|
|
||||||
// A slack target exercises the same max_retries field while needing
|
// A slack target exercises the same max_retries field while needing
|
||||||
// only a URL from the edit template, so the test data stays
|
// only Config.URL from the edit template, so the test data stays
|
||||||
// minimal. The Target and TargetForm keys mirror the field names
|
// minimal. The Target key mirrors the field names the template reads
|
||||||
// the template reads off the handler's values.
|
// off the handler's view value.
|
||||||
editBody := renderPage(
|
editBody := renderPage(
|
||||||
t, h, sess, "target_edit.html", map[string]any{
|
t, h, sess, "target_edit.html", map[string]any{
|
||||||
dataKeyWebhook: webhook,
|
dataKeyWebhook: webhook,
|
||||||
"Target": map[string]any{
|
"Target": map[string]any{
|
||||||
"ID": "tg-1",
|
"ID": "tg-1",
|
||||||
"Name": "t",
|
"Name": "t",
|
||||||
"Type": "slack",
|
"Type": "slack",
|
||||||
"Active": true,
|
"Active": true,
|
||||||
},
|
"MaxRetries": 3,
|
||||||
"TargetForm": map[string]any{
|
"Config": map[string]any{
|
||||||
"URL": "https://hooks.slack.com/services/x",
|
"URL": "https://hooks.slack.com/services/x",
|
||||||
"MaxRetries": "3",
|
},
|
||||||
},
|
},
|
||||||
dataKeyError: "",
|
dataKeyError: "",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Contains(t, editBody, `class="label">Delivery attempts</label>`)
|
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, editBody, maxRetriesHelp,
|
t, editBody, maxRetriesHelp,
|
||||||
"the target edit form must explain max_retries as total attempts",
|
"the target edit form must explain max_retries as total attempts",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCreateFormCallsTheDatabaseTargetAnArchive pins the names the new
|
|
||||||
// webhook page gives the database target its Archive checkbox creates,
|
|
||||||
// and that target's settings, to the ones the target forms use.
|
|
||||||
func TestCreateFormCallsTheDatabaseTargetAnArchive(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var h *handlers.Handlers
|
|
||||||
|
|
||||||
var sess *session.Session
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
body := renderPage(t, h, sess, "sources_new.html", map[string]any{
|
|
||||||
dataKeyError: "",
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Contains(t, body, "created with an archive target")
|
|
||||||
assert.Contains(t, body, `class="label">Archive expiry</label>`)
|
|
||||||
assert.Contains(t, body, `class="label">Archive rotation</label>`)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -326,19 +326,6 @@ func (h *Handlers) createAndFanOut(
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// A resubmitted copy did not arrive on its entrypoint's URL, so it
|
|
||||||
// leaves the entrypoint's last event as it is.
|
|
||||||
if src.ResubmittedFromID == nil {
|
|
||||||
err = database.AddEntrypointTotals(tx, database.EntrypointTotals{
|
|
||||||
EntrypointID: event.EntrypointID, LastEventAt: event.CreatedAt,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
tx.Rollback()
|
|
||||||
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = tx.Commit().Error
|
err = tx.Commit().Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf(
|
return nil, nil, fmt.Errorf(
|
||||||
|
|||||||
@@ -278,14 +278,12 @@ func (lrw *loggingResponseWriter) Unwrap() http.ResponseWriter {
|
|||||||
// after the '?'. Keeping the path and dropping the query is what makes
|
// after the '?'. Keeping the path and dropping the query is what makes
|
||||||
// this branch as bounded as the pattern branches below.
|
// this branch as bounded as the pattern branches below.
|
||||||
//
|
//
|
||||||
// Nothing debuggable is lost. The only query parameters the service
|
// Nothing debuggable is lost. One route in the service reads a query
|
||||||
// reads are the login page's `next`, the page to return to,
|
// parameter at all — `page`, on the authenticated pagination links in
|
||||||
// `notice`, which names the line a page shows after an action, and
|
// internal/handlers/source_management.go — and the alternatives that
|
||||||
// the event log's `show`, which picks the events it lists. The
|
// would preserve more (a key count, a key allowlist) all require
|
||||||
// alternatives that would preserve more (a key count, a key
|
// parsing an attacker-sized query on every request, which is work an
|
||||||
// allowlist) all require parsing an attacker-sized query on every
|
// unauthenticated client would then be choosing for us.
|
||||||
// request, which is work an unauthenticated client would then be
|
|
||||||
// choosing for us.
|
|
||||||
func concreteLogURL(r *http.Request) string {
|
func concreteLogURL(r *http.Request) string {
|
||||||
path := r.URL.EscapedPath()
|
path := r.URL.EscapedPath()
|
||||||
|
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ func checkDataDir(dir string) error {
|
|||||||
|
|
||||||
dbPath := filepath.Join(dir, database.MainDBFileName)
|
dbPath := filepath.Join(dir, database.MainDBFileName)
|
||||||
|
|
||||||
dbInfo, err := os.Stat(dbPath)
|
_, err = os.Stat(dbPath)
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, fs.ErrNotExist):
|
case errors.Is(err, fs.ErrNotExist):
|
||||||
@@ -319,15 +319,6 @@ func checkDataDir(dir string) error {
|
|||||||
)
|
)
|
||||||
case err != nil:
|
case err != nil:
|
||||||
return fmt.Errorf("checking %s: %w", dbPath, err)
|
return fmt.Errorf("checking %s: %w", dbPath, err)
|
||||||
case dbInfo.Size() == 0:
|
|
||||||
// SQLite opens a zero-length file as an empty database, so
|
|
||||||
// it holds no deployment either, and opening it would write
|
|
||||||
// an empty schema into it.
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%w: %s is zero-length. The admin account is created by "+
|
|
||||||
"the first server start",
|
|
||||||
ErrNoDatabase, dbPath,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -143,14 +142,6 @@ func (n *noopArchives) Rename(_, _, _ string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type noopCircuitBreakers struct{}
|
|
||||||
|
|
||||||
func (n *noopCircuitBreakers) StateAndCooldown(
|
|
||||||
string,
|
|
||||||
) (delivery.CircuitState, time.Duration) {
|
|
||||||
return delivery.CircuitClosed, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// newServerApp starts the real login path against dir: the handlers,
|
// newServerApp starts the real login path against dir: the handlers,
|
||||||
// the middleware that bounds password verification, the session store
|
// the middleware that bounds password verification, the session store
|
||||||
// and the database, exactly as internal/handlers builds them.
|
// and the database, exactly as internal/handlers builds them.
|
||||||
@@ -183,9 +174,6 @@ func newServerApp(
|
|||||||
session.New,
|
session.New,
|
||||||
func() delivery.Notifier { return &noopNotifier{} },
|
func() delivery.Notifier { return &noopNotifier{} },
|
||||||
func() delivery.Archives { return &noopArchives{} },
|
func() delivery.Archives { return &noopArchives{} },
|
||||||
func() delivery.CircuitBreakers {
|
|
||||||
return &noopCircuitBreakers{}
|
|
||||||
},
|
|
||||||
metrics.NewRegistry,
|
metrics.NewRegistry,
|
||||||
metrics.New,
|
metrics.New,
|
||||||
middleware.New,
|
middleware.New,
|
||||||
@@ -389,33 +377,6 @@ func TestMissingDatabaseCreatesNothing(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestZeroLengthDatabaseCreatesNothing covers a webhooker.db left at
|
|
||||||
// zero length, as a truncated copy leaves it. SQLite would open it as
|
|
||||||
// an empty database, so it is refused like a missing one and left as
|
|
||||||
// it is.
|
|
||||||
func TestZeroLengthDatabaseCreatesNothing(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
t.Setenv("DATA_DIR", dir)
|
|
||||||
|
|
||||||
dbPath := filepath.Join(dir, database.MainDBFileName)
|
|
||||||
require.NoError(
|
|
||||||
t, os.WriteFile(dbPath, nil, database.SQLiteFilePerm),
|
|
||||||
)
|
|
||||||
|
|
||||||
code, _, stderr := run(t, newPassword+"\n", operatorUser)
|
|
||||||
|
|
||||||
require.Equal(t, exitFailure, code)
|
|
||||||
assert.Contains(t, stderr, dbPath)
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, entries, 1, "nothing may be created beside it")
|
|
||||||
|
|
||||||
info, err := os.Stat(dbPath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Zero(t, info.Size(), "nothing may be written into it")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUnknownUserFails states the decision: resetpw changes an
|
// TestUnknownUserFails states the decision: resetpw changes an
|
||||||
// existing account's password and never creates an account. A typo in
|
// existing account's password and never creates an account. A typo in
|
||||||
// the username must say so rather than quietly adding a second user.
|
// the username must say so rather than quietly adding a second user.
|
||||||
|
|||||||
@@ -18,13 +18,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/chromedp/cdproto/browser"
|
"github.com/chromedp/cdproto/browser"
|
||||||
"github.com/chromedp/cdproto/dom"
|
|
||||||
"github.com/chromedp/cdproto/input"
|
|
||||||
"github.com/chromedp/cdproto/log"
|
"github.com/chromedp/cdproto/log"
|
||||||
"github.com/chromedp/cdproto/network"
|
"github.com/chromedp/cdproto/network"
|
||||||
"github.com/chromedp/cdproto/runtime"
|
"github.com/chromedp/cdproto/runtime"
|
||||||
"github.com/chromedp/chromedp"
|
"github.com/chromedp/chromedp"
|
||||||
"github.com/chromedp/chromedp/kb"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
@@ -42,26 +39,12 @@ const (
|
|||||||
// the mobile menu button instead of the navigation links.
|
// the mobile menu button instead of the navigation links.
|
||||||
phoneWidth = 390
|
phoneWidth = 390
|
||||||
phoneHeight = 844
|
phoneHeight = 844
|
||||||
|
|
||||||
// A window short enough that the event log scrolls with its last
|
|
||||||
// event expanded, and tall enough to show all of that event.
|
|
||||||
shortWidth = 1024
|
|
||||||
shortHeight = 450
|
|
||||||
|
|
||||||
// A person's double- or triple-click: each press is held a tenth of a
|
|
||||||
// second, and the next press comes a quarter second after the release.
|
|
||||||
pressHeld = 100 * time.Millisecond
|
|
||||||
betweenClicks = 250 * time.Millisecond
|
|
||||||
|
|
||||||
// olderBody is the body of the event received before the newest.
|
|
||||||
olderBody = "the older event"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
|
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
|
||||||
// event log in a headless browser, served by the real router and so
|
// event log in a headless browser, served by the real router and so
|
||||||
// under the real Content-Security-Policy, and checks that the pages'
|
// under the real Content-Security-Policy, and checks that the pages'
|
||||||
// Alpine.js directives and the copy control work, and that a target's
|
// Alpine.js directives and the copy control work.
|
||||||
// row shows its delivery counts.
|
|
||||||
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -72,45 +55,6 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
|||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
userID, _ := env.seedUser(t, "browser", "browser-password")
|
userID, _ := env.seedUser(t, "browser", "browser-password")
|
||||||
webhook, older, event, target := seedBrowserWebhook(t, env, userID)
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")),
|
|
||||||
))
|
|
||||||
|
|
||||||
page := srv.URL + "/hook/" + webhook.ID
|
|
||||||
|
|
||||||
// The checks share one browser tab, so they run one at a time, in
|
|
||||||
// this order. A new check is one more line here.
|
|
||||||
checkAddEntrypoint(ctx, t, page)
|
|
||||||
checkAddEachTargetType(ctx, t, page)
|
|
||||||
checkArchiveChoices(ctx, t, page)
|
|
||||||
checkRefusedTarget(ctx, t, page)
|
|
||||||
checkTargetDeliveries(ctx, t, page, target.Name,
|
|
||||||
"0 in total, 0 in the last 24 hours",
|
|
||||||
"1 in total, 1 in the last 24 hours")
|
|
||||||
checkRefusedEdits(ctx, t, page, target.ID)
|
|
||||||
checkCopy(ctx, t, page)
|
|
||||||
checkEntrypointEdit(ctx, t, page, page+"/events")
|
|
||||||
checkRecentEvents(ctx, t, page)
|
|
||||||
checkArchiveChoice(ctx, t, srv.URL+"/hooks/new", page)
|
|
||||||
checkNewWebhookTargets(ctx, t, env, srv.URL+"/hooks/new")
|
|
||||||
checkRefusedNewWebhook(ctx, t, srv.URL+"/hooks/new")
|
|
||||||
checkEventLog(ctx, t, page+"/events", event.ID, older.ID, target.Name)
|
|
||||||
checkMobileMenu(ctx, t, page)
|
|
||||||
|
|
||||||
assert.Empty(t, problems(), "the browser reported problems")
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedBrowserWebhook seeds the webhook the browser test loads, owned by
|
|
||||||
// userID: an entrypoint, two events, and a target whose delivery of the
|
|
||||||
// newer event failed once with a 502. It returns the webhook, the older
|
|
||||||
// and the newer event, and the target.
|
|
||||||
func seedBrowserWebhook(
|
|
||||||
t *testing.T, env *testEnv, userID string,
|
|
||||||
) (*database.Webhook, *database.Event, *database.Event, *database.Target) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
webhook := env.seedWebhook(t, userID)
|
webhook := env.seedWebhook(t, userID)
|
||||||
require.NoError(t, env.db.DB().Omit(clause.Associations).Create(
|
require.NoError(t, env.db.DB().Omit(clause.Associations).Create(
|
||||||
&database.Entrypoint{
|
&database.Entrypoint{
|
||||||
@@ -119,7 +63,6 @@ func seedBrowserWebhook(
|
|||||||
Active: true,
|
Active: true,
|
||||||
},
|
},
|
||||||
).Error)
|
).Error)
|
||||||
older := env.seedEvent(t, webhook.ID, olderBody)
|
|
||||||
event := env.seedEvent(t, webhook.ID, `{"hello":"browser"}`)
|
event := env.seedEvent(t, webhook.ID, `{"hello":"browser"}`)
|
||||||
target := env.seedTarget(t, webhook.ID)
|
target := env.seedTarget(t, webhook.ID)
|
||||||
dlv := env.seedFailedDelivery(t, webhook.ID, event.ID, target.ID)
|
dlv := env.seedFailedDelivery(t, webhook.ID, event.ID, target.ID)
|
||||||
@@ -134,7 +77,48 @@ func seedBrowserWebhook(
|
|||||||
},
|
},
|
||||||
).Error)
|
).Error)
|
||||||
|
|
||||||
return webhook, older, event, target
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")),
|
||||||
|
))
|
||||||
|
|
||||||
|
page := srv.URL + "/hook/" + webhook.ID
|
||||||
|
|
||||||
|
checkAddEntrypoint(ctx, t, page)
|
||||||
|
|
||||||
|
// Each target type, with the fields its add target form submits, in
|
||||||
|
// page order. Only http and slack have a url field.
|
||||||
|
targetTypes := []struct {
|
||||||
|
name string
|
||||||
|
fields string
|
||||||
|
values map[string]string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"http", "csrf_token name type url headers timeout max_retries",
|
||||||
|
map[string]string{"url": publicTargetURL},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slack", "csrf_token name type url max_retries",
|
||||||
|
map[string]string{"url": publicTargetURL},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"database", "csrf_token name type expiry",
|
||||||
|
map[string]string{"expiry": "720h"},
|
||||||
|
},
|
||||||
|
{"log", "csrf_token name type", nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range targetTypes {
|
||||||
|
checkAddTarget(
|
||||||
|
ctx, t, page, tt.name, strings.Fields(tt.fields), tt.values,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkRefusedTarget(ctx, t, page)
|
||||||
|
checkCopy(ctx, t, page)
|
||||||
|
checkEventLog(ctx, t, page+"/events", event.ID, target.Name)
|
||||||
|
checkMobileMenu(ctx, t, page)
|
||||||
|
|
||||||
|
assert.Empty(t, problems(), "the browser reported problems")
|
||||||
}
|
}
|
||||||
|
|
||||||
// startBrowser starts a headless browser for one test. It returns the
|
// startBrowser starts a headless browser for one test. It returns the
|
||||||
@@ -313,55 +297,17 @@ const (
|
|||||||
document.querySelector('form[action$="/targets"]')).keys()]`
|
document.querySelector('form[action$="/targets"]')).keys()]`
|
||||||
)
|
)
|
||||||
|
|
||||||
// checkAddEachTargetType runs checkAddTarget on a webhook page for each
|
|
||||||
// target type, in page order.
|
|
||||||
func checkAddEachTargetType(ctx context.Context, t *testing.T, url string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
// Each target type, with the badge its targets are listed with and
|
|
||||||
// the fields its add target form submits, in page order. Only http
|
|
||||||
// and slack have a url field.
|
|
||||||
targetTypes := []struct {
|
|
||||||
name string
|
|
||||||
badge string
|
|
||||||
fields string
|
|
||||||
values map[string]string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
"http", "http",
|
|
||||||
"csrf_token name type url headers timeout max_retries",
|
|
||||||
map[string]string{"url": publicTargetURL},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"slack", "slack", "csrf_token name type url max_retries",
|
|
||||||
map[string]string{"url": publicTargetURL},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"database", "archive", "csrf_token name type expiry rotation",
|
|
||||||
map[string]string{"expiry": "720h", "rotation": "daily"},
|
|
||||||
},
|
|
||||||
{"log", "log", "csrf_token name type", nil},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range targetTypes {
|
|
||||||
checkAddTarget(
|
|
||||||
ctx, t, url, tt.name, tt.badge,
|
|
||||||
strings.Fields(tt.fields), tt.values,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkAddTarget loads a webhook page and walks the add target form for
|
// checkAddTarget loads a webhook page and walks the add target form for
|
||||||
// one target type. The form shows nothing until Add is clicked; Add
|
// one target type. The form shows nothing until Add is clicked; Add
|
||||||
// shows only the type choice; Cancel there closes it; Next shows the
|
// shows only the type choice; Cancel there closes it; Next shows the
|
||||||
// type's own fields in place of the choice, and the form then submits
|
// type's own fields in place of the choice, and the form then submits
|
||||||
// exactly fields, so a field another type uses, such as url, is absent;
|
// exactly fields, so a field another type uses, such as url, is absent;
|
||||||
// Cancel closes it again. It then adds a target of the type, filling in
|
// Cancel closes it again. It then adds a target of the type, filling in
|
||||||
// values, and checks that the section lists it with badge.
|
// values, and checks that the section lists it with that type.
|
||||||
func checkAddTarget(
|
func checkAddTarget(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
url, targetType, badge string,
|
url, targetType string,
|
||||||
fields []string,
|
fields []string,
|
||||||
values map[string]string,
|
values map[string]string,
|
||||||
) {
|
) {
|
||||||
@@ -416,8 +362,8 @@ func checkAddTarget(
|
|||||||
|
|
||||||
click(ctx, t, saveButton)
|
click(ctx, t, saveButton)
|
||||||
assert.Truef(t, shown(ctx, `//span[text()="`+name+
|
assert.Truef(t, shown(ctx, `//span[text()="`+name+
|
||||||
`"]/following-sibling::div/span[text()="`+badge+`"]`),
|
`"]/following-sibling::div/span[text()="`+targetType+`"]`),
|
||||||
"%s: the added target is not listed as %s", targetType, badge)
|
"%s: the added target is not listed with its type", targetType)
|
||||||
}
|
}
|
||||||
|
|
||||||
// chooseTargetType clicks Add, picks targetType and clicks Next, and
|
// chooseTargetType clicks Add, picks targetType and clicks Next, and
|
||||||
@@ -439,57 +385,6 @@ func chooseTargetType(ctx context.Context, t *testing.T, targetType string) {
|
|||||||
"%s: Add still shows while the form is open", targetType)
|
"%s: Add still shows while the form is open", targetType)
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkArchiveChoices loads a webhook page and checks that the add
|
|
||||||
// target form's archive expiry starts on never and its archive
|
|
||||||
// rotation on none, that the database target checkAddTarget added with
|
|
||||||
// 720h and daily is listed as 30 days and daily, and that its edit
|
|
||||||
// form starts on 720h and daily.
|
|
||||||
func checkArchiveChoices(ctx context.Context, t *testing.T, url string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
const (
|
|
||||||
expiry = `form[action$="/targets"] select[name="expiry"]`
|
|
||||||
rotation = `form[action$="/targets"] select[name="rotation"]`
|
|
||||||
)
|
|
||||||
|
|
||||||
row := `//span[text()="added-database"]/ancestor::div[@class="p-4"][1]`
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
|
||||||
|
|
||||||
chooseTargetType(ctx, t, "database")
|
|
||||||
|
|
||||||
var startExpiry, startRotation, editedExpiry, editedRotation string
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx,
|
|
||||||
chromedp.Value(expiry, &startExpiry, chromedp.ByQuery),
|
|
||||||
chromedp.Value(rotation, &startRotation, chromedp.ByQuery),
|
|
||||||
))
|
|
||||||
assert.Equal(t, "never", startExpiry,
|
|
||||||
"the add target form's archive expiry does not start on never")
|
|
||||||
assert.Equal(t, "none", startRotation,
|
|
||||||
"the add target form's archive rotation does not start on none")
|
|
||||||
|
|
||||||
assert.True(t, shown(ctx, row+`//span[text()="Archive expiry:"]`+
|
|
||||||
`/following-sibling::span[text()="30 days"]`),
|
|
||||||
"a database target added with 720h is not listed as 30 days")
|
|
||||||
assert.True(t, shown(ctx, row+`//span[text()="Archive rotation:"]`+
|
|
||||||
`/following-sibling::span[text()="daily"]`),
|
|
||||||
"a database target added with daily is not listed as daily")
|
|
||||||
|
|
||||||
click(ctx, t, row+`//a[text()="Edit"]`)
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx,
|
|
||||||
chromedp.WaitReady("#expiry", chromedp.ByQuery),
|
|
||||||
chromedp.Value("#expiry", &editedExpiry, chromedp.ByQuery),
|
|
||||||
chromedp.Value("#rotation", &editedRotation, chromedp.ByQuery),
|
|
||||||
))
|
|
||||||
assert.Equal(t, "720h", editedExpiry,
|
|
||||||
"the edit form does not start on the stored archive expiry")
|
|
||||||
assert.Equal(t, "daily", editedRotation,
|
|
||||||
"the edit form does not start on the stored archive rotation")
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkRefusedTarget submits an http target the server refuses, a
|
// checkRefusedTarget submits an http target the server refuses, a
|
||||||
// loopback destination, and checks that the page comes back with the
|
// loopback destination, and checks that the page comes back with the
|
||||||
// form open on the http fields, the values entered and the reason, and
|
// form open on the http fields, the values entered and the reason, and
|
||||||
@@ -551,86 +446,6 @@ func checkRefusedTarget(ctx context.Context, t *testing.T, url string) {
|
|||||||
assert.Empty(t, typed, "after Cancel, the next Add keeps the url entered")
|
assert.Empty(t, typed, "after Cancel, the next Add keeps the url entered")
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkTargetDeliveries loads a webhook page and checks that the row of
|
|
||||||
// the target named name shows delivered and failed beside its
|
|
||||||
// "Delivered:" and "Failed:" labels.
|
|
||||||
func checkTargetDeliveries(
|
|
||||||
ctx context.Context, t *testing.T, url, name, delivered, failed string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
row := `//span[text()="` + name + `"]/ancestor::div[@class="p-4"][1]`
|
|
||||||
figure := func(label, value string) string {
|
|
||||||
return row + `//span[text()="` + label +
|
|
||||||
`"]/following-sibling::span[text()="` + value + `"]`
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
|
||||||
|
|
||||||
assert.Truef(t, shown(ctx, figure("Delivered:", delivered)),
|
|
||||||
"the row of %s does not show %q delivered", name, delivered)
|
|
||||||
assert.Truef(t, shown(ctx, figure("Failed:", failed)),
|
|
||||||
"the row of %s does not show %q failed", name, failed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkRefusedEdits fills in the target edit page and the webhook edit
|
|
||||||
// page of a webhook page with values the server refuses, a loopback
|
|
||||||
// destination and a retention above the longest finite one, which the
|
|
||||||
// browser lets through. It saves each and checks that the page comes
|
|
||||||
// back with the reason and every value still in its field. The values
|
|
||||||
// are keyed by the id of their field.
|
|
||||||
func checkRefusedEdits(
|
|
||||||
ctx context.Context, t *testing.T, page, targetID string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
const reason = `//div[@class="alert-error"]`
|
|
||||||
|
|
||||||
edits := []struct {
|
|
||||||
url string
|
|
||||||
values map[string]string
|
|
||||||
}{
|
|
||||||
{page + "/targets/" + targetID + "/edit", map[string]string{
|
|
||||||
"#name": "edited-target",
|
|
||||||
"#url": "http://127.0.0.1/hook",
|
|
||||||
"#headers": "X-Edited: kept",
|
|
||||||
"#timeout": "12",
|
|
||||||
"#max_retries": "3",
|
|
||||||
}},
|
|
||||||
{page + "/edit", map[string]string{
|
|
||||||
"#name": "edited-webhook",
|
|
||||||
"#description": "kept description",
|
|
||||||
"#retention_days": "200000",
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, edit := range edits {
|
|
||||||
require.NoError(t, chromedp.Run(ctx, loadPage(edit.url)))
|
|
||||||
|
|
||||||
for field, value := range edit.values {
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx, chromedp.SetValue(field, value, chromedp.ByQuery),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
click(ctx, t, `//button[text()="Save Changes"]`)
|
|
||||||
|
|
||||||
assert.Truef(t, shown(ctx, reason),
|
|
||||||
"%s: a refused save does not show the reason", edit.url)
|
|
||||||
|
|
||||||
for field, value := range edit.values {
|
|
||||||
var kept string
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx, chromedp.Value(field, &kept, chromedp.ByQuery),
|
|
||||||
))
|
|
||||||
assert.Equalf(t, value, kept,
|
|
||||||
"%s: a refused save does not keep the %s entered",
|
|
||||||
edit.url, field)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkCopy loads a webhook page and checks that the Copy control beside
|
// checkCopy loads a webhook page and checks that the Copy control beside
|
||||||
// its entrypoint's URL is a button, and that clicking it copies the URL
|
// its entrypoint's URL is a button, and that clicking it copies the URL
|
||||||
// and says so: the button reads "Copied" only once the copy succeeded.
|
// and says so: the button reads "Copied" only once the copy succeeded.
|
||||||
@@ -656,172 +471,28 @@ func checkCopy(ctx context.Context, t *testing.T, url string) {
|
|||||||
`clicking Copy does not show "Copied"`)
|
`clicking Copy does not show "Copied"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkEntrypointEdit loads a webhook page whose entrypoint has no
|
// checkEventLog loads the event log and checks that clicking an event's
|
||||||
// description, and checks that Edit shows the edit form in place of
|
// row expands it, that in there clicking its delivery shows the
|
||||||
// the description and hides until the form closes, so the form always
|
// delivery's attempts and clicking again hides them, and that clicking
|
||||||
// opens on the saved description; that Cancel hides it and drops what
|
// the event's row again collapses it.
|
||||||
// was typed; that after typing, opening the page at elsewhere and going
|
|
||||||
// back, Edit again opens the form on the saved description; and that
|
|
||||||
// Save changes the description the page shows.
|
|
||||||
func checkEntrypointEdit(
|
|
||||||
ctx context.Context, t *testing.T, url, elsewhere string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
// Cancel and Save are found inside the edit form, since the add
|
|
||||||
// target form has buttons of the same names.
|
|
||||||
const (
|
|
||||||
editForm = `form[action$="/edit"]`
|
|
||||||
input = editForm + ` input[name="description"]`
|
|
||||||
cancelEdit = `//form[contains(@action, "/edit")]/button[text()="Cancel"]`
|
|
||||||
saveEdit = `//form[contains(@action, "/edit")]/button[text()="Save"]`
|
|
||||||
description = `//span[text()="Entrypoint"]`
|
|
||||||
edit = `//button[text()="Edit"]`
|
|
||||||
)
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
|
||||||
|
|
||||||
assert.True(t, hidden(ctx, editForm),
|
|
||||||
"the edit form shows before Edit is clicked")
|
|
||||||
|
|
||||||
click(ctx, t, edit)
|
|
||||||
assert.True(t, shown(ctx, editForm),
|
|
||||||
"clicking Edit does not show the edit form")
|
|
||||||
assert.True(t, hidden(ctx, description),
|
|
||||||
"the description stays shown beside the edit form")
|
|
||||||
assert.True(t, hidden(ctx, edit),
|
|
||||||
"Edit stays shown while the edit form is open")
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx, chromedp.SendKeys(input, "draft", chromedp.ByQuery),
|
|
||||||
))
|
|
||||||
click(ctx, t, cancelEdit)
|
|
||||||
assert.True(t, hidden(ctx, editForm),
|
|
||||||
"clicking Cancel does not hide the edit form")
|
|
||||||
assert.True(t, shown(ctx, description),
|
|
||||||
"clicking Cancel does not show the description again")
|
|
||||||
assert.True(t, shown(ctx, edit),
|
|
||||||
"clicking Cancel does not show Edit again")
|
|
||||||
|
|
||||||
var typed string
|
|
||||||
|
|
||||||
click(ctx, t, edit)
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx, chromedp.Value(input, &typed, chromedp.ByQuery),
|
|
||||||
))
|
|
||||||
assert.Empty(t, typed, "Cancel keeps what was typed")
|
|
||||||
|
|
||||||
var loaded string
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx,
|
|
||||||
chromedp.SendKeys(input, "draft", chromedp.ByQuery),
|
|
||||||
loadPage(elsewhere),
|
|
||||||
chromedp.NavigateBack(),
|
|
||||||
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
|
|
||||||
chromedp.Evaluate(
|
|
||||||
`performance.getEntriesByType("navigation")[0].type`, &loaded,
|
|
||||||
),
|
|
||||||
))
|
|
||||||
require.Equal(
|
|
||||||
t, "back_forward", loaded,
|
|
||||||
"going back, the browser did not load the page again",
|
|
||||||
)
|
|
||||||
|
|
||||||
click(ctx, t, edit)
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx, chromedp.Value(input, &typed, chromedp.ByQuery),
|
|
||||||
))
|
|
||||||
assert.Empty(t, typed, "going back puts what was typed back in the form")
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx, chromedp.SendKeys(input, "Billing sender", chromedp.ByQuery),
|
|
||||||
))
|
|
||||||
click(ctx, t, saveEdit)
|
|
||||||
|
|
||||||
assert.True(t, shown(ctx, `//span[text()="Billing sender"]`),
|
|
||||||
"saving the edit form does not change the description")
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkRecentEvents loads a webhook page and checks that of its recent
|
|
||||||
// events only the newest starts expanded, showing its body, that
|
|
||||||
// clicking the older one's row expands it and clicking again collapses
|
|
||||||
// it, and that clicking the newest one's row collapses it. It then
|
|
||||||
// follows the newest one's Open link to the event's own page, which
|
|
||||||
// shows the body.
|
|
||||||
func checkRecentEvents(ctx context.Context, t *testing.T, url string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
// The newest event's body is pretty-printed JSON. Each row's
|
|
||||||
// toggle is the button in the element that holds its state.
|
|
||||||
newest := `//pre[contains(., '"hello": "browser"')]`
|
|
||||||
older := `//pre[text()="` + olderBody + `"]`
|
|
||||||
toggle := `/ancestor::div[@x-data][1]//button`
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
|
||||||
|
|
||||||
assert.True(t, shown(ctx, newest), "the newest event starts collapsed")
|
|
||||||
assert.True(t, hidden(ctx, older), "an older event starts expanded")
|
|
||||||
|
|
||||||
click(ctx, t, older+toggle)
|
|
||||||
assert.True(t, shown(ctx, older), "clicking an event does not expand it")
|
|
||||||
|
|
||||||
click(ctx, t, older+toggle)
|
|
||||||
assert.True(t, hidden(ctx, older),
|
|
||||||
"clicking an event again does not collapse it")
|
|
||||||
|
|
||||||
click(ctx, t, newest+toggle)
|
|
||||||
assert.True(t, hidden(ctx, newest),
|
|
||||||
"clicking the newest event does not collapse it")
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
|
||||||
click(ctx, t, newest+`/ancestor::div[@x-data][1]//a[text()="Open"]`)
|
|
||||||
|
|
||||||
assert.True(t, shown(ctx, `//h2[text()="Body"]`),
|
|
||||||
"Open does not lead to the event's own page")
|
|
||||||
assert.True(t, shown(ctx, newest),
|
|
||||||
"the event's own page does not show its body")
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkEventLog loads the event log and checks that of its events only
|
|
||||||
// the newest, eventID, starts expanded: its row says so and its caret is
|
|
||||||
// turned up, and the log's last event, lastEventID, starts collapsed. In
|
|
||||||
// the newest event, clicking its delivery shows the delivery's attempts
|
|
||||||
// and clicking again hides them. Clicking the row's caret collapses the
|
|
||||||
// event, clicking it again expands it, clicking its ID collapses it and
|
|
||||||
// clicking the ID again expands it. While the event is collapsed the row
|
|
||||||
// says so and its caret is turned down. It then runs checkEventSelection
|
|
||||||
// on lastEventID and checkEventKeyboard on eventID.
|
|
||||||
func checkEventLog(
|
func checkEventLog(
|
||||||
ctx context.Context,
|
ctx context.Context, t *testing.T, url, eventID, targetName string,
|
||||||
t *testing.T,
|
|
||||||
url, eventID, lastEventID, targetName string,
|
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
// The event's row shows its ID and ends with its caret, which turns
|
// The event's row shows its ID, and its Resubmit form is in the part
|
||||||
// up with Tailwind's rotate-180 class, and its Resubmit form is in
|
// that expands. The delivery's row there shows the target's name.
|
||||||
// the part that expands. The delivery's row there shows the target's
|
eventRow := `//span[text()="` + eventID + `"]`
|
||||||
// name.
|
expanded := `form[action$="/resubmit"]`
|
||||||
id := `//span[text()="` + eventID + `"]`
|
|
||||||
row := id + `/ancestor::div[@role="button"]`
|
|
||||||
caret := row + `//*[local-name()="svg"]`
|
|
||||||
caretUp := caret + `[contains(@class, "rotate-180")]`
|
|
||||||
caretDown := caret + `[not(contains(@class, "rotate-180"))]`
|
|
||||||
expanded := `form[action$="/` + eventID + `/resubmit"]`
|
|
||||||
lastExpanded := `form[action$="/` + lastEventID + `/resubmit"]`
|
|
||||||
deliveryRow := `//span[text()="` + targetName + `"]`
|
deliveryRow := `//span[text()="` + targetName + `"]`
|
||||||
attempt := `//span[text()="Attempt 1"]`
|
attempt := `//span[text()="Attempt 1"]`
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
||||||
|
|
||||||
assert.True(t, shown(ctx, expanded), "the newest event starts collapsed")
|
assert.True(t, hidden(ctx, expanded), "the event starts expanded")
|
||||||
assert.True(t, shown(ctx, row+`[@aria-expanded="true"]`),
|
|
||||||
"the expanded event's row does not say it is expanded")
|
click(ctx, t, eventRow)
|
||||||
assert.True(t, shown(ctx, caretUp),
|
assert.True(t, shown(ctx, expanded), "clicking the event does not expand it")
|
||||||
"the expanded event's caret does not turn up")
|
|
||||||
assert.True(t, hidden(ctx, lastExpanded),
|
|
||||||
"an older event starts expanded")
|
|
||||||
|
|
||||||
assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown")
|
assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown")
|
||||||
|
|
||||||
@@ -833,439 +504,9 @@ func checkEventLog(
|
|||||||
assert.True(t, hidden(ctx, attempt),
|
assert.True(t, hidden(ctx, attempt),
|
||||||
"clicking the delivery again does not hide its attempts")
|
"clicking the delivery again does not hide its attempts")
|
||||||
|
|
||||||
click(ctx, t, caret)
|
click(ctx, t, eventRow)
|
||||||
assert.True(t, hidden(ctx, expanded),
|
assert.True(t, hidden(ctx, expanded),
|
||||||
"clicking the caret does not collapse the event")
|
"clicking the event again does not collapse it")
|
||||||
assert.True(t, shown(ctx, row+`[@aria-expanded="false"]`),
|
|
||||||
"the collapsed event's row does not say it is collapsed")
|
|
||||||
assert.True(t, shown(ctx, caretDown),
|
|
||||||
"the collapsed event's caret stays turned up")
|
|
||||||
|
|
||||||
click(ctx, t, caret)
|
|
||||||
assert.True(t, shown(ctx, expanded),
|
|
||||||
"clicking the caret again does not expand the event")
|
|
||||||
|
|
||||||
click(ctx, t, id)
|
|
||||||
assert.True(t, hidden(ctx, expanded),
|
|
||||||
"clicking the event's ID does not collapse it")
|
|
||||||
|
|
||||||
click(ctx, t, id)
|
|
||||||
assert.True(t, shown(ctx, expanded),
|
|
||||||
"clicking the event's ID again does not expand it")
|
|
||||||
|
|
||||||
checkEventSelection(ctx, t, url, lastEventID)
|
|
||||||
checkEventKeyboard(ctx, t, url, eventID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkEventSelection loads the event log in a short window and checks
|
|
||||||
// that selecting the ID of its last event, eventID, with the mouse leaves
|
|
||||||
// the event as it was, and that its caret toggles it at once. Dragging
|
|
||||||
// over the ID leaves the event collapsed, and the caret's click expands
|
|
||||||
// it at once. With the page then scrolled to its end, a double-click on
|
|
||||||
// the ID that goes on to drag along it, and a triple-click on it, each
|
|
||||||
// leave the event expanded and select that ID. Had a click there
|
|
||||||
// collapsed the event, the page would have got shorter and moved under
|
|
||||||
// the pointer before the next click.
|
|
||||||
func checkEventSelection(
|
|
||||||
ctx context.Context, t *testing.T, url, eventID string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
id := `//span[text()="` + eventID + `"]`
|
|
||||||
row := id + `/ancestor::div[@role="button"]`
|
|
||||||
caret := row + `//*[local-name()="svg"]`
|
|
||||||
expanded := `form[action$="/` + eventID + `/resubmit"]`
|
|
||||||
|
|
||||||
var (
|
|
||||||
selected, state string
|
|
||||||
hasState bool
|
|
||||||
scrolled float64
|
|
||||||
)
|
|
||||||
|
|
||||||
// What is selected, and whether the event's row says it is expanded.
|
|
||||||
read := chromedp.Tasks{
|
|
||||||
chromedp.Evaluate(`window.getSelection().toString()`, &selected),
|
|
||||||
chromedp.AttributeValue(
|
|
||||||
row, "aria-expanded", &state, &hasState, chromedp.BySearch,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
// A click on the ID toggles the event half a second after it, so a
|
|
||||||
// check that selecting the ID did not toggle it waits a second first.
|
|
||||||
settle := chromedp.Sleep(time.Second)
|
|
||||||
|
|
||||||
// The double-click and the triple-click each start with nothing
|
|
||||||
// selected, so that their first click waits to toggle the event.
|
|
||||||
clearSelection := chromedp.Evaluate(
|
|
||||||
`window.getSelection().removeAllRanges()`, nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
// The newest event starts expanded, which can push this one below
|
|
||||||
// the short window, where the mouse cannot reach it.
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx, chromedp.EmulateViewport(shortWidth, shortHeight), loadPage(url),
|
|
||||||
chromedp.ScrollIntoView(id, chromedp.BySearch),
|
|
||||||
))
|
|
||||||
|
|
||||||
selectText(ctx, t, id)
|
|
||||||
require.NoError(t, chromedp.Run(ctx, settle, read))
|
|
||||||
assert.Equal(t, eventID, selected, "the event's ID cannot be selected")
|
|
||||||
require.True(t, hasState, "the event's row does not say if it is expanded")
|
|
||||||
assert.Equal(t, "false", state, "selecting the event's ID expands it")
|
|
||||||
|
|
||||||
click(ctx, t, caret)
|
|
||||||
require.NoError(t, chromedp.Run(ctx, read))
|
|
||||||
assert.Equal(t, "true", state,
|
|
||||||
"clicking the caret does not expand the event at once")
|
|
||||||
|
|
||||||
// The row says it is expanded before its expanded part is shown, so
|
|
||||||
// the scroll waits for that part, to end at the expanded page's end.
|
|
||||||
require.True(t, shown(ctx, expanded),
|
|
||||||
"clicking the caret does not show the event's expanded part")
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(ctx, chromedp.Evaluate(
|
|
||||||
`window.scrollTo(0, document.body.scrollHeight); window.scrollY`,
|
|
||||||
&scrolled,
|
|
||||||
)))
|
|
||||||
require.Positive(t, scrolled, "the event log does not scroll")
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(ctx, clearSelection))
|
|
||||||
doubleClickAndDrag(ctx, t, id)
|
|
||||||
require.NoError(t, chromedp.Run(ctx, settle, read))
|
|
||||||
assert.Contains(t, selected, eventID,
|
|
||||||
"a double-click and drag does not select the event's ID")
|
|
||||||
assert.Equal(t, "true", state,
|
|
||||||
"a double-click and drag over the event's ID collapses it")
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(ctx, clearSelection))
|
|
||||||
tripleClick(ctx, t, id)
|
|
||||||
require.NoError(t, chromedp.Run(ctx, settle, read))
|
|
||||||
assert.Contains(t, selected, eventID,
|
|
||||||
"a triple-click does not select the event's ID")
|
|
||||||
assert.Equal(t, "true", state,
|
|
||||||
"a triple-click selecting the event's ID collapses it")
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkEventKeyboard loads the event log and checks that Tab from the
|
|
||||||
// page's Pending link, the last link above the list, reaches the row of
|
|
||||||
// the newest event, the first after it, and that Enter then collapses
|
|
||||||
// that event, which starts expanded, and Space expands it again.
|
|
||||||
func checkEventKeyboard(
|
|
||||||
ctx context.Context, t *testing.T, url, eventID string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
pending := `//a[starts-with(text(), "Pending")]`
|
|
||||||
expanded := `form[action$="/` + eventID + `/resubmit"]`
|
|
||||||
|
|
||||||
var focused string
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx,
|
|
||||||
loadPage(url),
|
|
||||||
chromedp.Focus(pending, chromedp.BySearch),
|
|
||||||
chromedp.KeyEvent(kb.Tab),
|
|
||||||
chromedp.Evaluate(`document.activeElement.textContent`, &focused),
|
|
||||||
))
|
|
||||||
require.Contains(t, focused, eventID,
|
|
||||||
"Tab from the Pending link does not reach the event's row")
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(kb.Enter)))
|
|
||||||
assert.True(t, hidden(ctx, expanded), "Enter does not collapse the event")
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(" ")))
|
|
||||||
assert.True(t, shown(ctx, expanded), "Space does not expand the event")
|
|
||||||
}
|
|
||||||
|
|
||||||
// selectText selects the text of the element matching an XPath
|
|
||||||
// expression as a person does with the mouse: pressing the button at the
|
|
||||||
// text's start, moving to its end and releasing it there.
|
|
||||||
func selectText(ctx context.Context, t *testing.T, xpath string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
left, right, y := textEnds(ctx, t, xpath)
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx, press(left, y, 1), drag(right, y), release(right, y, 1),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
// doubleClickAndDrag double-clicks the start of the text of the element
|
|
||||||
// matching an XPath expression, which selects its first word, and keeps
|
|
||||||
// the button down to drag to the text's end, which selects it word by
|
|
||||||
// word. It holds the button for a second, longer than a single click on
|
|
||||||
// an event's row waits before it toggles the event.
|
|
||||||
func doubleClickAndDrag(ctx context.Context, t *testing.T, xpath string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
left, right, y := textEnds(ctx, t, xpath)
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx,
|
|
||||||
press(left, y, 1), chromedp.Sleep(pressHeld), release(left, y, 1),
|
|
||||||
chromedp.Sleep(betweenClicks),
|
|
||||||
press(left, y, 2), drag(right, y), chromedp.Sleep(time.Second),
|
|
||||||
release(right, y, 2),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
// tripleClick clicks three times in the middle of the text of the
|
|
||||||
// element matching an XPath expression, as a person does to select a
|
|
||||||
// whole line of text. The browser selects a word on the second click and
|
|
||||||
// the whole paragraph on the third.
|
|
||||||
func tripleClick(ctx context.Context, t *testing.T, xpath string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
left, right, y := textEnds(ctx, t, xpath)
|
|
||||||
x := (left + right) / 2
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx,
|
|
||||||
press(x, y, 1), chromedp.Sleep(pressHeld), release(x, y, 1),
|
|
||||||
chromedp.Sleep(betweenClicks),
|
|
||||||
press(x, y, 2), chromedp.Sleep(pressHeld), release(x, y, 2),
|
|
||||||
chromedp.Sleep(betweenClicks),
|
|
||||||
press(x, y, 3), chromedp.Sleep(pressHeld), release(x, y, 3),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
// textEnds returns where on screen the text of the element matching an
|
|
||||||
// XPath expression starts and ends, just inside its left and right
|
|
||||||
// edges, and the height of its middle: in that order, the x of its
|
|
||||||
// start, the x of its end, and the y of both.
|
|
||||||
func textEnds(
|
|
||||||
ctx context.Context, t *testing.T, xpath string,
|
|
||||||
) (float64, float64, float64) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var box *dom.BoxModel
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx, chromedp.Dimensions(xpath, &box, chromedp.BySearch),
|
|
||||||
))
|
|
||||||
|
|
||||||
// The content box's corners, clockwise from its top left.
|
|
||||||
return box.Content[0] + 1, box.Content[2] - 1,
|
|
||||||
(box.Content[1] + box.Content[5]) / 2
|
|
||||||
}
|
|
||||||
|
|
||||||
// press presses the left mouse button at x, y, as the nth click of a
|
|
||||||
// double- or triple-click.
|
|
||||||
func press(x, y float64, nth int64) *input.DispatchMouseEventParams {
|
|
||||||
return input.DispatchMouseEvent(input.MousePressed, x, y).
|
|
||||||
WithButton(input.Left).WithButtons(1).WithClickCount(nth)
|
|
||||||
}
|
|
||||||
|
|
||||||
// drag moves the pointer to x, y with the left mouse button down.
|
|
||||||
func drag(x, y float64) *input.DispatchMouseEventParams {
|
|
||||||
return input.DispatchMouseEvent(input.MouseMoved, x, y).
|
|
||||||
WithButton(input.Left).WithButtons(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// release releases the left mouse button at x, y, as the nth click of a
|
|
||||||
// double- or triple-click.
|
|
||||||
func release(x, y float64, nth int64) *input.DispatchMouseEventParams {
|
|
||||||
return input.DispatchMouseEvent(input.MouseReleased, x, y).
|
|
||||||
WithButton(input.Left).WithClickCount(nth)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The parts of the new webhook page the checks below find and click.
|
|
||||||
const (
|
|
||||||
archiveBox = `//input[@name="archive"]`
|
|
||||||
archiveIsOn = `document.querySelector('input[name="archive"]').checked`
|
|
||||||
pruningChoice = `//select[@name="archive_expiry"]`
|
|
||||||
createButton = `//button[text()="Create Webhook"]`
|
|
||||||
)
|
|
||||||
|
|
||||||
// checkArchiveChoice loads the new webhook page and checks that the
|
|
||||||
// archive pruning choice stays hidden until the archive box is checked
|
|
||||||
// and hides again when it is unchecked; and that after checking it,
|
|
||||||
// opening the page at elsewhere and going back, the page again shows
|
|
||||||
// the box unchecked and the choice hidden.
|
|
||||||
func checkArchiveChoice(
|
|
||||||
ctx context.Context, t *testing.T, url, elsewhere string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
|
||||||
|
|
||||||
assert.True(t, hidden(ctx, pruningChoice),
|
|
||||||
"the pruning choice shows before archive is checked")
|
|
||||||
|
|
||||||
click(ctx, t, archiveBox)
|
|
||||||
assert.True(t, shown(ctx, pruningChoice),
|
|
||||||
"checking archive does not show the pruning choice")
|
|
||||||
|
|
||||||
click(ctx, t, archiveBox)
|
|
||||||
assert.True(t, hidden(ctx, pruningChoice),
|
|
||||||
"unchecking archive does not hide the pruning choice")
|
|
||||||
|
|
||||||
var (
|
|
||||||
loaded string
|
|
||||||
checked bool
|
|
||||||
)
|
|
||||||
|
|
||||||
click(ctx, t, archiveBox)
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx,
|
|
||||||
loadPage(elsewhere),
|
|
||||||
chromedp.NavigateBack(),
|
|
||||||
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
|
|
||||||
chromedp.Evaluate(
|
|
||||||
`performance.getEntriesByType("navigation")[0].type`, &loaded,
|
|
||||||
),
|
|
||||||
chromedp.Evaluate(archiveIsOn, &checked),
|
|
||||||
))
|
|
||||||
require.Equal(
|
|
||||||
t, "back_forward", loaded,
|
|
||||||
"going back, the browser did not load the page again",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.False(t, checked, "going back leaves archive checked")
|
|
||||||
assert.True(t, hidden(ctx, pruningChoice),
|
|
||||||
"going back shows the pruning choice")
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkNewWebhookTargets submits the new webhook page with the HTTP
|
|
||||||
// target URL filled in or empty, and with archive left off or checked
|
|
||||||
// with each pruning choice, and checks that each webhook is created
|
|
||||||
// with exactly the targets asked for.
|
|
||||||
func checkNewWebhookTargets(
|
|
||||||
ctx context.Context, t *testing.T, env *testEnv, url string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
// Each value the pruning choice submits, after an empty one that
|
|
||||||
// stands for archive left off.
|
|
||||||
expiries := []string{
|
|
||||||
"", "never", "1h", "12h", "24h", "720h", "2160h", "8760h",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, httpURL := range []string{"", publicTargetURL} {
|
|
||||||
for _, expiry := range expiries {
|
|
||||||
name := "url=" + httpURL + " archive=" + expiry
|
|
||||||
want := map[database.TargetType]string{}
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx,
|
|
||||||
loadPage(url),
|
|
||||||
chromedp.SetValue("#name", name, chromedp.ByQuery),
|
|
||||||
))
|
|
||||||
|
|
||||||
if httpURL != "" {
|
|
||||||
require.NoError(t, chromedp.Run(ctx, chromedp.SetValue(
|
|
||||||
"#http_url", httpURL, chromedp.ByQuery,
|
|
||||||
)))
|
|
||||||
|
|
||||||
want[database.TargetTypeHTTP] = `{"url":"` + httpURL + `"}`
|
|
||||||
}
|
|
||||||
|
|
||||||
if expiry != "" {
|
|
||||||
// The choice showing moves Create down, so it is
|
|
||||||
// waited for before Create is clicked.
|
|
||||||
click(ctx, t, archiveBox)
|
|
||||||
require.Truef(t, shown(ctx, pruningChoice),
|
|
||||||
"%s: checking archive does not show the pruning choice",
|
|
||||||
name)
|
|
||||||
require.NoError(t, chromedp.Run(ctx, chromedp.SetValue(
|
|
||||||
pruningChoice, expiry, chromedp.BySearch,
|
|
||||||
)))
|
|
||||||
|
|
||||||
// The rotation choice is left on none.
|
|
||||||
want[database.TargetTypeDatabase] = `{"expiry":"` + expiry +
|
|
||||||
`","rotation":"none"}`
|
|
||||||
}
|
|
||||||
|
|
||||||
click(ctx, t, createButton)
|
|
||||||
require.Truef(t, shown(ctx, `//h1[text()="`+name+`"]`),
|
|
||||||
"%s: the new webhook's page does not open", name)
|
|
||||||
|
|
||||||
assert.Equalf(t, want, targetConfigs(t, env, name),
|
|
||||||
"%s: the webhook does not have the targets asked for", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// targetConfigs reads the targets of the webhook named name, and
|
|
||||||
// returns each one's stored configuration by its type.
|
|
||||||
func targetConfigs(
|
|
||||||
t *testing.T, env *testEnv, name string,
|
|
||||||
) map[database.TargetType]string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var (
|
|
||||||
webhook database.Webhook
|
|
||||||
targets []database.Target
|
|
||||||
)
|
|
||||||
|
|
||||||
require.NoError(t, env.db.DB().
|
|
||||||
Where("name = ?", name).First(&webhook).Error)
|
|
||||||
require.NoError(t, env.db.DB().
|
|
||||||
Where("webhook_id = ?", webhook.ID).Find(&targets).Error)
|
|
||||||
|
|
||||||
configs := map[database.TargetType]string{}
|
|
||||||
for _, target := range targets {
|
|
||||||
configs[target.Type] = target.Config
|
|
||||||
}
|
|
||||||
|
|
||||||
return configs
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkRefusedNewWebhook submits the new webhook page with archive
|
|
||||||
// checked and an HTTP target URL the server refuses, a loopback
|
|
||||||
// destination, and checks that the page comes back with the reason and
|
|
||||||
// every value entered, archive still checked and its pruning and
|
|
||||||
// rotation choices showing.
|
|
||||||
func checkRefusedNewWebhook(ctx context.Context, t *testing.T, url string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
const refusedURL = "http://127.0.0.1/hook"
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx,
|
|
||||||
loadPage(url),
|
|
||||||
chromedp.SetValue("#name", "refused", chromedp.ByQuery),
|
|
||||||
chromedp.SetValue("#description", "kept", chromedp.ByQuery),
|
|
||||||
chromedp.SetValue("#retention_days", "7", chromedp.ByQuery),
|
|
||||||
chromedp.SetValue("#http_url", refusedURL, chromedp.ByQuery),
|
|
||||||
))
|
|
||||||
click(ctx, t, archiveBox)
|
|
||||||
require.True(t, shown(ctx, pruningChoice),
|
|
||||||
"checking archive does not show the pruning choice")
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx,
|
|
||||||
chromedp.SetValue(pruningChoice, "2160h", chromedp.BySearch),
|
|
||||||
chromedp.SetValue("#archive_rotation", "monthly", chromedp.ByQuery),
|
|
||||||
))
|
|
||||||
click(ctx, t, createButton)
|
|
||||||
|
|
||||||
assert.True(t, shown(ctx, `//div[@class="alert-error"]`),
|
|
||||||
"a refused webhook does not show the reason")
|
|
||||||
|
|
||||||
var (
|
|
||||||
name, description, retention, typed, expiry, rotation string
|
|
||||||
checked bool
|
|
||||||
)
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx,
|
|
||||||
chromedp.Value("#name", &name, chromedp.ByQuery),
|
|
||||||
chromedp.Value("#description", &description, chromedp.ByQuery),
|
|
||||||
chromedp.Value("#retention_days", &retention, chromedp.ByQuery),
|
|
||||||
chromedp.Value("#http_url", &typed, chromedp.ByQuery),
|
|
||||||
chromedp.Value("#archive_expiry", &expiry, chromedp.ByQuery),
|
|
||||||
chromedp.Value("#archive_rotation", &rotation, chromedp.ByQuery),
|
|
||||||
chromedp.Evaluate(archiveIsOn, &checked),
|
|
||||||
))
|
|
||||||
|
|
||||||
assert.Equal(t, "refused", name, "the name entered is lost")
|
|
||||||
assert.Equal(t, "kept", description, "the description entered is lost")
|
|
||||||
assert.Equal(t, "7", retention, "the retention entered is lost")
|
|
||||||
assert.Equal(t, refusedURL, typed, "the url entered is lost")
|
|
||||||
assert.True(t, checked, "archive is no longer checked")
|
|
||||||
assert.True(t, shown(ctx, pruningChoice), "the pruning choice is hidden")
|
|
||||||
assert.Equal(t, "2160h", expiry, "the pruning chosen is lost")
|
|
||||||
assert.Equal(t, "monthly", rotation, "the rotation chosen is lost")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkMobileMenu loads a page in a phone-sized window and checks that
|
// checkMobileMenu loads a page in a phone-sized window and checks that
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ func TestEventResubmit_SignedOutRequestsNeverReachTheRateLimit(
|
|||||||
env.requireNotice(
|
env.requireNotice(
|
||||||
t, env.post(path, csrfForm(token), cookies),
|
t, env.post(path, csrfForm(token), cookies),
|
||||||
logsPath, "resubmit-no-targets",
|
logsPath, "resubmit-no-targets",
|
||||||
"this webhook has no active targets", cookies,
|
"this source has no active targets", cookies,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ func TestEventResubmit_RefusedWithoutAValidCSRFToken(t *testing.T) {
|
|||||||
env.requireNotice(
|
env.requireNotice(
|
||||||
t, env.post(path, csrfForm(token), cookies),
|
t, env.post(path, csrfForm(token), cookies),
|
||||||
logsPath, "resubmit-no-targets",
|
logsPath, "resubmit-no-targets",
|
||||||
"this webhook has no active targets", cookies,
|
"this source has no active targets", cookies,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ func TestEventResubmit_AnotherWebhooksEvent404s(t *testing.T) {
|
|||||||
csrfForm(token), cookies,
|
csrfForm(token), cookies,
|
||||||
),
|
),
|
||||||
intrudersLogs, "resubmit-no-targets",
|
intrudersLogs, "resubmit-no-targets",
|
||||||
"this webhook has no active targets", cookies,
|
"this source has no active targets", cookies,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -252,12 +252,11 @@ func (s *Server) setupSourceRoutes() {
|
|||||||
r.Post("/edit", s.h.HandleSourceEditSubmit())
|
r.Post("/edit", s.h.HandleSourceEditSubmit())
|
||||||
r.Post("/delete", s.h.HandleSourceDelete())
|
r.Post("/delete", s.h.HandleSourceDelete())
|
||||||
r.Get("/events", s.h.HandleSourceLogs())
|
r.Get("/events", s.h.HandleSourceLogs())
|
||||||
r.Get("/events/{eventID}", s.h.HandleEventDetail())
|
// The log page renders each body only up to its cap, so
|
||||||
// The pages show a body as escaped text and leave a
|
// this is the only route that serves a whole one. It
|
||||||
// binary one out, so this is the only route that serves
|
// belongs to this group for its RequireAuth and
|
||||||
// the stored bytes. It belongs to this group for its
|
// NoCache; see HandleEventBodyDownload for the headers
|
||||||
// RequireAuth and NoCache; see HandleEventBodyDownload for
|
// that keep the bytes it returns inert.
|
||||||
// the headers that keep the bytes it returns inert.
|
|
||||||
r.Get(
|
r.Get(
|
||||||
"/events/{eventID}/body",
|
"/events/{eventID}/body",
|
||||||
s.h.HandleEventBodyDownload(),
|
s.h.HandleEventBodyDownload(),
|
||||||
@@ -290,10 +289,6 @@ func (s *Server) setupSourceRoutes() {
|
|||||||
"/entrypoints",
|
"/entrypoints",
|
||||||
s.h.HandleEntrypointCreate(),
|
s.h.HandleEntrypointCreate(),
|
||||||
)
|
)
|
||||||
r.Post(
|
|
||||||
"/entrypoints/{entrypointID}/edit",
|
|
||||||
s.h.HandleEntrypointEdit(),
|
|
||||||
)
|
|
||||||
r.Post(
|
r.Post(
|
||||||
"/entrypoints/{entrypointID}/delete",
|
"/entrypoints/{entrypointID}/delete",
|
||||||
s.h.HandleEntrypointDelete(),
|
s.h.HandleEntrypointDelete(),
|
||||||
|
|||||||
+16
-244
@@ -11,9 +11,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"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"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
@@ -62,17 +60,6 @@ func (e *noopArchives) Rename(_, _, _ string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// noopCircuitBreakers satisfies handlers.New's
|
|
||||||
// delivery.CircuitBreakers dependency with no target's deliveries
|
|
||||||
// paused.
|
|
||||||
type noopCircuitBreakers struct{}
|
|
||||||
|
|
||||||
func (b *noopCircuitBreakers) StateAndCooldown(
|
|
||||||
string,
|
|
||||||
) (delivery.CircuitState, time.Duration) {
|
|
||||||
return delivery.CircuitClosed, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// testEnv is the real router from routes.go plus the collaborators
|
// testEnv is the real router from routes.go plus the collaborators
|
||||||
// tests need to seed users and forge sessions.
|
// tests need to seed users and forge sessions.
|
||||||
type testEnv struct {
|
type testEnv struct {
|
||||||
@@ -137,9 +124,6 @@ func newTestEnvWithConfig(
|
|||||||
session.New,
|
session.New,
|
||||||
func() delivery.Notifier { return &noopNotifier{} },
|
func() delivery.Notifier { return &noopNotifier{} },
|
||||||
func() delivery.Archives { return &noopArchives{} },
|
func() delivery.Archives { return &noopArchives{} },
|
||||||
func() delivery.CircuitBreakers {
|
|
||||||
return &noopCircuitBreakers{}
|
|
||||||
},
|
|
||||||
metrics.NewRegistry,
|
metrics.NewRegistry,
|
||||||
metrics.New,
|
metrics.New,
|
||||||
middleware.New,
|
middleware.New,
|
||||||
@@ -380,7 +364,6 @@ func (e *testEnv) seedEvent(
|
|||||||
WebhookID: webhookID,
|
WebhookID: webhookID,
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
Body: body,
|
Body: body,
|
||||||
BodyBytes: int64(len(body)),
|
|
||||||
ContentType: "application/octet-stream",
|
ContentType: "application/octet-stream",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,47 +398,8 @@ func (e *testEnv) seedTarget(
|
|||||||
return tgt
|
return tgt
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedEntrypoint creates an active entrypoint for a webhook.
|
|
||||||
func (e *testEnv) seedEntrypoint(
|
|
||||||
t *testing.T,
|
|
||||||
webhookID string,
|
|
||||||
) *database.Entrypoint {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
ep := &database.Entrypoint{
|
|
||||||
WebhookID: webhookID,
|
|
||||||
Path: uuid.New().String(),
|
|
||||||
Description: "Default entrypoint",
|
|
||||||
Active: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(
|
|
||||||
t,
|
|
||||||
e.db.DB().Omit(clause.Associations).Create(ep).Error,
|
|
||||||
)
|
|
||||||
|
|
||||||
return ep
|
|
||||||
}
|
|
||||||
|
|
||||||
// storedEntrypoint reloads an entrypoint row.
|
|
||||||
func (e *testEnv) storedEntrypoint(
|
|
||||||
t *testing.T,
|
|
||||||
entrypointID string,
|
|
||||||
) database.Entrypoint {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var ep database.Entrypoint
|
|
||||||
|
|
||||||
require.NoError(
|
|
||||||
t, e.db.DB().First(&ep, "id = ?", entrypointID).Error,
|
|
||||||
)
|
|
||||||
|
|
||||||
return ep
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedFailedDelivery records a terminally failed delivery of an event
|
// seedFailedDelivery records a terminally failed delivery of an event
|
||||||
// to a target in the webhook's own database, as the delivery engine
|
// to a target in the webhook's own database.
|
||||||
// leaves one: finished now, and counted in its target's totals.
|
|
||||||
func (e *testEnv) seedFailedDelivery(
|
func (e *testEnv) seedFailedDelivery(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
webhookID, eventID, targetID string,
|
webhookID, eventID, targetID string,
|
||||||
@@ -465,21 +409,16 @@ func (e *testEnv) seedFailedDelivery(
|
|||||||
webhookDB, err := e.dbMgr.GetDB(webhookID)
|
webhookDB, err := e.dbMgr.GetDB(webhookID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
finishedAt := time.Now()
|
|
||||||
dlv := &database.Delivery{
|
dlv := &database.Delivery{
|
||||||
EventID: eventID,
|
EventID: eventID,
|
||||||
TargetID: targetID,
|
TargetID: targetID,
|
||||||
Status: database.DeliveryStatusFailed,
|
Status: database.DeliveryStatusFailed,
|
||||||
FinishedAt: &finishedAt,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(
|
require.NoError(
|
||||||
t,
|
t,
|
||||||
webhookDB.Omit(clause.Associations).Create(dlv).Error,
|
webhookDB.Omit(clause.Associations).Create(dlv).Error,
|
||||||
)
|
)
|
||||||
require.NoError(t, database.AddTargetTotals(webhookDB,
|
|
||||||
database.TargetTotals{TargetID: targetID, Deliveries: 1, Failed: 1},
|
|
||||||
))
|
|
||||||
|
|
||||||
return dlv
|
return dlv
|
||||||
}
|
}
|
||||||
@@ -914,26 +853,6 @@ func TestPagesLogout_SaysSignedOut(t *testing.T) {
|
|||||||
env.requireNotice(t, w, "/pages/login", "signed-out", "Signed out.", nil)
|
env.requireNotice(t, w, "/pages/login", "signed-out", "Signed out.", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSignInAndSignOutWording pins one wording for both: the sign-in
|
|
||||||
// page's button reads "Sign in" and the navbar's buttons "Sign out", as
|
|
||||||
// the sign-in page's heading, the error page's link and the notice
|
|
||||||
// after signing out do.
|
|
||||||
func TestSignInAndSignOutWording(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
assert.Contains(
|
|
||||||
t, env.get("/pages/login", nil).Body.String(), ">Sign in</button>",
|
|
||||||
)
|
|
||||||
|
|
||||||
userID, _ := env.seedUser(t, "reader", "somepassword")
|
|
||||||
page := env.get("/hooks", env.authCookies(t, userID, "reader")).Body.String()
|
|
||||||
|
|
||||||
assert.Equal(t, 2, strings.Count(page, ">Sign out</button>"),
|
|
||||||
"the desktop and the mobile navbar each say Sign out")
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- /user/{username} group ---
|
// --- /user/{username} group ---
|
||||||
|
|
||||||
// TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged
|
// TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged
|
||||||
@@ -1168,119 +1087,6 @@ func TestHook_EntrypointActions(t *testing.T) {
|
|||||||
assert.Zero(t, left, "the delete should remove the entrypoint")
|
assert.Zero(t, left, "the delete should remove the entrypoint")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHook_EntrypointEdit changes an entrypoint's description with the
|
|
||||||
// edit form on the webhook page, then empties it. The entrypoint keeps
|
|
||||||
// its URL, and with no description it shows as "Entrypoint". Without
|
|
||||||
// the CSRF token, or without a session, the edit is refused.
|
|
||||||
func TestHook_EntrypointEdit(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
userID, _ := env.seedUser(t, "epeditor", "somepassword")
|
|
||||||
cookies := env.authCookies(t, userID, "epeditor")
|
|
||||||
wh := env.seedWebhook(t, userID)
|
|
||||||
ep := env.seedEntrypoint(t, wh.ID)
|
|
||||||
page := "/hook/" + wh.ID
|
|
||||||
|
|
||||||
token, cookies := env.csrfFrom(t, page, cookies)
|
|
||||||
action := env.urlFrom(
|
|
||||||
t, page, `action="(/hook/[^/"]+/entrypoints/[^/"]+/edit)"`,
|
|
||||||
cookies,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t, http.StatusForbidden,
|
|
||||||
env.post(action, entrypointEditForm("", "no token"), cookies).Code,
|
|
||||||
"an edit without a CSRF token must be refused",
|
|
||||||
)
|
|
||||||
|
|
||||||
// The request without a session carries a valid CSRF token from
|
|
||||||
// the login page, so only the session check can refuse it.
|
|
||||||
anonToken, anon := env.csrfFrom(t, "/pages/login", nil)
|
|
||||||
refused := env.post(
|
|
||||||
action, entrypointEditForm(anonToken, "no session"), anon,
|
|
||||||
)
|
|
||||||
assert.Equal(t, http.StatusSeeOther, refused.Code)
|
|
||||||
assert.Equal(
|
|
||||||
t, "/pages/login", refused.Header().Get("Location"),
|
|
||||||
"an edit without a session must be refused",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t, ep.Description, env.storedEntrypoint(t, ep.ID).Description,
|
|
||||||
"a refused edit must not change the description",
|
|
||||||
)
|
|
||||||
|
|
||||||
// edit submits the form with description and requires the
|
|
||||||
// redirect back to the webhook page with the notice.
|
|
||||||
edit := func(description string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
w := env.post(
|
|
||||||
action, entrypointEditForm(token, description), cookies,
|
|
||||||
)
|
|
||||||
env.requireNotice(t, w, page, "entrypoint-saved",
|
|
||||||
"Entrypoint description saved.", cookies)
|
|
||||||
}
|
|
||||||
|
|
||||||
edit("Billing sender")
|
|
||||||
|
|
||||||
stored := env.storedEntrypoint(t, ep.ID)
|
|
||||||
assert.Equal(t, "Billing sender", stored.Description)
|
|
||||||
assert.Equal(t, ep.Path, stored.Path,
|
|
||||||
"the edit must keep the entrypoint's URL")
|
|
||||||
|
|
||||||
body := env.get(page, cookies).Body.String()
|
|
||||||
assert.Contains(t, body, ">Billing sender</span>")
|
|
||||||
assert.Contains(t, body, "/h/"+ep.Path+"</code>")
|
|
||||||
|
|
||||||
edit("")
|
|
||||||
|
|
||||||
assert.Empty(t, env.storedEntrypoint(t, ep.ID).Description)
|
|
||||||
assert.Contains(t, env.get(page, cookies).Body.String(),
|
|
||||||
">Entrypoint</span>", "no description shows as Entrypoint")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHook_EntrypointEdit_OtherUser404s has another logged-in user, with
|
|
||||||
// a CSRF token of their own, try to edit an entrypoint: through the
|
|
||||||
// owner's webhook, and through a webhook of their own. Both are 404s
|
|
||||||
// and the description stays as it was.
|
|
||||||
func TestHook_EntrypointEdit_OtherUser404s(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
ownerID, _ := env.seedUser(t, "epowner", "somepassword")
|
|
||||||
wh := env.seedWebhook(t, ownerID)
|
|
||||||
ep := env.seedEntrypoint(t, wh.ID)
|
|
||||||
|
|
||||||
otherID, _ := env.seedUser(t, "epother", "somepassword")
|
|
||||||
other := env.authCookies(t, otherID, "epother")
|
|
||||||
theirs := env.seedWebhook(t, otherID)
|
|
||||||
token, other := env.csrfFrom(t, "/hook/"+theirs.ID, other)
|
|
||||||
|
|
||||||
for _, webhookID := range []string{wh.ID, theirs.ID} {
|
|
||||||
path := "/hook/" + webhookID + "/entrypoints/" + ep.ID + "/edit"
|
|
||||||
|
|
||||||
w := env.post(path, entrypointEditForm(token, "not theirs"), other)
|
|
||||||
assert.Equal(t, http.StatusNotFound, w.Code, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t, ep.Description, env.storedEntrypoint(t, ep.ID).Description,
|
|
||||||
"another user must not change the description",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// entrypointEditForm fills in the webhook page's entrypoint edit form.
|
|
||||||
func entrypointEditForm(token, description string) url.Values {
|
|
||||||
return url.Values{
|
|
||||||
"csrf_token": {token},
|
|
||||||
"description": {description},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHook_TargetActions adds a target with the form on the webhook
|
// TestHook_TargetActions adds a target with the form on the webhook
|
||||||
// page, follows its Edit link to the target edit form and submits
|
// page, follows its Edit link to the target edit form and submits
|
||||||
// it, then deactivates, activates and deletes it, every URL and token
|
// it, then deactivates, activates and deletes it, every URL and token
|
||||||
@@ -1383,7 +1189,7 @@ func TestHook_ResubmitFromEventLog(t *testing.T) {
|
|||||||
)
|
)
|
||||||
env.requireNotice(
|
env.requireNotice(
|
||||||
t, w, logsPath, "resubmit-no-targets",
|
t, w, logsPath, "resubmit-no-targets",
|
||||||
"this webhook has no active targets", cookies,
|
"this source has no active targets", cookies,
|
||||||
)
|
)
|
||||||
|
|
||||||
webhookDB, err := env.dbMgr.GetDB(wh.ID)
|
webhookDB, err := env.dbMgr.GetDB(wh.ID)
|
||||||
@@ -1399,9 +1205,9 @@ func TestHook_ResubmitFromEventLog(t *testing.T) {
|
|||||||
|
|
||||||
// TestHook_LinksBetweenPages follows each link to a webhook page that
|
// TestHook_LinksBetweenPages follows each link to a webhook page that
|
||||||
// the tests above do not: the navbar's "Webhooks" links, the back and
|
// the tests above do not: the navbar's "Webhooks" links, the back and
|
||||||
// Cancel links, the list's link to a webhook, and the "Full Event Log"
|
// Cancel links, the list's link to a webhook, the "Full Event Log"
|
||||||
// link beside the recent events. Each must point where it should, and
|
// link beside the recent events, and the event log's page links. Each
|
||||||
// that page must render.
|
// must point where it should, and that page must render.
|
||||||
func TestHook_LinksBetweenPages(t *testing.T) {
|
func TestHook_LinksBetweenPages(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1412,6 +1218,12 @@ func TestHook_LinksBetweenPages(t *testing.T) {
|
|||||||
wh := env.seedWebhook(t, userID)
|
wh := env.seedWebhook(t, userID)
|
||||||
tgt := env.seedTarget(t, wh.ID)
|
tgt := env.seedTarget(t, wh.ID)
|
||||||
|
|
||||||
|
// The event log shows 25 events a page; one more gives it a second
|
||||||
|
// page, so it renders its Next and Previous links.
|
||||||
|
for range 26 {
|
||||||
|
env.seedEvent(t, wh.ID, "paged")
|
||||||
|
}
|
||||||
|
|
||||||
list := "/hooks"
|
list := "/hooks"
|
||||||
newForm := list + "/new"
|
newForm := list + "/new"
|
||||||
page := "/hook/" + wh.ID
|
page := "/hook/" + wh.ID
|
||||||
@@ -1443,6 +1255,8 @@ func TestHook_LinksBetweenPages(t *testing.T) {
|
|||||||
{targetEdit, back, page},
|
{targetEdit, back, page},
|
||||||
{targetEdit, cancel, page},
|
{targetEdit, cancel, page},
|
||||||
{events, back, page},
|
{events, back, page},
|
||||||
|
{events, `href="([^"]+)"[^>]*>Next →<`, events + "?page=2"},
|
||||||
|
{events + "?page=2", `href="([^"]+)"[^>]*>← Previous<`, events + "?page=1"},
|
||||||
} {
|
} {
|
||||||
got := env.urlFrom(t, link.from, link.pattern, cookies)
|
got := env.urlFrom(t, link.from, link.pattern, cookies)
|
||||||
assert.Equal(t, link.want, got, "%s: %s", link.from, link.pattern)
|
assert.Equal(t, link.want, got, "%s: %s", link.from, link.pattern)
|
||||||
@@ -1450,48 +1264,6 @@ func TestHook_LinksBetweenPages(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEventPage_OpenedFromRecentEvents follows the Open link of a
|
|
||||||
// row in the recent events on the webhook page through the
|
|
||||||
// production router to the event's own page, which shows the body
|
|
||||||
// and links back. Another user gets a 404 at the same URL, and a
|
|
||||||
// logged-out request is sent to log in.
|
|
||||||
func TestEventPage_OpenedFromRecentEvents(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
ownerID, _ := env.seedUser(t, "owner", "somepassword")
|
|
||||||
cookies := env.authCookies(t, ownerID, "owner")
|
|
||||||
wh := env.seedWebhook(t, ownerID)
|
|
||||||
evt := env.seedEvent(t, wh.ID, "OWNERS-PAYLOAD-3e9d")
|
|
||||||
|
|
||||||
page := "/hook/" + wh.ID
|
|
||||||
path := env.urlFrom(t, page, `href="([^"]+)"[^>]*>Open<`, cookies)
|
|
||||||
|
|
||||||
require.Equal(t, page+"/events/"+evt.ID, path)
|
|
||||||
|
|
||||||
w := env.get(path, cookies)
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
assert.Contains(t, w.Body.String(), "OWNERS-PAYLOAD-3e9d")
|
|
||||||
assert.Equal(
|
|
||||||
t, page,
|
|
||||||
env.urlFrom(t, path, `href="([^"]+)"[^>]*>← Back to `, cookies),
|
|
||||||
)
|
|
||||||
|
|
||||||
intruderID, _ := env.seedUser(t, "intruder", "somepassword")
|
|
||||||
|
|
||||||
w = env.get(path, env.authCookies(t, intruderID, "intruder"))
|
|
||||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
||||||
assert.NotContains(t, w.Body.String(), "OWNERS-PAYLOAD-3e9d")
|
|
||||||
|
|
||||||
anon := env.get(path, nil)
|
|
||||||
assert.Equal(t, http.StatusSeeOther, anon.Code)
|
|
||||||
assert.Equal(
|
|
||||||
t, "/pages/login?next="+url.QueryEscape(path),
|
|
||||||
anon.Header().Get("Location"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSourceLogs_TruncationLinkDownloadsTheBody walks the whole
|
// TestSourceLogs_TruncationLinkDownloadsTheBody walks the whole
|
||||||
// feature the way a user does: render the event log page through
|
// feature the way a user does: render the event log page through
|
||||||
// the production router, take the download URL out of the markup
|
// the production router, take the download URL out of the markup
|
||||||
|
|||||||
@@ -218,9 +218,8 @@ func keptSentryHeaders(headers map[string]string) map[string]string {
|
|||||||
|
|
||||||
// sentryKeepsHeader reports whether a request header is routing or
|
// sentryKeepsHeader reports whether a request header is routing or
|
||||||
// content metadata rather than client-chosen payload. Referer is kept
|
// content metadata rather than client-chosen payload. Referer is kept
|
||||||
// on the reasoning that it is browser-set, that the only query
|
// on the reasoning that it is browser-set, that this service emits
|
||||||
// parameters in this service's own URLs are the login page's `next`,
|
// only ?page= in its own links, and that Referrer-Policy is set to
|
||||||
// `notice` and the event log's `show`, and that Referrer-Policy is set to
|
|
||||||
// strict-origin-when-cross-origin. X-Request-Id ties the event to the
|
// strict-origin-when-cross-origin. X-Request-Id ties the event to the
|
||||||
// local access log line, which holds the rest of the detail.
|
// local access log line, which holds the rest of the detail.
|
||||||
func sentryKeepsHeader(name string) bool {
|
func sentryKeepsHeader(name string) bool {
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"private": true,
|
|
||||||
"devDependencies": {
|
|
||||||
"eslint": "10.11.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user