Compare commits
4
Commits
d88948a1c3
...
17e6be34ab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17e6be34ab | ||
|
|
cb7bafab17 | ||
|
|
2416528b77 | ||
|
|
38157d8936 |
+10
-4
@@ -1,14 +1,20 @@
|
|||||||
|
# .git is sent so the build can derive the version it stamps into the binary
|
||||||
|
# (script/version). Its config, which can hold a remote URL carrying a
|
||||||
|
# credential and which `git describe` does not need, is left out of a
|
||||||
|
# directory context. A context sent as a tar is not filtered by this file, so
|
||||||
|
# it carries .git/config unless its sender leaves it out.
|
||||||
|
.git/config
|
||||||
|
|
||||||
|
# No tracked file may be listed here: git in the build would see it as
|
||||||
|
# deleted and mark the version -dirty.
|
||||||
|
#
|
||||||
# .ci-fingerprint is deliberately NOT excluded: it is the CI cache barrier
|
# .ci-fingerprint is deliberately NOT excluded: it is the CI cache barrier
|
||||||
# that keeps the check stages from replaying a cached pass. See the lint
|
# that keeps the check stages from replaying a cached pass. See the lint
|
||||||
# stage of the Dockerfile.
|
# stage of the Dockerfile.
|
||||||
.git/
|
|
||||||
bin/
|
bin/
|
||||||
# Extracted from 3p/ by `make assets` inside the build; a host copy is not
|
# Extracted from 3p/ by `make assets` inside the build; a host copy is not
|
||||||
# needed. The tarball in 3p/ must stay in the context.
|
# needed. The tarball in 3p/ must stay in the context.
|
||||||
static/js/alpine.min.js
|
static/js/alpine.min.js
|
||||||
*.md
|
|
||||||
LICENSE
|
|
||||||
.editorconfig
|
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
*.db
|
*.db
|
||||||
|
|||||||
@@ -12,9 +12,8 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23
|
||||||
with:
|
with:
|
||||||
# The fingerprint step below needs history to find the last commit
|
# The superseded-status step needs history to walk ancestors (it
|
||||||
# that touched the Docker build context, and the superseded-status
|
# aborts on a shallow clone).
|
||||||
# step needs it to walk ancestors (it aborts on a shallow clone).
|
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Mark superseded run statuses
|
- name: Mark superseded run statuses
|
||||||
@@ -28,16 +27,11 @@ jobs:
|
|||||||
run: script/ci-mark-superseded
|
run: script/ci-mark-superseded
|
||||||
|
|
||||||
- name: Fingerprint the build context
|
- name: Fingerprint the build context
|
||||||
# `.dockerignore` keeps docs out of the build context, so a docs-only
|
# Writes the hash of the commit being checked into the context, which
|
||||||
# commit legitimately replays the whole image from cache and stays
|
# invalidates the `COPY . .` layer of both check stages: a commit
|
||||||
# cheap. Every other commit writes a new fingerprint into the context,
|
# that was never linted, format-checked, tested and built cannot
|
||||||
# which invalidates the `COPY . .` layer of both check stages: a
|
# report success from cache.
|
||||||
# commit that was never linted, formatted-checked, tested and built
|
run: git rev-parse HEAD > .ci-fingerprint
|
||||||
# cannot report success from cache.
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
fp="$(git log -1 --format=%H -- . ':!*.md' ':!LICENSE' ':!.editorconfig')"
|
|
||||||
printf '%s\n' "${fp:-$GITHUB_SHA}" > .ci-fingerprint
|
|
||||||
|
|
||||||
- name: Build Docker image (runs make check)
|
- name: Build Docker image (runs make check)
|
||||||
run: script/cibuild
|
run: script/cibuild
|
||||||
|
|||||||
+22
-9
@@ -12,8 +12,8 @@ WORKDIR /src
|
|||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
# Copy source code. In CI the context also carries .ci-fingerprint, whose
|
# Copy source code. In CI the context also carries .ci-fingerprint, which
|
||||||
# value changes with every commit that touches the build context (see
|
# holds the hash of the commit being checked (see
|
||||||
# .gitea/workflows/check.yml). That invalidates this layer, so the checks
|
# .gitea/workflows/check.yml). That invalidates this layer, so the checks
|
||||||
# below cannot report success by replaying a cached pass. Do not add it to
|
# below cannot report success by replaying a cached pass. Do not add it to
|
||||||
# .dockerignore.
|
# .dockerignore.
|
||||||
@@ -38,8 +38,13 @@ FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a349228
|
|||||||
COPY --from=lint /src/go.sum /dev/null
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
|
||||||
# jq is a runtime dependency of script/ci-mark-superseded, which the test
|
# jq is a runtime dependency of script/ci-mark-superseded, which the test
|
||||||
# suite executes.
|
# suite executes. git is what script/version derives the version with.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq git && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# A build context sent as a tar archive keeps its files' owners, and git
|
||||||
|
# refuses to read a checkout owned by another user. Trust this one
|
||||||
|
# whoever owns it.
|
||||||
|
RUN git config --system --add safe.directory /build
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
@@ -55,14 +60,22 @@ COPY . .
|
|||||||
# from its tarball in 3p/.
|
# from its tarball in 3p/.
|
||||||
RUN make test
|
RUN make test
|
||||||
|
|
||||||
# Version stamped into the binary. .dockerignore excludes .git/, so
|
# Version stamped into the binary: the VERSION build arg when one is
|
||||||
# nothing in this stage can derive it: script/docker resolves it on the
|
# given, otherwise what script/version derives from the .git the build
|
||||||
# host and passes it in. The default is what a bare `docker build .`
|
# context carries, so any `docker build .` of a clone stamps its commit.
|
||||||
# with no --build-arg gets, and it names no tag the tree may not be at.
|
# With neither, as from a source tarball, it is "unknown".
|
||||||
#
|
#
|
||||||
# Declared here, below the test step, so a changed version does not
|
# Declared here, below the test step, so a changed version does not
|
||||||
# invalidate its cached layer.
|
# invalidate its cached layer.
|
||||||
ARG VERSION=unknown
|
ARG VERSION
|
||||||
|
|
||||||
|
# A context that carries .git must not stamp "unknown": that means git is
|
||||||
|
# missing here or could not read the checkout, and the image could not be
|
||||||
|
# traced back to its commit.
|
||||||
|
RUN if [ -d .git ] && [ "$(make version VERSION="$VERSION")" = unknown ]; then \
|
||||||
|
echo "version is unknown although the build context carries .git" >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
RUN make build VERSION="$VERSION"
|
RUN make build VERSION="$VERSION"
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
.DEFAULT_GOAL := check
|
.DEFAULT_GOAL := check
|
||||||
|
|
||||||
# Version stamped into the binary. Derived from git by script/version;
|
# Version stamped into the binary. Derived from git by script/version;
|
||||||
# override it (`make build VERSION=v1.2.3`) where git metadata is
|
# override it (`make build VERSION=v1.2.3`) to stamp a given value, which is
|
||||||
# unavailable, which is how the Dockerfile passes its build arg in.
|
# how the Dockerfile passes its build arg in.
|
||||||
VERSION ?= $(shell script/version)
|
VERSION ?= $(shell script/version)
|
||||||
|
|
||||||
# An empty override (`make build VERSION=`, or a `--build-arg VERSION=`
|
# An empty override (`make build VERSION=`, or the Dockerfile's `make build
|
||||||
# landing on the Dockerfile's `make build VERSION="$VERSION"`) means unset,
|
# VERSION="$VERSION"` when no VERSION build arg was given) means unset,
|
||||||
# exactly as it does in script/version -- stamping "" would leave the binary
|
# exactly as it does in script/version -- stamping "" would leave the binary
|
||||||
# reporting no version and the footer back on its "dev" fallback. `override`
|
# reporting no version and the footer back on its "dev" fallback. `override`
|
||||||
# is required: a plain assignment loses to the command-line definition it
|
# is required: a plain assignment loses to the command-line definition it
|
||||||
|
|||||||
@@ -698,7 +698,8 @@ 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-{uuid}.db`). Mount this as a persistent volume to
|
targets (`archive-{webhook_name}-{target_name}-{target_uuid}.db`). Mount
|
||||||
|
this as a persistent volume to
|
||||||
preserve data across container restarts.
|
preserve data across container restarts.
|
||||||
|
|
||||||
**The container sets its data directory's owner and mode itself
|
**The container sets its data directory's owner and mode itself
|
||||||
@@ -937,13 +938,13 @@ is both the simplest and the only complete rule:
|
|||||||
encryption key), users, API keys, webhooks, entrypoints, targets.
|
encryption key), users, API keys, webhooks, entrypoints, targets.
|
||||||
- `events-{webhook_uuid}.db` — **one per webhook**. Events, deliveries,
|
- `events-{webhook_uuid}.db` — **one per webhook**. Events, deliveries,
|
||||||
delivery results.
|
delivery results.
|
||||||
- `archive-{webhook_uuid}.db` — **one per webhook that has a `database`
|
- `archive-{webhook_name}-{target_name}-{target_uuid}.db` — **one per
|
||||||
target**. Archived events. Keyed on the webhook UUID, not the target
|
`database` target**. Archived events. The two names are made safe for
|
||||||
UUID: a webhook with several `database` targets still has exactly one
|
a file name, and the file is renamed when the webhook or the target is
|
||||||
archive file.
|
(see [Database Architecture](#database-architecture)).
|
||||||
|
|
||||||
`{webhook_uuid}` is the webhook's UUID primary key in its canonical
|
`{webhook_uuid}` and `{target_uuid}` are UUID primary keys in their
|
||||||
36-character hyphenated form, so a real filename looks like
|
canonical 36-character hyphenated form, so a real filename looks like
|
||||||
`events-3f2a1c9e-....db`. The only other file is `webhooker.lock`, the
|
`events-3f2a1c9e-....db`. The only other file is `webhooker.lock`, the
|
||||||
always-empty [single-instance lock](#single-instance-lock); it holds no
|
always-empty [single-instance lock](#single-instance-lock); it holds no
|
||||||
state and is not part of the backup set — a copied one is stale and
|
state and is not part of the backup set — a copied one is stale and
|
||||||
@@ -1017,8 +1018,8 @@ stopped copy.
|
|||||||
|
|
||||||
Archive databases are the one exception the service is built for: the
|
Archive databases are the one exception the service is built for: the
|
||||||
archive writer closes and reopens its handle around writes (debounced
|
archive writer closes and reopens its handle around writes (debounced
|
||||||
to at most one reopen per second), so an operator can move
|
to at most one reopen per second), so an operator can move an
|
||||||
`archive-{uuid}.db` away for offline retention while the service runs,
|
`archive-….db` away for offline retention while the service runs,
|
||||||
and it is recreated on the next write. See
|
and it is recreated on the next write. See
|
||||||
[Database Architecture](#database-architecture). That is a
|
[Database Architecture](#database-architecture). That is a
|
||||||
move-the-file-away workflow, not a substitute for the backup procedures
|
move-the-file-away workflow, not a substitute for the backup procedures
|
||||||
@@ -1036,7 +1037,7 @@ happens on the next write past the debounce window, when the connection
|
|||||||
pool retires the idle connection (about a minute after the last write),
|
pool retires the idle connection (about a minute after the last write),
|
||||||
or at the idle archive sweep — measured, the same file was a complete
|
or at the idle archive sweep — measured, the same file was a complete
|
||||||
20 KB `.db` with no sidecars about a minute after its last write. A
|
20 KB `.db` with no sidecars about a minute after its last write. A
|
||||||
clean stop closes it too. So either move `archive-{uuid}.db` together
|
clean stop closes it too. So either move the `archive-….db` together
|
||||||
with any `-wal`/`-shm` beside it, or wait until there are none.
|
with any `-wal`/`-shm` beside it, or wait until there are none.
|
||||||
|
|
||||||
### Restore
|
### Restore
|
||||||
@@ -1133,13 +1134,29 @@ build itself.
|
|||||||
| Uncommitted changes | the above with a `-dirty` suffix |
|
| Uncommitted changes | the above with a `-dirty` suffix |
|
||||||
| No git metadata | `unknown` |
|
| No git metadata | `unknown` |
|
||||||
|
|
||||||
`unknown` is what a source tarball or a `docker build .` with no
|
The image derives it the same way, from the `.git` that the build
|
||||||
`--build-arg VERSION=...` reports. `.dockerignore` excludes `.git/`, so
|
context carries, so any `docker build .` of a clone, with no build
|
||||||
the build context carries no git metadata and the image cannot derive
|
arguments, stamps the commit it was built from; a shallow clone of one
|
||||||
the version itself: `script/docker` (and so `make docker`) resolves it
|
branch has no tags and stamps the short SHA. `.dockerignore` must
|
||||||
on the host and passes it in as the `VERSION` build arg. A build that
|
therefore leave out neither `.git` nor any tracked file, which git in
|
||||||
reports `unknown` is a build nobody told what it was; it is not a
|
the build would see as deleted, marking the version `-dirty`. It does
|
||||||
failure, but it cannot be traced back to a commit.
|
leave `.git/config`, which can hold a remote URL carrying a credential
|
||||||
|
and which `git describe` does not need, out of a directory context. A
|
||||||
|
context sent as a tar is not filtered by `.dockerignore`, so it carries
|
||||||
|
`.git/config` unless its sender leaves it out; for upaas, that is
|
||||||
|
https://git.eeqj.de/sneak/upaas/issues/274. git in the build
|
||||||
|
reads the checkout whoever owns its files, since a context sent as a tar
|
||||||
|
archive keeps the sender's owners and git otherwise refuses a checkout
|
||||||
|
owned by another user. A `VERSION` build arg (`--build-arg VERSION=...`)
|
||||||
|
takes precedence; `script/docker` (and so `make docker`) passes the one
|
||||||
|
`script/version` resolves on the host. The image build fails if its
|
||||||
|
context carries `.git` and the version still comes out `unknown`, which
|
||||||
|
means git is missing from the build or could not read the checkout.
|
||||||
|
|
||||||
|
`unknown` is what a source tarball, or a `docker build` with no `.git`
|
||||||
|
in its context and no `VERSION` build arg, reports. A build that reports
|
||||||
|
`unknown` is a build nobody told what it was; it is not a failure, but
|
||||||
|
it cannot be traced back to a commit.
|
||||||
|
|
||||||
`make version` prints what the current checkout would stamp, and
|
`make version` prints what the current checkout would stamp, and
|
||||||
`make build VERSION=v1.2.3` overrides it. An empty override — from
|
`make build VERSION=v1.2.3` overrides it. An empty override — from
|
||||||
@@ -1155,7 +1172,7 @@ commit still produce a byte-identical binary.
|
|||||||
Treat a backup with the same care as the credentials inside it. Encrypt
|
Treat a backup with the same care as the credentials inside it. Encrypt
|
||||||
backups at rest and restrict who can read them.
|
backups at rest and restrict who can read them.
|
||||||
|
|
||||||
- `events-{uuid}.db` and `archive-{uuid}.db` hold the **full payload
|
- `events-{uuid}.db` and `archive-….db` hold the **full payload
|
||||||
body and headers** of every event as received, including whatever the
|
body and headers** of every event as received, including whatever the
|
||||||
sending service put in them — tokens, signatures, personal data.
|
sending service put in them — tokens, signatures, personal data.
|
||||||
- Event databases written before
|
- Event databases written before
|
||||||
@@ -1573,8 +1590,9 @@ events should be forwarded.
|
|||||||
is built on the same HTTP core as `http` and honours `max_retries`
|
is built on the same HTTP core as `http` and honours `max_retries`
|
||||||
identically, circuit breaker included. See the Slack target section
|
identically, circuit breaker included. See the Slack target section
|
||||||
under "Per-Webhook Event Databases" for the message format.
|
under "Per-Webhook Event Databases" for the message format.
|
||||||
- **`database`** — Archive the full event as a row into a separate
|
- **`database`** — Archive the full event as a row into the target's
|
||||||
per-webhook archive database (`archive-{webhookID}.db`) for long-term
|
own archive database
|
||||||
|
(`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). No external delivery and no retries; an archive write
|
forever). No external delivery and no retries; an archive write
|
||||||
failure fails the delivery. See the database target section under
|
failure fails the delivery. See the database target section under
|
||||||
@@ -1888,9 +1906,35 @@ The **database target type** builds on this architecture to provide
|
|||||||
long-term archiving, separate from the per-webhook event database (which
|
long-term archiving, separate from the per-webhook event database (which
|
||||||
may prune events under its own retention). Delivering to a database
|
may prune events under its own retention). Delivering to a database
|
||||||
target writes the full event — body, headers, method, content type, and
|
target writes the full event — body, headers, method, content type, and
|
||||||
webhook/entrypoint/event identifiers — as a row into a dedicated archive
|
webhook/entrypoint/event identifiers — as a row into the target's own
|
||||||
database, `archive-{webhookID}.db`, stored under the data directory
|
archive database, `archive-{webhook_name}-{target_name}-{target_uuid}.db`,
|
||||||
beside the event database. After each write the archive handle is closed
|
stored under the data directory beside the event database. Each
|
||||||
|
`database` target has its own archive file, even when one webhook has
|
||||||
|
several.
|
||||||
|
|
||||||
|
Both names are made safe for a file name the same way: lowercased, ASCII
|
||||||
|
letters and digits kept, every other run of characters turned into a
|
||||||
|
single `-`, no `-` at either end, cut to 40 characters, and `unnamed`
|
||||||
|
when nothing is left. The target UUID keeps the file name unique. A
|
||||||
|
webhook named `Orders (EU)` with a target named `Long-term archive`
|
||||||
|
archives into `archive-orders-eu-long-term-archive-{target_uuid}.db`.
|
||||||
|
Renaming the webhook or the target renames the file, under the same
|
||||||
|
lock the archive writes and the archive sweeper take, so the name on
|
||||||
|
disk matches the UI. A rename never replaces a file: if one already has
|
||||||
|
the new name, the edit is refused with an error naming that file, 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
|
||||||
|
stops between the two, the archive is left under the new name while the
|
||||||
|
UI still shows the old one, and the next delivery starts a second
|
||||||
|
archive under the name shown. To bring them back together, move the
|
||||||
|
file under the new name back to the name shown; if a second archive is
|
||||||
|
already there, move the older file out of the data directory instead
|
||||||
|
and keep it as you would any archive moved away.
|
||||||
|
|
||||||
|
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
|
||||||
@@ -1901,35 +1945,33 @@ older than the expiry are pruned each time the archive is (re)opened. An
|
|||||||
archive write failure is never silent success: the delivery records a
|
archive write failure is never silent success: the delivery records a
|
||||||
failed attempt with the error and is marked failed.
|
failed attempt with the error and is marked failed.
|
||||||
|
|
||||||
Because reopens only happen on writes, an archive belonging to a webhook
|
Because reopens only happen on writes, an archive whose target has
|
||||||
that has stopped receiving events would never be pruned. A background
|
stopped receiving events would never be pruned. A background **archive
|
||||||
**archive sweeper** closes that gap: on the same interval as the event
|
sweeper** closes that gap: on the same interval as the event retention
|
||||||
retention reaper (`RETENTION_SWEEP_INTERVAL`) it prunes every archive
|
reaper (`RETENTION_SWEEP_INTERVAL`) it prunes every archive whose
|
||||||
whose database target declares a positive expiry, whether or not the
|
database target declares a positive expiry, whether or not the target
|
||||||
webhook is still receiving traffic. The sweep never creates an archive —
|
is still receiving traffic. The sweep never creates an archive — a
|
||||||
a webhook whose archive file does not yet exist is skipped, not
|
target whose archive file does not yet exist is skipped, not initialised
|
||||||
initialised — it takes the same per-webhook lock the write path uses, so
|
— it takes the same per-target lock the write path uses, so it can never
|
||||||
it can never interleave with a write, and it leaves the archive closed
|
interleave with a write, and it leaves the archive closed afterwards so
|
||||||
afterwards so the move-the-file-away workflow keeps working. Archives
|
the move-the-file-away workflow keeps working. Archives with no expiry,
|
||||||
with no expiry, or the expiry `never`, are not touched by the sweep at
|
or the expiry `never`, are not touched by the sweep at all.
|
||||||
all.
|
|
||||||
|
|
||||||
Note that a webhook has one archive file but may carry more than one
|
Because each `database` target has its own archive file, a target's
|
||||||
`database` target, each with its own `expiry`. The shortest expiry
|
`expiry` governs only its own archive. Two `database` targets on one
|
||||||
configured on any of them therefore governs the whole archive, and the
|
webhook with different expiries keep two archives, each pruned on its
|
||||||
sweep applies it whether or not the webhook is still receiving events.
|
own schedule.
|
||||||
Configure a single `database` target per webhook unless you intend that.
|
|
||||||
|
|
||||||
Deleting a webhook releases its archive: the delivery engine's cached
|
Deleting a webhook releases its archives: the delivery engine's cached
|
||||||
archive writer is dropped and its file handle closed, so nothing lingers
|
archive writers are dropped and their file handles closed, so nothing
|
||||||
after the webhook is gone. The archive **file itself is deliberately
|
lingers after the webhook is gone. The archive **files themselves are
|
||||||
left on disk**. Unlike the event database — per-webhook working storage
|
deliberately left on disk**. Unlike the event database — per-webhook
|
||||||
that is hard-deleted with the webhook — an archive is long-term storage
|
working storage that is hard-deleted with the webhook — an archive is
|
||||||
an operator may still want to keep or move away for offline retention,
|
long-term storage an operator may still want to keep or move away for
|
||||||
and destroying it as a side effect of deleting a webhook would be
|
offline retention, and destroying it as a side effect of deleting a
|
||||||
unrecoverable. Removing `archive-{webhookID}.db` is the operator's call.
|
webhook would be unrecoverable. Removing an `archive-….db` is the
|
||||||
Deleting a webhook's last `database` target releases the writer the same
|
operator's call. Deleting a `database` target releases its writer the
|
||||||
way, and for the same reason leaves the file alone.
|
same way, and for the same reason leaves its file alone.
|
||||||
|
|
||||||
The **Slack target type** sends webhook events as formatted messages to
|
The **Slack target type** sends webhook events as formatted messages to
|
||||||
any Slack-compatible incoming webhook URL (works with Slack, Mattermost,
|
any Slack-compatible incoming webhook URL (works with Slack, Mattermost,
|
||||||
@@ -2939,8 +2981,8 @@ Components are wired via Uber fx in this order:
|
|||||||
11. `delivery.New` — Event-driven delivery engine
|
11. `delivery.New` — Event-driven delivery engine
|
||||||
12. `delivery.NewArchiveSweeper` — Periodic pruning of idle archives
|
12. `delivery.NewArchiveSweeper` — Periodic pruning of idle archives
|
||||||
13. `delivery.Engine` → `delivery.Notifier` — interface bridge
|
13. `delivery.Engine` → `delivery.Notifier` — interface bridge
|
||||||
14. `delivery.Engine` → `delivery.WebhookEvictor` — interface bridge so
|
14. `delivery.Engine` → `delivery.Archives` — interface bridge so
|
||||||
deleting a webhook releases its archive writer
|
deleting or renaming a webhook or target reaches its archive files
|
||||||
15. `server.New` — HTTP server and router
|
15. `server.New` — HTTP server and router
|
||||||
|
|
||||||
The server starts via `fx.Invoke(func(*server.Server, *delivery.Engine,
|
The server starts via `fx.Invoke(func(*server.Server, *delivery.Engine,
|
||||||
@@ -2984,6 +3026,12 @@ local record instead of nothing. What that placement gives up is
|
|||||||
recovery of a panic in the six entries above it, none of which does
|
recovery of a panic in the six entries above it, none of which does
|
||||||
more than set a header or start a timer.
|
more than set a header or start a timer.
|
||||||
|
|
||||||
|
Each admin page route group (`/pages`, `/user/*`, `/hooks`,
|
||||||
|
`/hook/*`) starts with its own **Recoverer** and, if `SENTRY_DSN` is
|
||||||
|
set, its own **Sentry** error reporting. That Recoverer answers a panic
|
||||||
|
with the `500` error page in the normal layout; the global one keeps
|
||||||
|
the plain-text `500` for every other route.
|
||||||
|
|
||||||
Additionally, form endpoints (`/pages`, `/user/*`, `/hooks`,
|
Additionally, form endpoints (`/pages`, `/user/*`, `/hooks`,
|
||||||
`/hook/*`) apply a **MaxBodySize** middleware that limits
|
`/hook/*`) apply a **MaxBodySize** middleware that limits
|
||||||
POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the
|
POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the
|
||||||
@@ -3227,8 +3275,9 @@ version is fixed independently of the compiler's:
|
|||||||
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
|
||||||
stamps the version. The version arrives as the `VERSION` build arg,
|
stamps the version. The version is the `VERSION` build arg if one is
|
||||||
since the context has no `.git` (see
|
given, otherwise derived from the `.git` in the context, and the
|
||||||
|
stage fails if a context with `.git` would stamp `unknown` (see
|
||||||
[Version stamping](#version-stamping)).
|
[Version stamping](#version-stamping)).
|
||||||
3. **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`
|
||||||
@@ -3260,19 +3309,13 @@ A layer cache lets `docker build .` exit 0 in seconds with the lint and
|
|||||||
test stages replayed rather than executed, which would make a green
|
test stages replayed rather than executed, which would make a green
|
||||||
check meaningless. The `check` workflow therefore writes
|
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 last commit that touched the build context, so:
|
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,
|
||||||
|
gets a new fingerprint, invalidates the `COPY . .` layer of both check
|
||||||
|
stages, and really runs `make fmt-check`, `golangci-lint`, `make test`,
|
||||||
|
and `make build`. A run that reports success ran them.
|
||||||
|
|
||||||
- Any commit that changes code (including a squash merge whose tree
|
The module download layer sits above `COPY . .` and stays cached.
|
||||||
matches an already-built branch) gets a new fingerprint, invalidates
|
|
||||||
the `COPY . .` layer of both check stages, and really runs
|
|
||||||
`make fmt-check`, `golangci-lint`, `make test`, and `make build`. A
|
|
||||||
run that reports success ran them.
|
|
||||||
- A docs-only commit leaves the fingerprint unchanged — `.dockerignore`
|
|
||||||
excludes `*.md`, `LICENSE` and `.editorconfig` from the context
|
|
||||||
anyway — so the image replays from cache and costs seconds.
|
|
||||||
|
|
||||||
The module download layer sits above `COPY . .` and stays cached either
|
|
||||||
way.
|
|
||||||
|
|
||||||
A separate workflow step, run before the fingerprint is written, covers
|
A separate workflow step, run before the fingerprint is written, covers
|
||||||
a second way the gate lied: Gitea cancels an in-flight run when a newer
|
a second way the gate lied: Gitea cancels an in-flight run when a newer
|
||||||
|
|||||||
@@ -187,11 +187,10 @@ func newApp() *fx.App {
|
|||||||
// Wire *delivery.Engine as delivery.Notifier so the
|
// Wire *delivery.Engine as delivery.Notifier so the
|
||||||
// webhook handler can notify the engine of new deliveries.
|
// webhook handler can notify the engine of new deliveries.
|
||||||
func(e *delivery.Engine) delivery.Notifier { return e },
|
func(e *delivery.Engine) delivery.Notifier { return e },
|
||||||
// Wire *delivery.Engine as delivery.WebhookEvictor so
|
// Wire *delivery.Engine as delivery.Archives so deleting
|
||||||
// deleting a webhook releases its archive writer.
|
// or renaming a webhook or target reaches its archive
|
||||||
func(e *delivery.Engine) delivery.WebhookEvictor {
|
// files.
|
||||||
return e
|
func(e *delivery.Engine) delivery.Archives { return e },
|
||||||
},
|
|
||||||
server.New,
|
server.New,
|
||||||
),
|
),
|
||||||
fx.Invoke(
|
fx.Invoke(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"gorm.io/gorm"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/lifecycle"
|
"sneak.berlin/go/webhooker/internal/lifecycle"
|
||||||
@@ -25,14 +26,14 @@ type ArchiveSweeperParams struct {
|
|||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArchiveSweeper periodically prunes expired rows from
|
// ArchiveSweeper periodically prunes expired rows from the
|
||||||
// per-webhook archive databases whose database target carries a
|
// archive databases of database targets that carry a positive
|
||||||
// positive expiry.
|
// expiry.
|
||||||
//
|
//
|
||||||
// Without it, pruning happens only when an archive is
|
// Without it, pruning happens only when an archive is
|
||||||
// (re)opened, and archives are only ever reopened by writes: an
|
// (re)opened, and archives are only ever reopened by writes: an
|
||||||
// archive belonging to a webhook that has stopped receiving
|
// archive whose target has stopped receiving events would keep
|
||||||
// events would keep its expired rows forever. The sweep closes
|
// its expired rows forever. The sweep closes
|
||||||
// that gap without changing anything for archives whose expiry
|
// that gap without changing anything for archives whose expiry
|
||||||
// is unset or "never".
|
// is unset or "never".
|
||||||
//
|
//
|
||||||
@@ -155,7 +156,7 @@ func (s *ArchiveSweeper) run(ctx context.Context) {
|
|||||||
// soft-deleted along with it, so GORM's default scope already
|
// soft-deleted along with it, so GORM's default scope already
|
||||||
// excludes them.
|
// excludes them.
|
||||||
//
|
//
|
||||||
// A failure for one webhook is logged and the sweep continues,
|
// A failure for one target is logged and the sweep continues,
|
||||||
// matching how the write path already treats a prune error as
|
// matching how the write path already treats a prune error as
|
||||||
// non-fatal.
|
// non-fatal.
|
||||||
func (s *ArchiveSweeper) sweep(ctx context.Context) {
|
func (s *ArchiveSweeper) sweep(ctx context.Context) {
|
||||||
@@ -210,19 +211,20 @@ func (s *ArchiveSweeper) sweepTarget(target *database.Target) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.eng.dbTarget.sweepWebhook(target.WebhookID, expiry)
|
err = s.eng.dbTarget.sweepArchive(target.ID, expiry)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// A writer evicted underneath the sweep means the operator
|
// A writer evicted, or a target row gone, underneath the sweep
|
||||||
// deleted the webhook (or its last database target) while the
|
// means the operator deleted the target or its webhook while
|
||||||
// sweep was walking the target list. That is an ordinary
|
// the sweep was walking the target list. That is an ordinary
|
||||||
// interleaving, not a failure, so it must not produce an
|
// interleaving, not a failure, so it must not produce an
|
||||||
// error line.
|
// error line.
|
||||||
if errors.Is(err, errArchiveWriterEvicted) {
|
if errors.Is(err, errArchiveWriterEvicted) ||
|
||||||
|
errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
s.log.Debug(
|
s.log.Debug(
|
||||||
"archive sweep: writer evicted mid-sweep",
|
"archive sweep: target deleted mid-sweep",
|
||||||
"webhook_id", target.WebhookID,
|
"webhook_id", target.WebhookID,
|
||||||
"target_id", target.ID,
|
"target_id", target.ID,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -34,18 +34,23 @@ const (
|
|||||||
sweepConcurrentWrites = 20
|
sweepConcurrentWrites = 20
|
||||||
)
|
)
|
||||||
|
|
||||||
// sweeperEnv bundles the pieces an archive sweep test drives:
|
// archiveTestWebhookName is the name of every webhook
|
||||||
// a main configuration database holding webhooks and targets, a
|
// seedDatabaseTarget creates. It is not safe in a file name as it
|
||||||
// delivery engine owning the archive writer registry, and the
|
// stands, so every archive test goes through archiveNamePart.
|
||||||
// data directory the archive files live in.
|
const archiveTestWebhookName = "Sweep Test!"
|
||||||
type sweeperEnv struct {
|
|
||||||
|
// archiveEnv bundles the pieces an archive test drives: a main
|
||||||
|
// configuration database holding webhooks and targets, a delivery
|
||||||
|
// engine owning the archive writer registry, the archive sweeper,
|
||||||
|
// and the data directory the archive files live in.
|
||||||
|
type archiveEnv struct {
|
||||||
sweeper *delivery.ArchiveSweeper
|
sweeper *delivery.ArchiveSweeper
|
||||||
eng *delivery.Engine
|
eng *delivery.Engine
|
||||||
mainDB *database.Database
|
mainDB *database.Database
|
||||||
dataDir string
|
dataDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupSweeperTest(t *testing.T) *sweeperEnv {
|
func setupArchiveTest(t *testing.T) *archiveEnv {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
dataDir := t.TempDir()
|
dataDir := t.TempDir()
|
||||||
@@ -78,7 +83,7 @@ func setupSweeperTest(t *testing.T) *sweeperEnv {
|
|||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
|
|
||||||
return &sweeperEnv{
|
return &archiveEnv{
|
||||||
sweeper: delivery.NewTestArchiveSweeper(
|
sweeper: delivery.NewTestArchiveSweeper(
|
||||||
mainDB, eng, log,
|
mainDB, eng, log,
|
||||||
),
|
),
|
||||||
@@ -88,25 +93,27 @@ func setupSweeperTest(t *testing.T) *sweeperEnv {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// archivePath returns where the engine keeps a webhook's
|
// archivePath returns where the engine keeps a database target's
|
||||||
// archive file.
|
// archive file, for the names seedDatabaseTarget gave it.
|
||||||
func (env *sweeperEnv) archivePath(webhookID string) string {
|
func (env *archiveEnv) archivePath(tgt *database.Target) string {
|
||||||
return filepath.Join(
|
return filepath.Join(
|
||||||
env.dataDir, fmt.Sprintf("archive-%s.db", webhookID),
|
env.dataDir,
|
||||||
|
delivery.ArchiveFileName(
|
||||||
|
archiveTestWebhookName, tgt.Name, tgt.ID,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedDatabaseTarget creates a webhook with one database target
|
// seedDatabaseTarget creates a webhook with one database target
|
||||||
// carrying the given target config JSON, and returns the
|
// carrying the given target config JSON, and returns the target.
|
||||||
// webhook id.
|
func (env *archiveEnv) seedDatabaseTarget(
|
||||||
func (env *sweeperEnv) seedDatabaseTarget(
|
|
||||||
t *testing.T, configJSON string,
|
t *testing.T, configJSON string,
|
||||||
) string {
|
) *database.Target {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
wh := &database.Webhook{
|
wh := &database.Webhook{
|
||||||
UserID: uuid.New().String(),
|
UserID: uuid.New().String(),
|
||||||
Name: "sweep-test",
|
Name: archiveTestWebhookName,
|
||||||
}
|
}
|
||||||
require.NoError(
|
require.NoError(
|
||||||
t,
|
t,
|
||||||
@@ -115,9 +122,19 @@ func (env *sweeperEnv) seedDatabaseTarget(
|
|||||||
Create(wh).Error,
|
Create(wh).Error,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return env.addDatabaseTarget(t, wh.ID, configJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addDatabaseTarget creates one more database target on an
|
||||||
|
// existing webhook and returns it.
|
||||||
|
func (env *archiveEnv) addDatabaseTarget(
|
||||||
|
t *testing.T, webhookID, configJSON string,
|
||||||
|
) *database.Target {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
tgt := &database.Target{
|
tgt := &database.Target{
|
||||||
WebhookID: wh.ID,
|
WebhookID: webhookID,
|
||||||
Name: "archive",
|
Name: "Archive",
|
||||||
Type: database.TargetTypeDatabase,
|
Type: database.TargetTypeDatabase,
|
||||||
Active: true,
|
Active: true,
|
||||||
Config: configJSON,
|
Config: configJSON,
|
||||||
@@ -129,19 +146,19 @@ func (env *sweeperEnv) seedDatabaseTarget(
|
|||||||
Create(tgt).Error,
|
Create(tgt).Error,
|
||||||
)
|
)
|
||||||
|
|
||||||
return wh.ID
|
return tgt
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedArchiveRows creates the archive file for a webhook and
|
// seedArchiveRows creates the archive file for a target and
|
||||||
// inserts one row per supplied archived-at timestamp, returning
|
// inserts one row per supplied archived-at timestamp, returning
|
||||||
// the archive path. The handle is closed before returning, so
|
// the archive path. The handle is closed before returning, so
|
||||||
// the archive is idle exactly as it would be with no traffic.
|
// the archive is idle exactly as it would be with no traffic.
|
||||||
func (env *sweeperEnv) seedArchiveRows(
|
func (env *archiveEnv) seedArchiveRows(
|
||||||
t *testing.T, webhookID string, archivedAt ...time.Time,
|
t *testing.T, tgt *database.Target, archivedAt ...time.Time,
|
||||||
) string {
|
) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
path := env.archivePath(webhookID)
|
path := env.archivePath(tgt)
|
||||||
|
|
||||||
sqlDB, err := sql.Open(
|
sqlDB, err := sql.Open(
|
||||||
"sqlite", fmt.Sprintf("file:%s?mode=rwc", path),
|
"sqlite", fmt.Sprintf("file:%s?mode=rwc", path),
|
||||||
@@ -160,7 +177,7 @@ func (env *sweeperEnv) seedArchiveRows(
|
|||||||
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,
|
||||||
@@ -243,13 +260,13 @@ func TestArchiveSweeper_LoopOutlivesStartHookContext(
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupSweeperTest(t)
|
env := setupArchiveTest(t)
|
||||||
|
|
||||||
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
path := env.seedArchiveRows(
|
path := env.seedArchiveRows(
|
||||||
t, webhookID,
|
t, tgt,
|
||||||
now.Add(-48*time.Hour),
|
now.Add(-48*time.Hour),
|
||||||
now.Add(-time.Minute),
|
now.Add(-time.Minute),
|
||||||
)
|
)
|
||||||
@@ -287,60 +304,60 @@ func TestArchiveSweeper_LoopOutlivesStartHookContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestArchiveSweep_DoesNotResurrectEvictedWriter covers the
|
// TestArchiveSweep_DoesNotResurrectEvictedWriter covers the
|
||||||
// interleaving where a sweep tick has already listed a webhook's
|
// interleaving where a sweep tick has already listed a target
|
||||||
// target when the webhook is deleted and its writer evicted. The
|
// when the target is deleted and its writer evicted. The sweep
|
||||||
// sweep must not put a writer back into the registry: nothing
|
// must not put a writer back into the registry: nothing would
|
||||||
// would ever evict it again, which is precisely the leak this
|
// ever evict it again, which is precisely the leak this change
|
||||||
// change exists to close.
|
// exists to close.
|
||||||
func TestArchiveSweep_DoesNotResurrectEvictedWriter(
|
func TestArchiveSweep_DoesNotResurrectEvictedWriter(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupSweeperTest(t)
|
env := setupArchiveTest(t)
|
||||||
|
|
||||||
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
env.seedArchiveRows(
|
env.seedArchiveRows(
|
||||||
t, webhookID, time.Now().Add(-48*time.Hour),
|
t, tgt, time.Now().Add(-48*time.Hour),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Prime the registry the way a delivery would, then evict as
|
// Prime the registry the way a delivery would, then evict as
|
||||||
// the deletion path does. The target row is deliberately left
|
// the deletion path does. The target row is deliberately left
|
||||||
// in place: this is the tick that listed the webhook before
|
// in place: this is the tick that listed the target before
|
||||||
// the deletion committed.
|
// the deletion committed.
|
||||||
_, err := env.eng.ExportEnsureArchiveWriter(webhookID)
|
_, err := env.eng.ExportEnsureArchiveWriter(tgt.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
env.eng.EvictWebhook(webhookID)
|
env.eng.EvictTarget(tgt.ID)
|
||||||
require.False(t, env.eng.ExportHasArchiveWriter(webhookID))
|
require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID))
|
||||||
|
|
||||||
env.sweeper.ExportSweep(context.Background())
|
env.sweeper.ExportSweep(context.Background())
|
||||||
|
|
||||||
assert.False(
|
assert.False(
|
||||||
t, env.eng.ExportHasArchiveWriter(webhookID),
|
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||||
"a sweep must never re-register a writer for a webhook "+
|
"a sweep must never re-register a writer for a target "+
|
||||||
"whose registry entry has already been released",
|
"whose registry entry has already been released",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestArchiveSweep_LeavesNoRegistryEntry states the same
|
// TestArchiveSweep_LeavesNoRegistryEntry states the same
|
||||||
// invariant in its general form: sweeping an archive whose
|
// invariant in its general form: sweeping an archive whose
|
||||||
// webhook has no cached writer must not leave one behind, so the
|
// target has no cached writer must not leave one behind, so the
|
||||||
// registry keeps holding only writers a delivery created and an
|
// registry keeps holding only writers a delivery created and an
|
||||||
// eviction can reach.
|
// eviction can reach.
|
||||||
func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) {
|
func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupSweeperTest(t)
|
env := setupArchiveTest(t)
|
||||||
|
|
||||||
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
path := env.seedArchiveRows(
|
path := env.seedArchiveRows(
|
||||||
t, webhookID,
|
t, tgt,
|
||||||
time.Now().Add(-48*time.Hour),
|
time.Now().Add(-48*time.Hour),
|
||||||
time.Now().Add(-time.Minute),
|
time.Now().Add(-time.Minute),
|
||||||
)
|
)
|
||||||
|
|
||||||
require.False(t, env.eng.ExportHasArchiveWriter(webhookID))
|
require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID))
|
||||||
|
|
||||||
env.sweeper.ExportSweep(context.Background())
|
env.sweeper.ExportSweep(context.Background())
|
||||||
|
|
||||||
@@ -349,7 +366,7 @@ func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) {
|
|||||||
"the sweep must still prune an idle archive",
|
"the sweep must still prune an idle archive",
|
||||||
)
|
)
|
||||||
assert.False(
|
assert.False(
|
||||||
t, env.eng.ExportHasArchiveWriter(webhookID),
|
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||||
"the sweep must release the registry entry it created",
|
"the sweep must release the registry entry it created",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -364,34 +381,31 @@ func TestArchiveSweep_KeepsWriterAdoptedByDelivery(
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupSweeperTest(t)
|
env := setupArchiveTest(t)
|
||||||
|
|
||||||
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
env.seedArchiveRows(
|
env.seedArchiveRows(
|
||||||
t, webhookID, time.Now().Add(-48*time.Hour),
|
t, tgt, time.Now().Add(-48*time.Hour),
|
||||||
)
|
)
|
||||||
|
|
||||||
webhookDB := testWebhookDB(t)
|
webhookDB := testWebhookDB(t)
|
||||||
event := seedEvent(t, webhookDB, `{"n":1}`)
|
event := seedEvent(t, webhookDB, `{"n":1}`)
|
||||||
event.WebhookID = webhookID
|
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
|
||||||
d := seedDatabaseTargetDelivery(
|
|
||||||
t, webhookDB, event, `{"expiry":"1h"}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
env.sweeper.ExportSweep(context.Background())
|
env.sweeper.ExportSweep(context.Background())
|
||||||
require.False(t, env.eng.ExportHasArchiveWriter(webhookID))
|
require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID))
|
||||||
|
|
||||||
env.eng.ExportDeliverDatabase(webhookDB, d)
|
env.eng.ExportDeliverDatabase(webhookDB, d)
|
||||||
|
|
||||||
assert.True(
|
assert.True(
|
||||||
t, env.eng.ExportHasArchiveWriter(webhookID),
|
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||||
"a delivery's writer must stay registered",
|
"a delivery's writer must stay registered",
|
||||||
)
|
)
|
||||||
|
|
||||||
env.sweeper.ExportSweep(context.Background())
|
env.sweeper.ExportSweep(context.Background())
|
||||||
|
|
||||||
assert.True(
|
assert.True(
|
||||||
t, env.eng.ExportHasArchiveWriter(webhookID),
|
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||||
"a sweep must not drop a writer a delivery owns",
|
"a sweep must not drop a writer a delivery owns",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -423,15 +437,15 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupSweeperTest(t)
|
env := setupArchiveTest(t)
|
||||||
|
|
||||||
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
env.seedArchiveRows(
|
env.seedArchiveRows(
|
||||||
t, webhookID, time.Now().Add(-48*time.Hour),
|
t, tgt, time.Now().Add(-48*time.Hour),
|
||||||
)
|
)
|
||||||
|
|
||||||
sweepWriter, created, err := env.eng.ExportSweepWriterFor(
|
sweepWriter, created, err := env.eng.ExportSweepWriterFor(
|
||||||
webhookID,
|
tgt.ID,
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.True(
|
require.True(
|
||||||
@@ -442,37 +456,34 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
|
|||||||
// The delivery lands mid-sweep and adopts the entry.
|
// The delivery lands mid-sweep and adopts the entry.
|
||||||
webhookDB := testWebhookDB(t)
|
webhookDB := testWebhookDB(t)
|
||||||
event := seedEvent(t, webhookDB, `{"n":1}`)
|
event := seedEvent(t, webhookDB, `{"n":1}`)
|
||||||
event.WebhookID = webhookID
|
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
|
||||||
d := seedDatabaseTargetDelivery(
|
|
||||||
t, webhookDB, event, `{"expiry":"1h"}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
env.eng.ExportDeliverDatabase(webhookDB, d)
|
env.eng.ExportDeliverDatabase(webhookDB, d)
|
||||||
|
|
||||||
adopted := env.eng.ExportArchiveWriterFor(webhookID)
|
adopted := env.eng.ExportArchiveWriterFor(tgt.ID)
|
||||||
require.NotNil(t, adopted)
|
require.NotNil(t, adopted)
|
||||||
require.True(
|
require.True(
|
||||||
t, sweepWriter.Same(adopted),
|
t, sweepWriter.Same(adopted),
|
||||||
"the delivery must have adopted the sweep's writer",
|
"the delivery must have adopted the sweep's writer",
|
||||||
)
|
)
|
||||||
require.True(
|
require.True(
|
||||||
t, env.eng.ExportArchiveHandleOpen(webhookID),
|
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
|
||||||
"the delivery leaves the archive handle open",
|
"the delivery leaves the archive handle open",
|
||||||
)
|
)
|
||||||
|
|
||||||
// The sweep finishes.
|
// The sweep finishes.
|
||||||
env.eng.ExportReleaseSweepWriter(webhookID, sweepWriter)
|
env.eng.ExportReleaseSweepWriter(tgt.ID, sweepWriter)
|
||||||
|
|
||||||
require.True(
|
require.True(
|
||||||
t, env.eng.ExportHasArchiveWriter(webhookID),
|
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||||
"a writer adopted by a delivery during a sweep must "+
|
"a writer adopted by a delivery during a sweep must "+
|
||||||
"stay registered, or its open handle is unreachable",
|
"stay registered, or its open handle is unreachable",
|
||||||
)
|
)
|
||||||
|
|
||||||
env.eng.EvictWebhook(webhookID)
|
env.eng.EvictTarget(tgt.ID)
|
||||||
|
|
||||||
assert.False(
|
assert.False(
|
||||||
t, env.eng.ExportHasArchiveWriter(webhookID),
|
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||||
"the adopted writer must still be evictable",
|
"the adopted writer must still be evictable",
|
||||||
)
|
)
|
||||||
assert.False(
|
assert.False(
|
||||||
@@ -481,34 +492,34 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestArchiveSweep_ContinuesAfterPerWebhookFailure proves a
|
// TestArchiveSweep_ContinuesAfterPerTargetFailure proves a
|
||||||
// failure for one webhook does not abort the sweep for the
|
// failure for one target does not abort the sweep for the
|
||||||
// others: an unparseable expiry and an unreadable archive both
|
// others: an unparseable expiry and an unreadable archive both
|
||||||
// have to be logged and stepped over.
|
// have to be logged and stepped over.
|
||||||
func TestArchiveSweep_ContinuesAfterPerWebhookFailure(
|
func TestArchiveSweep_ContinuesAfterPerTargetFailure(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupSweeperTest(t)
|
env := setupArchiveTest(t)
|
||||||
|
|
||||||
// Seeded first so the sweep reaches them before the healthy
|
// Seeded first so the sweep reaches them before the healthy
|
||||||
// webhook: targets come back in insertion order.
|
// target: targets come back in insertion order.
|
||||||
badConfigID := env.seedDatabaseTarget(t, `{"expiry":"!!!"}`)
|
badConfig := env.seedDatabaseTarget(t, `{"expiry":"!!!"}`)
|
||||||
env.seedArchiveRows(
|
env.seedArchiveRows(
|
||||||
t, badConfigID, time.Now().Add(-48*time.Hour),
|
t, badConfig, time.Now().Add(-48*time.Hour),
|
||||||
)
|
)
|
||||||
|
|
||||||
corruptID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
corrupt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
require.NoError(t, os.WriteFile(
|
require.NoError(t, os.WriteFile(
|
||||||
env.archivePath(corruptID),
|
env.archivePath(corrupt),
|
||||||
[]byte("this is not a sqlite database"),
|
[]byte("this is not a sqlite database"),
|
||||||
0o600,
|
0o600,
|
||||||
))
|
))
|
||||||
|
|
||||||
healthyID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
healthy := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
healthyPath := env.seedArchiveRows(
|
healthyPath := env.seedArchiveRows(
|
||||||
t, healthyID,
|
t, healthy,
|
||||||
time.Now().Add(-48*time.Hour),
|
time.Now().Add(-48*time.Hour),
|
||||||
time.Now().Add(-time.Minute),
|
time.Now().Add(-time.Minute),
|
||||||
)
|
)
|
||||||
@@ -518,14 +529,14 @@ func TestArchiveSweep_ContinuesAfterPerWebhookFailure(
|
|||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, []string{sweepRowNew},
|
t, []string{sweepRowNew},
|
||||||
archivedEventIDs(t, healthyPath),
|
archivedEventIDs(t, healthyPath),
|
||||||
"a failure for an earlier webhook must not stop the "+
|
"a failure for an earlier target must not stop the "+
|
||||||
"sweep from pruning the ones after it",
|
"sweep from pruning the ones after it",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestArchiveSweep_OpenExistingDoesNotCreateFile pins the second
|
// TestArchiveSweep_OpenExistingDoesNotCreateFile pins the second
|
||||||
// of the two no-create guards. The first is the stat in
|
// of the two no-create guards. The first is the stat in
|
||||||
// sweepWebhook; this one is the SQLite open mode, which is what
|
// sweepExpired; this one is the SQLite open mode, which is what
|
||||||
// protects the window between that stat and the open. Flipping
|
// protects the window between that stat and the open. Flipping
|
||||||
// the sweep's mode to create-if-missing makes this fail.
|
// the sweep's mode to create-if-missing makes this fail.
|
||||||
func TestArchiveSweep_OpenExistingDoesNotCreateFile(
|
func TestArchiveSweep_OpenExistingDoesNotCreateFile(
|
||||||
@@ -561,13 +572,13 @@ func TestArchiveSweep_OpenExistingDoesNotCreateFile(
|
|||||||
func TestArchiveSweep_PrunesIdleArchive(t *testing.T) {
|
func TestArchiveSweep_PrunesIdleArchive(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupSweeperTest(t)
|
env := setupArchiveTest(t)
|
||||||
|
|
||||||
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
path := env.seedArchiveRows(
|
path := env.seedArchiveRows(
|
||||||
t, webhookID,
|
t, tgt,
|
||||||
now.Add(-48*time.Hour),
|
now.Add(-48*time.Hour),
|
||||||
now.Add(-time.Minute),
|
now.Add(-time.Minute),
|
||||||
)
|
)
|
||||||
@@ -600,11 +611,11 @@ func TestArchiveSweep_PrunesIdleArchive(t *testing.T) {
|
|||||||
func TestArchiveSweep_LeavesArchiveClosed(t *testing.T) {
|
func TestArchiveSweep_LeavesArchiveClosed(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupSweeperTest(t)
|
env := setupArchiveTest(t)
|
||||||
|
|
||||||
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
path := env.seedArchiveRows(
|
path := env.seedArchiveRows(
|
||||||
t, webhookID, time.Now().Add(-48*time.Hour),
|
t, tgt, time.Now().Add(-48*time.Hour),
|
||||||
)
|
)
|
||||||
|
|
||||||
w := delivery.NewExportArchiveWriter(
|
w := delivery.NewExportArchiveWriter(
|
||||||
@@ -640,35 +651,32 @@ func TestArchiveSweep_ClosesHandleOfRegisteredWriter(
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupSweeperTest(t)
|
env := setupArchiveTest(t)
|
||||||
|
|
||||||
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
env.seedArchiveRows(
|
env.seedArchiveRows(
|
||||||
t, webhookID, time.Now().Add(-48*time.Hour),
|
t, tgt, time.Now().Add(-48*time.Hour),
|
||||||
)
|
)
|
||||||
|
|
||||||
webhookDB := testWebhookDB(t)
|
webhookDB := testWebhookDB(t)
|
||||||
event := seedEvent(t, webhookDB, `{"n":1}`)
|
event := seedEvent(t, webhookDB, `{"n":1}`)
|
||||||
event.WebhookID = webhookID
|
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
|
||||||
d := seedDatabaseTargetDelivery(
|
|
||||||
t, webhookDB, event, `{"expiry":"1h"}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
env.eng.ExportDeliverDatabase(webhookDB, d)
|
env.eng.ExportDeliverDatabase(webhookDB, d)
|
||||||
|
|
||||||
require.True(
|
require.True(
|
||||||
t, env.eng.ExportArchiveHandleOpen(webhookID),
|
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
|
||||||
"the delivery must leave the archive handle open",
|
"the delivery must leave the archive handle open",
|
||||||
)
|
)
|
||||||
|
|
||||||
env.sweeper.ExportSweep(context.Background())
|
env.sweeper.ExportSweep(context.Background())
|
||||||
|
|
||||||
require.True(
|
require.True(
|
||||||
t, env.eng.ExportHasArchiveWriter(webhookID),
|
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||||
"the delivery's registry entry must survive the sweep",
|
"the delivery's registry entry must survive the sweep",
|
||||||
)
|
)
|
||||||
assert.False(
|
assert.False(
|
||||||
t, env.eng.ExportArchiveHandleOpen(webhookID),
|
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
|
||||||
"the sweep must leave the archive closed",
|
"the sweep must leave the archive closed",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -684,11 +692,11 @@ func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) {
|
|||||||
`{"expiry":""}`,
|
`{"expiry":""}`,
|
||||||
"",
|
"",
|
||||||
} {
|
} {
|
||||||
env := setupSweeperTest(t)
|
env := setupArchiveTest(t)
|
||||||
|
|
||||||
webhookID := env.seedDatabaseTarget(t, configJSON)
|
tgt := env.seedDatabaseTarget(t, configJSON)
|
||||||
path := env.seedArchiveRows(
|
path := env.seedArchiveRows(
|
||||||
t, webhookID,
|
t, tgt,
|
||||||
time.Now().Add(-10000*time.Hour),
|
time.Now().Add(-10000*time.Hour),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -699,7 +707,7 @@ func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) {
|
|||||||
"config %q must keep rows forever", configJSON,
|
"config %q must keep rows forever", configJSON,
|
||||||
)
|
)
|
||||||
assert.False(
|
assert.False(
|
||||||
t, env.eng.ExportHasArchiveWriter(webhookID),
|
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||||
"config %q must leave no registry entry behind",
|
"config %q must leave no registry entry behind",
|
||||||
configJSON,
|
configJSON,
|
||||||
)
|
)
|
||||||
@@ -722,10 +730,10 @@ func TestArchiveSweep_NeverExpirySkipsBeforeOpening(
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupSweeperTest(t)
|
env := setupArchiveTest(t)
|
||||||
|
|
||||||
webhookID := env.seedDatabaseTarget(t, `{"expiry":"never"}`)
|
tgt := env.seedDatabaseTarget(t, `{"expiry":"never"}`)
|
||||||
path := env.archivePath(webhookID)
|
path := env.archivePath(tgt)
|
||||||
|
|
||||||
seedUnmigratedArchive(t, path)
|
seedUnmigratedArchive(t, path)
|
||||||
require.False(t, archiveTableExists(t, path))
|
require.False(t, archiveTableExists(t, path))
|
||||||
@@ -768,16 +776,16 @@ func archiveTableExists(t *testing.T, path string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestArchiveSweep_DoesNotCreateArchiveFile proves the sweep
|
// TestArchiveSweep_DoesNotCreateArchiveFile proves the sweep
|
||||||
// never conjures an archive: a webhook with a database target
|
// never conjures an archive: a database target that has never
|
||||||
// that has never received an event must still have no archive
|
// received an event must still have no archive file (nor SQLite
|
||||||
// file (nor SQLite sidecar) after a sweep.
|
// sidecar) after a sweep, and no registry entry either.
|
||||||
func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) {
|
func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupSweeperTest(t)
|
env := setupArchiveTest(t)
|
||||||
|
|
||||||
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
path := env.archivePath(webhookID)
|
path := env.archivePath(tgt)
|
||||||
|
|
||||||
require.NoFileExists(t, path)
|
require.NoFileExists(t, path)
|
||||||
|
|
||||||
@@ -789,6 +797,11 @@ func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) {
|
|||||||
"the sweep must not create an archive file",
|
"the sweep must not create an archive file",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assert.False(
|
||||||
|
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||||
|
"the sweep must leave no registry entry behind",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestArchiveSweep_DoesNotCreateAfterWriterExists covers the
|
// TestArchiveSweep_DoesNotCreateAfterWriterExists covers the
|
||||||
@@ -800,11 +813,11 @@ func TestArchiveSweep_DoesNotCreateAfterWriterExists(
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupSweeperTest(t)
|
env := setupArchiveTest(t)
|
||||||
|
|
||||||
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
|
|
||||||
path, err := env.eng.ExportEnsureArchiveWriter(webhookID)
|
path, err := env.eng.ExportEnsureArchiveWriter(tgt.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoFileExists(t, path)
|
require.NoFileExists(t, path)
|
||||||
|
|
||||||
@@ -819,17 +832,17 @@ func TestArchiveSweep_DoesNotCreateAfterWriterExists(
|
|||||||
func TestArchiveSweep_SkipsDeletedWebhookTargets(t *testing.T) {
|
func TestArchiveSweep_SkipsDeletedWebhookTargets(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupSweeperTest(t)
|
env := setupArchiveTest(t)
|
||||||
|
|
||||||
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
path := env.seedArchiveRows(
|
path := env.seedArchiveRows(
|
||||||
t, webhookID, time.Now().Add(-48*time.Hour),
|
t, tgt, time.Now().Add(-48*time.Hour),
|
||||||
)
|
)
|
||||||
|
|
||||||
require.NoError(
|
require.NoError(
|
||||||
t,
|
t,
|
||||||
env.mainDB.DB().
|
env.mainDB.DB().
|
||||||
Where("webhook_id = ?", webhookID).
|
Where("webhook_id = ?", tgt.WebhookID).
|
||||||
Delete(&database.Target{}).Error,
|
Delete(&database.Target{}).Error,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -842,14 +855,14 @@ func TestArchiveSweep_SkipsDeletedWebhookTargets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestArchiveSweep_ConcurrentWrites proves the sweep serialises
|
// TestArchiveSweep_ConcurrentWrites proves the sweep serialises
|
||||||
// against writes through the per-webhook writer mutex. Run
|
// against writes through the target's writer mutex. Run under
|
||||||
// under -race, an unsynchronised sweep would be caught here.
|
// -race, an unsynchronised sweep would be caught here.
|
||||||
func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
|
func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupSweeperTest(t)
|
env := setupArchiveTest(t)
|
||||||
|
|
||||||
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
|
|
||||||
webhookDB := testWebhookDB(t)
|
webhookDB := testWebhookDB(t)
|
||||||
|
|
||||||
@@ -862,13 +875,10 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
|
|||||||
|
|
||||||
for range sweepConcurrentWrites {
|
for range sweepConcurrentWrites {
|
||||||
event := seedEvent(t, webhookDB, `{"n":1}`)
|
event := seedEvent(t, webhookDB, `{"n":1}`)
|
||||||
event.WebhookID = webhookID
|
|
||||||
|
|
||||||
deliveries = append(
|
deliveries = append(
|
||||||
deliveries,
|
deliveries,
|
||||||
seedDatabaseTargetDelivery(
|
seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
|
||||||
t, webhookDB, event, `{"expiry":"1h"}`,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -894,7 +904,7 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
|
|||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
assert.FileExists(t, env.archivePath(webhookID))
|
assert.FileExists(t, env.archivePath(tgt))
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestArchiveSweeper_StopsCleanly proves the background loop
|
// TestArchiveSweeper_StopsCleanly proves the background loop
|
||||||
@@ -902,11 +912,11 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
|
|||||||
func TestArchiveSweeper_StopsCleanly(t *testing.T) {
|
func TestArchiveSweeper_StopsCleanly(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupSweeperTest(t)
|
env := setupArchiveTest(t)
|
||||||
|
|
||||||
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||||
env.seedArchiveRows(
|
env.seedArchiveRows(
|
||||||
t, webhookID, time.Now().Add(-48*time.Hour),
|
t, tgt, time.Now().Add(-48*time.Hour),
|
||||||
)
|
)
|
||||||
|
|
||||||
env.sweeper.ExportSetInterval(time.Millisecond)
|
env.sweeper.ExportSetInterval(time.Millisecond)
|
||||||
@@ -930,7 +940,7 @@ func TestArchiveSweeper_StopHookHonoursStopTimeout(
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupSweeperTest(t)
|
env := setupArchiveTest(t)
|
||||||
|
|
||||||
lc := &recordingLifecycle{}
|
lc := &recordingLifecycle{}
|
||||||
env.sweeper.ExportRegisterHooks(lc)
|
env.sweeper.ExportRegisterHooks(lc)
|
||||||
|
|||||||
+51
-20
@@ -122,21 +122,24 @@ type Notifier interface {
|
|||||||
Notify(tasks []Task)
|
Notify(tasks []Task)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebhookEvictor releases the delivery engine's per-webhook
|
// Archives is how the handlers keep the database targets' archive
|
||||||
// state for a webhook that no longer needs it — currently the
|
// files in step with the configuration. Deleting a webhook or a
|
||||||
// cached archive writer of the database target, whose open
|
// target releases the cached archive writers, whose open file
|
||||||
// file handle would otherwise outlive the webhook.
|
// handles would otherwise outlive them; renaming one renames the
|
||||||
|
// archive files, which are named for the webhook and the target
|
||||||
|
// (see ArchiveFileName).
|
||||||
//
|
//
|
||||||
// It is deliberately separate from Notifier and deliberately
|
// It is deliberately separate from Notifier: archiving lifecycle
|
||||||
// one method wide: archiving lifecycle is not notification, and
|
// is not notification, and a small interface keeps the handlers
|
||||||
// a single-method interface keeps the handlers package free of
|
// package free of any dependency on the engine's internals while
|
||||||
// any dependency on the engine's internals while staying
|
// staying trivially fakeable in tests.
|
||||||
// trivially fakeable in tests.
|
|
||||||
//
|
//
|
||||||
// EvictWebhook never deletes an archive file. It is idempotent
|
// Neither eviction deletes an archive file. Both are idempotent
|
||||||
// and is a no-op for a webhook with no engine state.
|
// and are no-ops for a webhook or target with no engine state.
|
||||||
type WebhookEvictor interface {
|
type Archives interface {
|
||||||
EvictWebhook(webhookID string)
|
EvictWebhook(webhookID string)
|
||||||
|
EvictTarget(targetID string)
|
||||||
|
Rename(targetID, webhookName, targetName string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// EngineParams are the fx dependencies for the delivery
|
// EngineParams are the fx dependencies for the delivery
|
||||||
@@ -181,7 +184,7 @@ type Engine struct {
|
|||||||
httpTarget *httpTarget
|
httpTarget *httpTarget
|
||||||
|
|
||||||
// dbTarget is retained so the engine can reach the archive
|
// dbTarget is retained so the engine can reach the archive
|
||||||
// writer registry for webhook eviction and the idle sweep.
|
// writer registry for eviction, renames and the idle sweep.
|
||||||
dbTarget *databaseTarget
|
dbTarget *databaseTarget
|
||||||
|
|
||||||
// inflight is the set of deliveries this engine currently owns.
|
// inflight is the set of deliveries this engine currently owns.
|
||||||
@@ -249,17 +252,44 @@ func (e *Engine) Notify(tasks []Task) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EvictWebhook implements WebhookEvictor. It releases the
|
// EvictWebhook implements Archives. The cached archive writer of
|
||||||
// engine's per-webhook archiving state: the database target's
|
// every database target of the webhook is dropped from the
|
||||||
// cached archive writer is dropped from the registry and its
|
// registry and its file handle closed. The archive files
|
||||||
// file handle closed. The archive file itself is left on disk
|
// themselves are left on disk — they are long-term storage the
|
||||||
// — it is long-term storage the operator owns.
|
// operator owns.
|
||||||
func (e *Engine) EvictWebhook(webhookID string) {
|
func (e *Engine) EvictWebhook(webhookID string) {
|
||||||
if e.dbTarget == nil {
|
if e.dbTarget == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
e.dbTarget.evict(webhookID)
|
e.dbTarget.evictWebhook(webhookID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvictTarget implements Archives. It is EvictWebhook for a single
|
||||||
|
// database target, and leaves the archive file on disk the same
|
||||||
|
// way.
|
||||||
|
func (e *Engine) EvictTarget(targetID string) {
|
||||||
|
if e.dbTarget == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
e.dbTarget.evict(targetID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename implements Archives. It renames a database target's
|
||||||
|
// archive file to ArchiveFileName(webhookName, targetName,
|
||||||
|
// targetID), under the lock the target's archive writes and the
|
||||||
|
// idle sweep take. It never replaces a file: if one already has the
|
||||||
|
// new name, the error is ErrArchiveNameTaken. The caller renames
|
||||||
|
// before it saves the new name: see databaseTarget.rename.
|
||||||
|
func (e *Engine) Rename(
|
||||||
|
targetID, webhookName, targetName string,
|
||||||
|
) error {
|
||||||
|
if e.dbTarget == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.dbTarget.rename(targetID, webhookName, targetName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScheduleRetry schedules a task to be re-enqueued onto the
|
// ScheduleRetry schedules a task to be re-enqueued onto the
|
||||||
@@ -366,7 +396,8 @@ func (e *Engine) start() {
|
|||||||
// Once the pool has drained it closes the archive writers, so a
|
// Once the pool has drained it closes the archive writers, so a
|
||||||
// clean stop leaves no archive -wal behind. Nothing else holds a
|
// clean stop leaves no archive -wal behind. Nothing else holds a
|
||||||
// writer for long by then: the archive sweeper stops before the
|
// writer for long by then: the archive sweeper stops before the
|
||||||
// engine, and deleting a webhook only closes one. If the pool did
|
// engine, and deleting or renaming a webhook or target only closes
|
||||||
|
// or moves one. If the pool did
|
||||||
// not drain in time, the writers are left open, as a kill would
|
// not drain in time, the writers are left open, as a kill would
|
||||||
// leave them. Closing them would wait for any write in progress,
|
// leave them. Closing them would wait for any write in progress,
|
||||||
// and a worker still running would then open new writers that
|
// and a worker still running would then open new writers that
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package delivery_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -10,6 +9,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
)
|
)
|
||||||
@@ -272,22 +272,35 @@ func TestEngine_StopHookHonoursStopTimeout(t *testing.T) {
|
|||||||
requireStopHookExpires(t, lc.hooks[0], "delivery engine")
|
requireStopHookExpires(t, lc.hooks[0], "delivery engine")
|
||||||
}
|
}
|
||||||
|
|
||||||
// deliverToArchive runs one delivery to a database target through
|
// deliverToArchive gives the setup's webhook a database target,
|
||||||
// the running engine and returns the webhook's archive file path.
|
// runs one delivery to it through the running engine, and returns
|
||||||
// The archive writer holds the file open afterwards.
|
// the target's ID and archive file path. The archive writer holds
|
||||||
func deliverToArchive(t *testing.T, s iSetup) string {
|
// the file open afterwards.
|
||||||
|
func deliverToArchive(t *testing.T, s iSetup) (string, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
iCreateWebhook(t, s.MainDB, s.WebhookID, "hook")
|
||||||
|
|
||||||
|
tgt := &database.Target{
|
||||||
|
WebhookID: s.WebhookID,
|
||||||
|
Name: "archive",
|
||||||
|
Type: database.TargetTypeDatabase,
|
||||||
|
}
|
||||||
|
require.NoError(
|
||||||
|
t, s.MainDB.Omit(clause.Associations).Create(tgt).Error,
|
||||||
|
)
|
||||||
|
|
||||||
deliveryID, task := seedLogTask(t, s)
|
deliveryID, task := seedLogTask(t, s)
|
||||||
|
task.TargetID = tgt.ID
|
||||||
task.TargetType = database.TargetTypeDatabase
|
task.TargetType = database.TargetTypeDatabase
|
||||||
|
|
||||||
s.Engine.Notify([]delivery.Task{task})
|
s.Engine.Notify([]delivery.Task{task})
|
||||||
|
|
||||||
iWaitForDelivered(t, s.WebhookDB, deliveryID)
|
iWaitForDelivered(t, s.WebhookDB, deliveryID)
|
||||||
|
|
||||||
return filepath.Join(
|
return tgt.ID, filepath.Join(
|
||||||
filepath.Dir(s.DBMgr.DBPath(s.WebhookID)),
|
filepath.Dir(s.DBMgr.DBPath(s.WebhookID)),
|
||||||
fmt.Sprintf("archive-%s.db", s.WebhookID),
|
"archive-hook-archive-"+tgt.ID+".db",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +317,7 @@ func TestEngine_StopHookClosesArchives(t *testing.T) {
|
|||||||
|
|
||||||
lc := startEngineViaHook(t, s.Engine)
|
lc := startEngineViaHook(t, s.Engine)
|
||||||
|
|
||||||
path := deliverToArchive(t, s)
|
_, path := deliverToArchive(t, s)
|
||||||
require.FileExists(
|
require.FileExists(
|
||||||
t, path+"-wal",
|
t, path+"-wal",
|
||||||
"an open archive should have a -wal for the stop to remove",
|
"an open archive should have a -wal for the stop to remove",
|
||||||
@@ -338,7 +351,7 @@ func TestEngine_StopHookTimeoutLeavesArchivesOpen(t *testing.T) {
|
|||||||
|
|
||||||
lc := startEngineViaHook(t, s.Engine)
|
lc := startEngineViaHook(t, s.Engine)
|
||||||
|
|
||||||
deliverToArchive(t, s)
|
targetID, _ := deliverToArchive(t, s)
|
||||||
|
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
|
|
||||||
@@ -352,7 +365,7 @@ func TestEngine_StopHookTimeoutLeavesArchivesOpen(t *testing.T) {
|
|||||||
requireStopHookExpires(t, lc.hooks[0], "delivery engine")
|
requireStopHookExpires(t, lc.hooks[0], "delivery engine")
|
||||||
|
|
||||||
require.True(
|
require.True(
|
||||||
t, s.Engine.ExportArchiveHandleOpen(s.WebhookID),
|
t, s.Engine.ExportArchiveHandleOpen(targetID),
|
||||||
"a stop that timed out must not close archive writers",
|
"a stop that timed out must not close archive writers",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -351,23 +351,15 @@ func TestDeliverDatabase_ImmediateSuccess(
|
|||||||
|
|
||||||
db := testWebhookDB(t)
|
db := testWebhookDB(t)
|
||||||
|
|
||||||
// The database target archives for real now, so the engine
|
// The database target archives for real, so the engine needs
|
||||||
// needs a webhook DB manager to locate the data directory.
|
// the target in the main database and a data directory.
|
||||||
e := delivery.NewTestEngineWithDB(
|
env := setupArchiveTest(t)
|
||||||
nil,
|
tgt := env.seedDatabaseTarget(t, "")
|
||||||
database.NewTestWebhookDBManager(t.TempDir()),
|
|
||||||
slog.New(slog.NewTextHandler(
|
|
||||||
os.Stderr,
|
|
||||||
&slog.HandlerOptions{Level: slog.LevelDebug},
|
|
||||||
)),
|
|
||||||
&http.Client{Timeout: 5 * time.Second},
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
|
|
||||||
event := seedEvent(t, db, `{"db":"target"}`)
|
event := seedEvent(t, db, `{"db":"target"}`)
|
||||||
d := seedDatabaseTargetDelivery(t, db, event, "")
|
d := seedDatabaseTargetDelivery(t, db, event, tgt)
|
||||||
|
|
||||||
e.ExportDeliverDatabase(db, d)
|
env.eng.ExportDeliverDatabase(db, d)
|
||||||
|
|
||||||
var updated database.Delivery
|
var updated database.Delivery
|
||||||
|
|
||||||
@@ -1336,32 +1328,27 @@ func TestProcessDelivery_RoutesToCorrectHandler(
|
|||||||
|
|
||||||
db := testWebhookDB(t)
|
db := testWebhookDB(t)
|
||||||
|
|
||||||
// The database target archives for real now, so the engine
|
// The database target archives for real, so the engine needs
|
||||||
// needs a webhook DB manager to locate the data directory.
|
// the target in the main database and a data directory.
|
||||||
e := delivery.NewTestEngineWithDB(
|
env := setupArchiveTest(t)
|
||||||
nil,
|
archive := env.seedDatabaseTarget(t, "")
|
||||||
database.NewTestWebhookDBManager(t.TempDir()),
|
|
||||||
slog.New(slog.NewTextHandler(
|
|
||||||
os.Stderr,
|
|
||||||
&slog.HandlerOptions{Level: slog.LevelDebug},
|
|
||||||
)),
|
|
||||||
&http.Client{Timeout: 5 * time.Second},
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
targetType database.TargetType
|
targetType database.TargetType
|
||||||
|
targetID string
|
||||||
wantStatus database.DeliveryStatus
|
wantStatus database.DeliveryStatus
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"database target",
|
"database target",
|
||||||
database.TargetTypeDatabase,
|
database.TargetTypeDatabase,
|
||||||
|
archive.ID,
|
||||||
database.DeliveryStatusDelivered,
|
database.DeliveryStatusDelivered,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"log target",
|
"log target",
|
||||||
database.TargetTypeLog,
|
database.TargetTypeLog,
|
||||||
|
uuid.New().String(),
|
||||||
database.DeliveryStatusDelivered,
|
database.DeliveryStatusDelivered,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1371,7 +1358,7 @@ func TestProcessDelivery_RoutesToCorrectHandler(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
runRoutingSubtest(
|
runRoutingSubtest(
|
||||||
t, db, e, tt.targetType,
|
t, db, env.eng, tt.targetType, tt.targetID,
|
||||||
tt.wantStatus,
|
tt.wantStatus,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -1383,6 +1370,7 @@ func runRoutingSubtest(
|
|||||||
db *gorm.DB,
|
db *gorm.DB,
|
||||||
e *delivery.Engine,
|
e *delivery.Engine,
|
||||||
targetType database.TargetType,
|
targetType database.TargetType,
|
||||||
|
targetID string,
|
||||||
wantStatus database.DeliveryStatus,
|
wantStatus database.DeliveryStatus,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -1390,8 +1378,7 @@ func runRoutingSubtest(
|
|||||||
event := seedEvent(t, db, `{"routing":"test"}`)
|
event := seedEvent(t, db, `{"routing":"test"}`)
|
||||||
|
|
||||||
dlv := seedDelivery(
|
dlv := seedDelivery(
|
||||||
t, db, event.ID,
|
t, db, event.ID, targetID,
|
||||||
uuid.New().String(),
|
|
||||||
database.DeliveryStatusPending,
|
database.DeliveryStatusPending,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -40,11 +40,6 @@ const (
|
|||||||
ExportPendingSweepMinAge = pendingSweepMinAge
|
ExportPendingSweepMinAge = pendingSweepMinAge
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExportIsBlockedIP exposes isBlockedIP for testing.
|
|
||||||
func ExportIsBlockedIP(ip net.IP) bool {
|
|
||||||
return isBlockedIP(ip)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTestGuard builds an SSRF Guard from an explicit egress
|
// NewTestGuard builds an SSRF Guard from an explicit egress
|
||||||
// allowlist, without going through config. Passing no prefixes
|
// allowlist, without going through config. Passing no prefixes
|
||||||
// yields the default guard, which blocks every private/reserved
|
// yields the default guard, which blocks every private/reserved
|
||||||
@@ -70,6 +65,11 @@ func ExportBlockedNetworks() []*net.IPNet {
|
|||||||
return blockedNetworks
|
return blockedNetworks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExportBlockedPublicNetworks exposes blockedPublicNetworks.
|
||||||
|
func ExportBlockedPublicNetworks() []*net.IPNet {
|
||||||
|
return blockedPublicNetworks
|
||||||
|
}
|
||||||
|
|
||||||
// ExportIsForwardableHeader exposes isForwardableHeader.
|
// ExportIsForwardableHeader exposes isForwardableHeader.
|
||||||
func ExportIsForwardableHeader(name string) bool {
|
func ExportIsForwardableHeader(name string) bool {
|
||||||
return isForwardableHeader(name)
|
return isForwardableHeader(name)
|
||||||
@@ -473,7 +473,7 @@ func NewTestCircuitBreaker(
|
|||||||
type ExportArchivedEvent = archivedEvent
|
type ExportArchivedEvent = archivedEvent
|
||||||
|
|
||||||
// ExportArchiveWriter wraps an archiveWriter so black-box tests
|
// ExportArchiveWriter wraps an archiveWriter so black-box tests
|
||||||
// can exercise the per-webhook archive file mechanics.
|
// can exercise the archive file mechanics.
|
||||||
type ExportArchiveWriter struct {
|
type ExportArchiveWriter struct {
|
||||||
w *archiveWriter
|
w *archiveWriter
|
||||||
}
|
}
|
||||||
@@ -548,6 +548,12 @@ func (e *ExportArchiveWriter) Evict() {
|
|||||||
e.w.evict()
|
e.w.evict()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rename gives the archive file a new name in the same directory,
|
||||||
|
// as a rename of the webhook or target does.
|
||||||
|
func (e *ExportArchiveWriter) Rename(name string) error {
|
||||||
|
return e.w.rename(name)
|
||||||
|
}
|
||||||
|
|
||||||
// HandleOpen reports whether the writer currently holds an open
|
// HandleOpen reports whether the writer currently holds an open
|
||||||
// archive handle.
|
// archive handle.
|
||||||
func (e *ExportArchiveWriter) HandleOpen() bool {
|
func (e *ExportArchiveWriter) HandleOpen() bool {
|
||||||
@@ -567,16 +573,16 @@ func (e *ExportArchiveWriter) Same(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ExportArchiveWriterFor returns the archive writer the registry
|
// ExportArchiveWriterFor returns the archive writer the registry
|
||||||
// currently caches for a webhook, or nil when none is cached. It
|
// currently caches for a database target, or nil when none is
|
||||||
// never creates one, so a test can hold a reference to the very
|
// cached. It never creates one, so a test can hold a reference to
|
||||||
// writer an eviction is about to detach.
|
// the very writer an eviction is about to detach.
|
||||||
func (e *Engine) ExportArchiveWriterFor(
|
func (e *Engine) ExportArchiveWriterFor(
|
||||||
webhookID string,
|
targetID string,
|
||||||
) *ExportArchiveWriter {
|
) *ExportArchiveWriter {
|
||||||
e.dbTarget.mu.Lock()
|
e.dbTarget.mu.Lock()
|
||||||
defer e.dbTarget.mu.Unlock()
|
defer e.dbTarget.mu.Unlock()
|
||||||
|
|
||||||
w, ok := e.dbTarget.writers[webhookID]
|
w, ok := e.dbTarget.writers[targetID]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -585,26 +591,26 @@ func (e *Engine) ExportArchiveWriterFor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ExportHasArchiveWriter reports whether the database target
|
// ExportHasArchiveWriter reports whether the database target
|
||||||
// currently caches an archive writer for a webhook.
|
// type currently caches an archive writer for a target.
|
||||||
func (e *Engine) ExportHasArchiveWriter(
|
func (e *Engine) ExportHasArchiveWriter(
|
||||||
webhookID string,
|
targetID string,
|
||||||
) bool {
|
) bool {
|
||||||
e.dbTarget.mu.Lock()
|
e.dbTarget.mu.Lock()
|
||||||
defer e.dbTarget.mu.Unlock()
|
defer e.dbTarget.mu.Unlock()
|
||||||
|
|
||||||
_, ok := e.dbTarget.writers[webhookID]
|
_, ok := e.dbTarget.writers[targetID]
|
||||||
|
|
||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportArchiveHandleOpen reports whether the cached archive
|
// ExportArchiveHandleOpen reports whether the cached archive
|
||||||
// writer for a webhook holds an open database handle. It
|
// writer for a target holds an open database handle. It
|
||||||
// returns false when no writer is cached.
|
// returns false when no writer is cached.
|
||||||
func (e *Engine) ExportArchiveHandleOpen(
|
func (e *Engine) ExportArchiveHandleOpen(
|
||||||
webhookID string,
|
targetID string,
|
||||||
) bool {
|
) bool {
|
||||||
e.dbTarget.mu.Lock()
|
e.dbTarget.mu.Lock()
|
||||||
w, ok := e.dbTarget.writers[webhookID]
|
w, ok := e.dbTarget.writers[targetID]
|
||||||
e.dbTarget.mu.Unlock()
|
e.dbTarget.mu.Unlock()
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -618,12 +624,12 @@ func (e *Engine) ExportArchiveHandleOpen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ExportEnsureArchiveWriter creates (if needed) and returns the
|
// ExportEnsureArchiveWriter creates (if needed) and returns the
|
||||||
// archive file path of the cached writer for a webhook, so a
|
// archive file path of the cached writer for a target, so a
|
||||||
// test can prime the registry the way a delivery would.
|
// test can prime the registry the way a delivery would.
|
||||||
func (e *Engine) ExportEnsureArchiveWriter(
|
func (e *Engine) ExportEnsureArchiveWriter(
|
||||||
webhookID string,
|
targetID string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
w, err := e.dbTarget.writerFor(webhookID)
|
w, err := e.dbTarget.writerFor(targetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -631,14 +637,14 @@ func (e *Engine) ExportEnsureArchiveWriter(
|
|||||||
return w.path, nil
|
return w.path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportSweepWriterFor takes a webhook's registry writer exactly
|
// ExportSweepWriterFor takes a target's registry writer exactly
|
||||||
// as the idle sweep does, reporting whether the sweep had to
|
// as the idle sweep does, reporting whether the sweep had to
|
||||||
// create the entry. It lets a test drive the registry through the
|
// create the entry. It lets a test drive the registry through the
|
||||||
// sweep's own entry point instead of choreographing goroutines.
|
// sweep's own entry point instead of choreographing goroutines.
|
||||||
func (e *Engine) ExportSweepWriterFor(
|
func (e *Engine) ExportSweepWriterFor(
|
||||||
webhookID string,
|
targetID string,
|
||||||
) (*ExportArchiveWriter, bool, error) {
|
) (*ExportArchiveWriter, bool, error) {
|
||||||
w, created, err := e.dbTarget.sweepWriterFor(webhookID)
|
w, created, err := e.dbTarget.sweepWriterFor(targetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
@@ -649,9 +655,9 @@ func (e *Engine) ExportSweepWriterFor(
|
|||||||
// ExportReleaseSweepWriter releases a sweep-created registry entry
|
// ExportReleaseSweepWriter releases a sweep-created registry entry
|
||||||
// exactly as a finished sweep does.
|
// exactly as a finished sweep does.
|
||||||
func (e *Engine) ExportReleaseSweepWriter(
|
func (e *Engine) ExportReleaseSweepWriter(
|
||||||
webhookID string, w *ExportArchiveWriter,
|
targetID string, w *ExportArchiveWriter,
|
||||||
) {
|
) {
|
||||||
e.dbTarget.releaseSweepWriter(webhookID, w.w)
|
e.dbTarget.releaseSweepWriter(targetID, w.w)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTestArchiveSweeper builds an ArchiveSweeper backed by the
|
// NewTestArchiveSweeper builds an ArchiveSweeper backed by the
|
||||||
|
|||||||
+46
-25
@@ -25,8 +25,16 @@ var (
|
|||||||
errNoIPs = errors.New(
|
errNoIPs = errors.New(
|
||||||
"hostname resolved to no IP addresses",
|
"hostname resolved to no IP addresses",
|
||||||
)
|
)
|
||||||
errBlockedIP = errors.New(
|
// ErrBlockedPrivateOrReservedIP reports an address in the
|
||||||
"blocked private, reserved or cloud metadata address",
|
// default blocklist's private and reserved ranges,
|
||||||
|
// blockedNetworks.
|
||||||
|
ErrBlockedPrivateOrReservedIP = errors.New(
|
||||||
|
"blocked private or reserved address",
|
||||||
|
)
|
||||||
|
// errBlockedPublicMetadata reports a public address on the
|
||||||
|
// default blocklist, one in blockedPublicNetworks.
|
||||||
|
errBlockedPublicMetadata = errors.New(
|
||||||
|
"blocked cloud metadata address",
|
||||||
)
|
)
|
||||||
errBlockedMetadata = errors.New(
|
errBlockedMetadata = errors.New(
|
||||||
"blocked link-local or cloud instance metadata " +
|
"blocked link-local or cloud instance metadata " +
|
||||||
@@ -37,22 +45,32 @@ var (
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// blockedNetworks is the default blocklist: the private and
|
// blockedNetworks and blockedPublicNetworks together are the
|
||||||
// reserved IP ranges, plus the public cloud metadata addresses,
|
// default blocklist: the private and reserved IP ranges, plus
|
||||||
// that are blocked to prevent SSRF attacks. An operator can
|
// the public cloud metadata addresses, that are blocked to
|
||||||
// permit specific blocks out of this set with
|
// prevent SSRF attacks. An operator can permit specific blocks
|
||||||
// ALLOWED_EGRESS_CIDRS; see Guard.
|
// out of this set with ALLOWED_EGRESS_CIDRS; see Guard.
|
||||||
//
|
//
|
||||||
// A public address belongs on the default blocklist only if it
|
// blockedNetworks holds the private and reserved IP ranges.
|
||||||
// hands credentials, user data or bootstrap material to whatever
|
|
||||||
// can reach it, without the caller presenting anything. A
|
|
||||||
// provider's other public addresses are not refused, since
|
|
||||||
// reaching them can be legitimate and no list of them could be
|
|
||||||
// complete.
|
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // package-level network list is appropriate here
|
//nolint:gochecknoglobals // package-level network list is appropriate here
|
||||||
var blockedNetworks []*net.IPNet
|
var blockedNetworks []*net.IPNet
|
||||||
|
|
||||||
|
// blockedPublicNetworks holds the default blocklist's public
|
||||||
|
// addresses, kept apart from blockedNetworks so that they are
|
||||||
|
// refused as cloud metadata addresses, never as private or
|
||||||
|
// reserved ones.
|
||||||
|
//
|
||||||
|
// A public address belongs on the default blocklist only if it
|
||||||
|
// hands credentials, user data or bootstrap material to whatever
|
||||||
|
// can reach it, without the caller presenting anything; it goes
|
||||||
|
// in this list. A provider's other public addresses are not
|
||||||
|
// refused, since reaching them can be legitimate and no list of
|
||||||
|
// them could be complete.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // package-level network list is appropriate here
|
||||||
|
var blockedPublicNetworks []*net.IPNet
|
||||||
|
|
||||||
// alwaysBlockedNetworks are the ranges no configuration can
|
// alwaysBlockedNetworks are the ranges no configuration can
|
||||||
// open: the link-local blocks and the cloud instance metadata
|
// open: the link-local blocks and the cloud instance metadata
|
||||||
// endpoints that live outside them. Reaching one is credential
|
// endpoints that live outside them. Reaching one is credential
|
||||||
@@ -88,8 +106,8 @@ var blockedNetworks []*net.IPNet
|
|||||||
// when it clears both halves. Nothing in this list can be
|
// when it clears both halves. Nothing in this list can be
|
||||||
// reopened, so putting a public address here leaves the operator
|
// reopened, so putting a public address here leaves the operator
|
||||||
// no escape hatch at all — the condition ALLOWED_EGRESS_CIDRS
|
// no escape hatch at all — the condition ALLOWED_EGRESS_CIDRS
|
||||||
// exists to remove. Default-block it in blockedNetworks instead,
|
// exists to remove. Default-block it in blockedPublicNetworks
|
||||||
// which an allowlist can override.
|
// instead, which an allowlist can override.
|
||||||
//
|
//
|
||||||
// This is a criterion, not an enumeration of every metadata
|
// This is a criterion, not an enumeration of every metadata
|
||||||
// address in existence.
|
// address in existence.
|
||||||
@@ -130,6 +148,9 @@ func init() {
|
|||||||
"::1/128",
|
"::1/128",
|
||||||
"fc00::/7",
|
"fc00::/7",
|
||||||
"fe80::/10",
|
"fe80::/10",
|
||||||
|
})
|
||||||
|
|
||||||
|
blockedPublicNetworks = mustParseCIDRs([]string{
|
||||||
// Azure WireServer, a public address that serves VM credentials.
|
// Azure WireServer, a public address that serves VM credentials.
|
||||||
"168.63.129.16/32",
|
"168.63.129.16/32",
|
||||||
})
|
})
|
||||||
@@ -225,13 +246,6 @@ func matchesAny(networks []*net.IPNet, ip net.IP) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// isBlockedIP checks whether an IP address falls within
|
|
||||||
// the default blocklist, before any operator allowlist is
|
|
||||||
// considered.
|
|
||||||
func isBlockedIP(ip net.IP) bool {
|
|
||||||
return matchesAny(blockedNetworks, ip)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guard makes every SSRF decision in the process.
|
// Guard makes every SSRF decision in the process.
|
||||||
//
|
//
|
||||||
// It holds the operator's ALLOWED_EGRESS_CIDRS allowlist and
|
// It holds the operator's ALLOWED_EGRESS_CIDRS allowlist and
|
||||||
@@ -332,7 +346,8 @@ func (g *Guard) allows(ip net.IP) bool {
|
|||||||
// consulted, so no configured CIDR reaches link-local or a
|
// consulted, so no configured CIDR reaches link-local or a
|
||||||
// cloud metadata endpoint at a non-public address.
|
// cloud metadata endpoint at a non-public address.
|
||||||
// 2. The allowlist is consulted next, so a listed private
|
// 2. The allowlist is consulted next, so a listed private
|
||||||
// network becomes reachable.
|
// network, or a listed public address on the default
|
||||||
|
// blocklist, becomes reachable.
|
||||||
// 3. Everything else keeps the default blocklist's answer.
|
// 3. Everything else keeps the default blocklist's answer.
|
||||||
func (g *Guard) checkIP(ip net.IP) error {
|
func (g *Guard) checkIP(ip net.IP) error {
|
||||||
if matchesAny(alwaysBlockedNetworks, ip) {
|
if matchesAny(alwaysBlockedNetworks, ip) {
|
||||||
@@ -345,9 +360,15 @@ func (g *Guard) checkIP(ip net.IP) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if isBlockedIP(ip) {
|
if matchesAny(blockedNetworks, ip) {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"target IP %s: %w", ip, errBlockedIP,
|
"target IP %s: %w", ip, ErrBlockedPrivateOrReservedIP,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if matchesAny(blockedPublicNetworks, ip) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"target IP %s: %w", ip, errBlockedPublicMetadata,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ const (
|
|||||||
metadataIP = "169.254.169.254"
|
metadataIP = "169.254.169.254"
|
||||||
metadataURL = "http://" + metadataIP + "/latest/meta-data/"
|
metadataURL = "http://" + metadataIP + "/latest/meta-data/"
|
||||||
|
|
||||||
|
// linkLocalIPv4 is the IPv4 link-local block, which holds
|
||||||
|
// metadataIP.
|
||||||
|
linkLocalIPv4 = "169.254.0.0/16"
|
||||||
|
|
||||||
// loopbackHookURL is a target on this host: blocked by
|
// loopbackHookURL is a target on this host: blocked by
|
||||||
// default, reachable only once an operator allowlists
|
// default, reachable only once an operator allowlists
|
||||||
// loopback.
|
// loopback.
|
||||||
@@ -237,7 +241,7 @@ func linkLocalRefusedCases() []metadataAlwaysRefusedCase {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "whole link-local block",
|
name: "whole link-local block",
|
||||||
allow: "169.254.0.0/16",
|
allow: linkLocalIPv4,
|
||||||
target: metadataURL,
|
target: metadataURL,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -412,6 +416,9 @@ func TestGuardAllowlist_AzureWireServerReopenable(t *testing.T) {
|
|||||||
"WireServer must be refused by the default blocklist, "+
|
"WireServer must be refused by the default blocklist, "+
|
||||||
"which an allowlist can override",
|
"which an allowlist can override",
|
||||||
)
|
)
|
||||||
|
require.NotErrorIs(t, err, delivery.ErrBlockedPrivateOrReservedIP,
|
||||||
|
"WireServer is public, not private or reserved",
|
||||||
|
)
|
||||||
|
|
||||||
assertDialRefused(t, defaultGuard, target)
|
assertDialRefused(t, defaultGuard, target)
|
||||||
|
|
||||||
@@ -496,7 +503,7 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
|
|||||||
want := []string{
|
want := []string{
|
||||||
// IPv4 link-local: the 169.254.169.254 metadata
|
// IPv4 link-local: the 169.254.169.254 metadata
|
||||||
// service on AWS, Azure and others.
|
// service on AWS, Azure and others.
|
||||||
"169.254.0.0/16",
|
linkLocalIPv4,
|
||||||
// IPv6 link-local.
|
// IPv6 link-local.
|
||||||
"fe80::/10",
|
"fe80::/10",
|
||||||
// AWS IPv6 IMDS, inside the ULA space an operator may
|
// AWS IPv6 IMDS, inside the ULA space an operator may
|
||||||
@@ -526,6 +533,90 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
|
|||||||
assert.Equal(t, want, got)
|
assert.Equal(t, want, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDefaultBlocklist_PinnedSet pins each list of the default
|
||||||
|
// blocklist on its own, the private and reserved ranges in
|
||||||
|
// blockedNetworks and the public addresses in
|
||||||
|
// blockedPublicNetworks, so moving an entry from one list to the
|
||||||
|
// other fails it. For the first address of each entry it then
|
||||||
|
// checks that the default guard refuses it, and that listing the
|
||||||
|
// entry in ALLOWED_EGRESS_CIDRS opens it unless the unconditional
|
||||||
|
// set holds that address.
|
||||||
|
func TestDefaultBlocklist_PinnedSet(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// public marks an entry of blockedPublicNetworks; every other
|
||||||
|
// entry belongs in blockedNetworks.
|
||||||
|
tests := []struct {
|
||||||
|
cidr string
|
||||||
|
public bool
|
||||||
|
reopenable bool
|
||||||
|
}{
|
||||||
|
{cidr: "127.0.0.0/8", reopenable: true},
|
||||||
|
{cidr: "10.0.0.0/8", reopenable: true},
|
||||||
|
{cidr: "172.16.0.0/12", reopenable: true},
|
||||||
|
{cidr: "192.168.0.0/16", reopenable: true},
|
||||||
|
{cidr: linkLocalIPv4, reopenable: false},
|
||||||
|
{cidr: "0.0.0.0/8", reopenable: true},
|
||||||
|
{cidr: "100.64.0.0/10", reopenable: true},
|
||||||
|
{cidr: "192.0.0.0/24", reopenable: true},
|
||||||
|
{cidr: "192.0.2.0/24", reopenable: true},
|
||||||
|
{cidr: "198.18.0.0/15", reopenable: true},
|
||||||
|
{cidr: "198.51.100.0/24", reopenable: true},
|
||||||
|
{cidr: "203.0.113.0/24", reopenable: true},
|
||||||
|
{cidr: "224.0.0.0/4", reopenable: true},
|
||||||
|
{cidr: "240.0.0.0/4", reopenable: true},
|
||||||
|
{cidr: "::1/128", reopenable: true},
|
||||||
|
{cidr: "fc00::/7", reopenable: true},
|
||||||
|
{cidr: "fe80::/10", reopenable: false},
|
||||||
|
{cidr: "168.63.129.16/32", public: true, reopenable: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
wantPrivate := make([]string, 0, len(tests))
|
||||||
|
wantPublic := make([]string, 0, len(tests))
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if tt.public {
|
||||||
|
wantPublic = append(wantPublic, tt.cidr)
|
||||||
|
} else {
|
||||||
|
wantPrivate = append(wantPrivate, tt.cidr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gotPrivate := make([]string, 0, len(tests))
|
||||||
|
for _, n := range delivery.ExportBlockedNetworks() {
|
||||||
|
gotPrivate = append(gotPrivate, n.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
gotPublic := make([]string, 0, len(tests))
|
||||||
|
for _, n := range delivery.ExportBlockedPublicNetworks() {
|
||||||
|
gotPublic = append(gotPublic, n.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ElementsMatch(t, wantPrivate, gotPrivate, "blockedNetworks")
|
||||||
|
assert.ElementsMatch(t, wantPublic, gotPublic, "blockedPublicNetworks")
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.cidr, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
prefix := netip.MustParsePrefix(tt.cidr)
|
||||||
|
ip := net.IP(prefix.Addr().AsSlice())
|
||||||
|
|
||||||
|
require.Error(t,
|
||||||
|
delivery.NewTestGuard().ExportCheckIP(ip),
|
||||||
|
"the default guard must refuse %s", ip,
|
||||||
|
)
|
||||||
|
|
||||||
|
err := delivery.NewTestGuard(prefix).ExportCheckIP(ip)
|
||||||
|
if tt.reopenable {
|
||||||
|
assert.NoError(t, err, "listing %s must open it", tt.cidr)
|
||||||
|
} else {
|
||||||
|
assert.Error(t, err, "listing %s must not open it", tt.cidr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// requireLoopback fails the test unless rawURL's host is a
|
// requireLoopback fails the test unless rawURL's host is a
|
||||||
// loopback address, so the allowlist test cannot silently stop
|
// loopback address, so the allowlist test cannot silently stop
|
||||||
// exercising a blocked range.
|
// exercising a blocked range.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestIsBlockedIP_PrivateRanges(t *testing.T) {
|
func TestGuardCheckIP_PrivateRanges(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -56,12 +56,14 @@ func TestIsBlockedIP_PrivateRanges(t *testing.T) {
|
|||||||
"failed to parse IP %s", tt.ip,
|
"failed to parse IP %s", tt.ip,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
refused := delivery.NewTestGuard().ExportCheckIP(ip) != nil
|
||||||
|
|
||||||
assert.Equal(t,
|
assert.Equal(t,
|
||||||
tt.blocked,
|
tt.blocked,
|
||||||
delivery.ExportIsBlockedIP(ip),
|
refused,
|
||||||
"isBlockedIP(%s) = %v, want %v",
|
"default guard refuses %s = %v, want %v",
|
||||||
tt.ip,
|
tt.ip,
|
||||||
delivery.ExportIsBlockedIP(ip),
|
refused,
|
||||||
tt.blocked,
|
tt.blocked,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -11,22 +12,75 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// databaseTarget is a no-retry target that archives the
|
// archiveNameMaxLen is how many characters of a webhook or target
|
||||||
// full inbound event into a per-webhook archive SQLite file,
|
// name an archive file name keeps.
|
||||||
// separate from the per-webhook event database. The event is
|
const archiveNameMaxLen = 40
|
||||||
// already persisted in the per-webhook event DB by the time
|
|
||||||
// delivery runs; the database target additionally writes a
|
// databaseTarget is a no-retry target that archives the full
|
||||||
// durable long-term copy into archive-{webhookID}.db and then
|
// inbound event into the target's own archive SQLite file, separate
|
||||||
// records a single attempt whose outcome reflects whether the
|
// from the per-webhook event database. The event is already
|
||||||
// archive write succeeded. See archiveWriter for the
|
// persisted in the per-webhook event DB by the time delivery runs;
|
||||||
// close/reopen, auto-recreate, and expiry semantics.
|
// the database target additionally writes a durable long-term copy
|
||||||
|
// into the file ArchiveFileName names and then records a single
|
||||||
|
// attempt whose outcome reflects whether the archive write
|
||||||
|
// succeeded. See archiveWriter for the close/reopen, auto-recreate,
|
||||||
|
// and expiry semantics.
|
||||||
type databaseTarget struct {
|
type databaseTarget struct {
|
||||||
eng *Engine
|
eng *Engine
|
||||||
|
|
||||||
|
// writers holds one archive writer per database target, keyed
|
||||||
|
// by target ID.
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
writers map[string]*archiveWriter
|
writers map[string]*archiveWriter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ArchiveFileName returns the file name of a database target's
|
||||||
|
// archive: archive-WEBHOOKNAME-TARGETNAME-TARGETID.db, with both
|
||||||
|
// names passed through archiveNamePart. The target ID keeps the
|
||||||
|
// name unique when two targets' names come out the same.
|
||||||
|
func ArchiveFileName(webhookName, targetName, targetID string) string {
|
||||||
|
return "archive-" + archiveNamePart(webhookName) + "-" +
|
||||||
|
archiveNamePart(targetName) + "-" + targetID + ".db"
|
||||||
|
}
|
||||||
|
|
||||||
|
// archiveNamePart makes a webhook or target name safe to put in a
|
||||||
|
// file name. It is lowercased; ASCII letters and digits are kept,
|
||||||
|
// every other run of characters becomes a single "-", and no "-" is
|
||||||
|
// left at either end. It is cut to archiveNameMaxLen characters, and
|
||||||
|
// a name with nothing left is "unnamed".
|
||||||
|
func archiveNamePart(name string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
dash := false
|
||||||
|
|
||||||
|
for _, r := range strings.ToLower(name) {
|
||||||
|
if (r < 'a' || r > 'z') && (r < '0' || r > '9') {
|
||||||
|
dash = b.Len() > 0
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if dash {
|
||||||
|
b.WriteByte('-')
|
||||||
|
|
||||||
|
dash = false
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
part := b.String()
|
||||||
|
if len(part) > archiveNameMaxLen {
|
||||||
|
part = strings.TrimRight(part[:archiveNameMaxLen], "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
if part == "" {
|
||||||
|
return "unnamed"
|
||||||
|
}
|
||||||
|
|
||||||
|
return part
|
||||||
|
}
|
||||||
|
|
||||||
// Deliver implements Target. It archives the event, then
|
// Deliver implements Target. It archives the event, then
|
||||||
// records one successful attempt and marks the delivery
|
// records one successful attempt and marks the delivery
|
||||||
// delivered. An archiving error fails the delivery: the
|
// delivered. An archiving error fails the delivery: the
|
||||||
@@ -92,7 +146,7 @@ func (t *databaseTarget) Deliver(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// archive writes the full event as a row into the webhook's
|
// archive writes the full event as a row into the target's
|
||||||
// archive database, honouring the optional per-target expiry
|
// archive database, honouring the optional per-target expiry
|
||||||
// parsed from the target config JSON.
|
// parsed from the target config JSON.
|
||||||
func (t *databaseTarget) archive(d *database.Delivery) error {
|
func (t *databaseTarget) archive(d *database.Delivery) error {
|
||||||
@@ -106,7 +160,7 @@ func (t *databaseTarget) archive(d *database.Delivery) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
w, err := t.writerFor(webhookID)
|
w, err := t.writerFor(d.TargetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -124,30 +178,31 @@ func (t *databaseTarget) archive(d *database.Delivery) error {
|
|||||||
return w.write(row, expiry)
|
return w.write(row, expiry)
|
||||||
}
|
}
|
||||||
|
|
||||||
// writerFor returns the archiveWriter for a webhook, creating
|
// writerFor returns the archive writer for a database target,
|
||||||
// and caching it on first use. Each webhook has one writer so
|
// creating and caching it on first use. Each target has one writer
|
||||||
// its close/reopen debounce state is shared across concurrent
|
// so its close/reopen debounce state is shared across concurrent
|
||||||
// deliveries. The archive file lives beside the per-webhook
|
// deliveries, and so a rename and the idle sweep take the same lock
|
||||||
// event database in the data directory.
|
// as its writes.
|
||||||
func (t *databaseTarget) writerFor(
|
func (t *databaseTarget) writerFor(
|
||||||
webhookID string,
|
targetID string,
|
||||||
) (*archiveWriter, error) {
|
) (*archiveWriter, error) {
|
||||||
path, err := t.archivePath(webhookID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
defer t.mu.Unlock()
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
if t.writers == nil {
|
w, ok := t.writers[targetID]
|
||||||
t.writers = make(map[string]*archiveWriter)
|
|
||||||
}
|
|
||||||
|
|
||||||
w, ok := t.writers[webhookID]
|
|
||||||
if !ok {
|
if !ok {
|
||||||
w = newArchiveWriter(path, t.eng.log)
|
var err error
|
||||||
t.writers[webhookID] = w
|
|
||||||
|
w, err = t.newWriter(targetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.writers == nil {
|
||||||
|
t.writers = make(map[string]*archiveWriter)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.writers[targetID] = w
|
||||||
}
|
}
|
||||||
|
|
||||||
// A delivery claims the entry: even if the idle sweep created
|
// A delivery claims the entry: even if the idle sweep created
|
||||||
@@ -159,40 +214,39 @@ func (t *databaseTarget) writerFor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sweepWriterFor returns the archive writer the idle sweep should
|
// sweepWriterFor returns the archive writer the idle sweep should
|
||||||
// prune a webhook through, together with whether the sweep itself
|
// prune a target's archive through, together with whether the sweep
|
||||||
// created the registry entry.
|
// itself created the registry entry.
|
||||||
//
|
//
|
||||||
// The sweep must route its prune through the registered writer so
|
// The sweep must route its prune through the registered writer so
|
||||||
// the writer's mutex orders it against concurrent writes, but it
|
// the writer's mutex orders it against concurrent writes, but it
|
||||||
// must never leave a registry entry behind: a sweep that ran
|
// must never leave a registry entry behind: a sweep that ran
|
||||||
// concurrently with the webhook's deletion would otherwise
|
// concurrently with the target's deletion would otherwise
|
||||||
// re-create an entry that nothing will ever evict again, which is
|
// re-create an entry that nothing will ever evict again, which is
|
||||||
// exactly the leak eviction exists to prevent. An entry the sweep
|
// exactly the leak eviction exists to prevent. An entry the sweep
|
||||||
// creates is therefore marked sweep-owned and handed back to
|
// creates is therefore marked sweep-owned and handed back to
|
||||||
// releaseSweepWriter when the sweep is done.
|
// releaseSweepWriter when the sweep is done.
|
||||||
func (t *databaseTarget) sweepWriterFor(
|
func (t *databaseTarget) sweepWriterFor(
|
||||||
webhookID string,
|
targetID string,
|
||||||
) (*archiveWriter, bool, error) {
|
) (*archiveWriter, bool, error) {
|
||||||
path, err := t.archivePath(webhookID)
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
w, ok := t.writers[targetID]
|
||||||
|
if ok {
|
||||||
|
return w, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
w, err := t.newWriter(targetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
t.mu.Lock()
|
|
||||||
defer t.mu.Unlock()
|
|
||||||
|
|
||||||
if t.writers == nil {
|
if t.writers == nil {
|
||||||
t.writers = make(map[string]*archiveWriter)
|
t.writers = make(map[string]*archiveWriter)
|
||||||
}
|
}
|
||||||
|
|
||||||
w, ok := t.writers[webhookID]
|
|
||||||
if ok {
|
|
||||||
return w, false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
w = newArchiveWriter(path, t.eng.log)
|
|
||||||
w.sweepOwned = true
|
w.sweepOwned = true
|
||||||
t.writers[webhookID] = w
|
t.writers[targetID] = w
|
||||||
|
|
||||||
return w, true, nil
|
return w, true, nil
|
||||||
}
|
}
|
||||||
@@ -209,57 +263,95 @@ func (t *databaseTarget) sweepWriterFor(
|
|||||||
// delivery that adopted the writer keeps a registered, evictable
|
// delivery that adopted the writer keeps a registered, evictable
|
||||||
// one.
|
// one.
|
||||||
func (t *databaseTarget) releaseSweepWriter(
|
func (t *databaseTarget) releaseSweepWriter(
|
||||||
webhookID string, w *archiveWriter,
|
targetID string, w *archiveWriter,
|
||||||
) {
|
) {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
defer t.mu.Unlock()
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
cur, ok := t.writers[webhookID]
|
cur, ok := t.writers[targetID]
|
||||||
if !ok || cur != w || !cur.sweepOwned {
|
if !ok || cur != w || !cur.sweepOwned {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(t.writers, webhookID)
|
delete(t.writers, targetID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// archivePath returns the archive file path for a webhook: it
|
// newWriter builds the writer for a database target's archive. The
|
||||||
// lives beside the per-webhook event database in the data
|
// file lives beside the webhook's event database in the data
|
||||||
// directory. It does not touch the filesystem.
|
// directory and is named for the webhook and the target as the main
|
||||||
func (t *databaseTarget) archivePath(
|
// database has them now; from then on only rename changes the name
|
||||||
webhookID string,
|
// the writer uses. It does not touch the archive file.
|
||||||
) (string, error) {
|
func (t *databaseTarget) newWriter(
|
||||||
|
targetID string,
|
||||||
|
) (*archiveWriter, error) {
|
||||||
if t.eng.dbManager == nil {
|
if t.eng.dbManager == nil {
|
||||||
return "", errArchiveNoDataDir
|
return nil, errArchiveNoDataDir
|
||||||
}
|
}
|
||||||
|
|
||||||
dir := filepath.Dir(t.eng.dbManager.DBPath(webhookID))
|
var target database.Target
|
||||||
|
|
||||||
return filepath.Join(
|
err := t.eng.database.DB().
|
||||||
dir, fmt.Sprintf("archive-%s.db", webhookID),
|
Preload("Webhook").
|
||||||
), nil
|
First(&target, "id = ?", targetID).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"loading database target %s: %w", targetID, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Dir(t.eng.dbManager.DBPath(target.WebhookID))
|
||||||
|
name := ArchiveFileName(
|
||||||
|
target.Webhook.Name, target.Name, target.ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
w := newArchiveWriter(filepath.Join(dir, name), t.eng.log)
|
||||||
|
w.webhookID = target.WebhookID
|
||||||
|
|
||||||
|
return w, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// evict drops a webhook's archive writer from the registry and
|
// rename moves a database target's archive file to the name for
|
||||||
// closes its handle, so a deleted webhook does not leave a
|
// webhookName and targetName. It goes through the target's writer,
|
||||||
// writer (and an open archive handle within its debounce
|
// so the move holds the lock that writes and the idle sweep take,
|
||||||
// window) alive for the process lifetime.
|
// and later writes use the new name.
|
||||||
|
//
|
||||||
|
// The writer is created if there is none, and it stays cached. The
|
||||||
|
// handlers rename before they save the new name, so until the save
|
||||||
|
// the main database still has the old one; a delivery in that window
|
||||||
|
// must find this writer rather than build one from the old name.
|
||||||
|
func (t *databaseTarget) rename(
|
||||||
|
targetID, webhookName, targetName string,
|
||||||
|
) error {
|
||||||
|
w, err := t.writerFor(targetID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return w.rename(ArchiveFileName(webhookName, targetName, targetID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// evict drops a database target's archive writer from the registry
|
||||||
|
// and closes its handle, so a deleted target does not leave a
|
||||||
|
// writer (and an open archive handle within its debounce window)
|
||||||
|
// alive for the process lifetime.
|
||||||
//
|
//
|
||||||
// The map entry is removed under the registry lock, which is
|
// The map entry is removed under the registry lock, which is
|
||||||
// then released before the handle is closed under the writer's
|
// then released before the handle is closed under the writer's
|
||||||
// own lock: that ordering keeps the registry available to other
|
// own lock: that ordering keeps the registry available to other
|
||||||
// webhooks while an in-flight write on this one drains, and
|
// targets while an in-flight write on this one drains, and
|
||||||
// closing under the writer's lock means eviction can never race
|
// closing under the writer's lock means eviction can never race
|
||||||
// a write.
|
// a write.
|
||||||
//
|
//
|
||||||
// Eviction is idempotent and silent for a webhook with no
|
// Eviction is idempotent and silent for a target with no writer,
|
||||||
// writer, which is the common case: a webhook with no database
|
// which is the common case: only a database target that has
|
||||||
// target never creates one. It never deletes the archive file.
|
// received an event or been renamed has one. It never deletes the
|
||||||
func (t *databaseTarget) evict(webhookID string) {
|
// archive file.
|
||||||
|
func (t *databaseTarget) evict(targetID string) {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
|
|
||||||
w, ok := t.writers[webhookID]
|
w, ok := t.writers[targetID]
|
||||||
if ok {
|
if ok {
|
||||||
delete(t.writers, webhookID)
|
delete(t.writers, targetID)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
@@ -272,13 +364,41 @@ func (t *databaseTarget) evict(webhookID string) {
|
|||||||
|
|
||||||
t.eng.log.Info(
|
t.eng.log.Info(
|
||||||
"evicted archive writer",
|
"evicted archive writer",
|
||||||
"webhook_id", webhookID,
|
"target_id", targetID,
|
||||||
"path", w.path,
|
"path", w.path,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// evictWebhook evicts, exactly as evict does, the writer of every
|
||||||
|
// database target of a webhook.
|
||||||
|
func (t *databaseTarget) evictWebhook(webhookID string) {
|
||||||
|
t.mu.Lock()
|
||||||
|
|
||||||
|
var gone []*archiveWriter
|
||||||
|
|
||||||
|
for targetID, w := range t.writers {
|
||||||
|
if w.webhookID == webhookID {
|
||||||
|
delete(t.writers, targetID)
|
||||||
|
|
||||||
|
gone = append(gone, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
for _, w := range gone {
|
||||||
|
w.evict()
|
||||||
|
|
||||||
|
t.eng.log.Info(
|
||||||
|
"evicted archive writer",
|
||||||
|
"webhook_id", webhookID,
|
||||||
|
"path", w.path,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// evictAll evicts every cached archive writer, exactly as evict
|
// evictAll evicts every cached archive writer, exactly as evict
|
||||||
// does for one webhook. The engine calls it at shutdown, once its
|
// does for one target. The engine calls it at shutdown, once its
|
||||||
// workers have returned. Closing the last handle on an archive
|
// workers have returned. Closing the last handle on an archive
|
||||||
// moves the contents of its -wal into the .db and removes the
|
// moves the contents of its -wal into the .db and removes the
|
||||||
// -wal, so a clean stop leaves each archive as a single file.
|
// -wal, so a clean stop leaves each archive as a single file.
|
||||||
@@ -295,38 +415,25 @@ func (t *databaseTarget) evictAll() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sweepWebhook prunes one webhook's archive of rows older than
|
// sweepArchive prunes one database target's archive of rows older
|
||||||
// expiry, without requiring a write. It returns nil (nothing to
|
// than expiry, without requiring a write. A missing archive file is
|
||||||
// do) when the archive file does not exist, so a sweep never
|
// left missing (see sweepExpired), so a sweep never creates an
|
||||||
// creates an archive for a webhook that has a database target
|
// archive for a target that has never received an event.
|
||||||
// but has never received an event.
|
|
||||||
//
|
//
|
||||||
// It also never leaves a registry entry behind: an entry it had
|
// It also never leaves a registry entry behind: an entry it had
|
||||||
// to create to reach the writer's mutex is released again once
|
// to create to reach the writer's mutex is released again once
|
||||||
// the prune is done, so a sweep racing a webhook deletion cannot
|
// the prune is done, so a sweep racing a target deletion cannot
|
||||||
// resurrect the writer the eviction just dropped.
|
// resurrect the writer the eviction just dropped.
|
||||||
func (t *databaseTarget) sweepWebhook(
|
func (t *databaseTarget) sweepArchive(
|
||||||
webhookID string, expiry time.Duration,
|
targetID string, expiry time.Duration,
|
||||||
) error {
|
) error {
|
||||||
path, err := t.archivePath(webhookID)
|
w, created, err := t.sweepWriterFor(targetID)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check before taking a writer at all: a webhook whose
|
|
||||||
// archive has never been created gets no writer, no handle,
|
|
||||||
// and no file.
|
|
||||||
if !fileExists(path) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
w, created, err := t.sweepWriterFor(webhookID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if created {
|
if created {
|
||||||
defer t.releaseSweepWriter(webhookID, w)
|
defer t.releaseSweepWriter(targetID, w)
|
||||||
}
|
}
|
||||||
|
|
||||||
return w.sweepExpired(expiry)
|
return w.sweepExpired(expiry)
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -41,7 +43,7 @@ const (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
// errArchiveMissingWebhookID is returned when an event to
|
// errArchiveMissingWebhookID is returned when an event to
|
||||||
// archive has no webhook id to key its archive file on.
|
// archive has no webhook id to record in its archive row.
|
||||||
errArchiveMissingWebhookID = errors.New(
|
errArchiveMissingWebhookID = errors.New(
|
||||||
"cannot archive event without a webhook id",
|
"cannot archive event without a webhook id",
|
||||||
)
|
)
|
||||||
@@ -61,13 +63,19 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// errArchiveWriterEvicted is returned when a writer that has
|
// errArchiveWriterEvicted is returned when a writer that has
|
||||||
// been evicted (its webhook was deleted, or its last database
|
// been evicted (its target or its webhook was deleted) is used
|
||||||
// target was removed) is used again. An evicted writer is no
|
// again. An evicted writer is no longer in the registry, so
|
||||||
// longer in the registry, so reopening its file would leak a
|
// reopening its file would leak a handle nothing owns.
|
||||||
// handle nothing owns.
|
|
||||||
errArchiveWriterEvicted = errors.New(
|
errArchiveWriterEvicted = errors.New(
|
||||||
"archive writer has been evicted",
|
"archive writer has been evicted",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrArchiveNameTaken is returned when an archive cannot be
|
||||||
|
// renamed because a file already has the new name. That file may
|
||||||
|
// be an archive with rows of its own, so it is never replaced.
|
||||||
|
ErrArchiveNameTaken = errors.New(
|
||||||
|
"a file already has the archive's new name",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// databaseTargetConfig is the optional per-target JSON config
|
// databaseTargetConfig is the optional per-target JSON config
|
||||||
@@ -80,7 +88,7 @@ type databaseTargetConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// archivedEvent is one fully captured webhook event stored in a
|
// archivedEvent is one fully captured webhook event stored in a
|
||||||
// per-webhook archive database for long-term retention. It is a
|
// database target's archive for long-term retention. It is a
|
||||||
// self-contained copy — independent of the per-webhook event
|
// self-contained copy — independent of the per-webhook event
|
||||||
// database, which may prune events under its own retention.
|
// database, which may prune events under its own retention.
|
||||||
type archivedEvent struct {
|
type archivedEvent struct {
|
||||||
@@ -170,8 +178,8 @@ func ValidateArchiveExpiry(expiry string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// archiveWriter owns one per-webhook archive SQLite file. It
|
// archiveWriter owns one database target's archive SQLite file.
|
||||||
// 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
|
||||||
// next write recreates a moved or removed file, because the
|
// next write recreates a moved or removed file, because the
|
||||||
@@ -187,16 +195,21 @@ type archiveWriter struct {
|
|||||||
reopens int
|
reopens int
|
||||||
|
|
||||||
// evicted marks a writer that has been removed from the
|
// evicted marks a writer that has been removed from the
|
||||||
// per-webhook registry. Its handle is closed and it must
|
// registry. Its handle is closed and it must never open the
|
||||||
// never open the file again: nothing holds it any more, so a
|
// file again: nothing holds it any more, so a reopen would
|
||||||
// reopen would leak the handle for the process lifetime.
|
// leak the handle for the process lifetime.
|
||||||
evicted bool
|
evicted bool
|
||||||
|
|
||||||
|
// webhookID is the webhook the archive's target belongs to,
|
||||||
|
// so deleting the webhook can find its writers. It is set
|
||||||
|
// when the writer is created and never changes.
|
||||||
|
webhookID string
|
||||||
|
|
||||||
// sweepOwned marks a registry entry that the idle sweep
|
// sweepOwned marks a registry entry that the idle sweep
|
||||||
// created because no writer was cached for the webhook. The
|
// created because no writer was cached for the target. The
|
||||||
// sweep removes such an entry again when it is done, so a
|
// sweep removes such an entry again when it is done, so a
|
||||||
// sweep can never leave — or resurrect — a registry entry
|
// sweep can never leave — or resurrect — a registry entry
|
||||||
// for a webhook that has been deleted. A delivery that adopts
|
// for a target that has been deleted. A delivery that adopts
|
||||||
// the writer clears the flag, handing the entry to the
|
// the writer clears the flag, handing the entry to the
|
||||||
// registry proper.
|
// registry proper.
|
||||||
//
|
//
|
||||||
@@ -385,11 +398,62 @@ func (w *archiveWriter) sweepExpired(expiry time.Duration) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rename gives the archive file a new name in the same directory,
|
||||||
|
// and the writer uses the file under that name from now on. The
|
||||||
|
// handle is closed first, which folds the -wal into the .db; any
|
||||||
|
// -wal or -shm still beside the file (left by a crash) is moved with
|
||||||
|
// it, because SQLite finds them by name. A missing file is not an
|
||||||
|
// error: the operator may have moved it away, and the next write
|
||||||
|
// creates it under the new name.
|
||||||
|
//
|
||||||
|
// If a file already has the new name, nothing is moved and the
|
||||||
|
// error is ErrArchiveNameTaken.
|
||||||
|
func (w *archiveWriter) rename(name string) error {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
if w.evicted {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: %s", errArchiveWriterEvicted, w.path,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(filepath.Dir(w.path), name)
|
||||||
|
if path == w.path {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
suffixes := []string{"", "-wal", "-shm"}
|
||||||
|
|
||||||
|
for _, suffix := range suffixes {
|
||||||
|
if fileExists(path + suffix) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: %s", ErrArchiveNameTaken, name+suffix,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.close()
|
||||||
|
|
||||||
|
for _, suffix := range suffixes {
|
||||||
|
err := os.Rename(w.path+suffix, path+suffix)
|
||||||
|
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"renaming archive %s to %s: %w", w.path, path, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.path = path
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// evict closes the writer's handle and marks it unusable. It is
|
// evict closes the writer's handle and marks it unusable. It is
|
||||||
// called when the writer leaves the registry, either because the
|
// called when the writer leaves the registry, because its target
|
||||||
// webhook was deleted or because its last database target was
|
// or its webhook was deleted, or at shutdown. The archive FILE is
|
||||||
// removed. The archive FILE is deliberately left on disk: it is
|
// deliberately left on disk: it is long-term storage an operator
|
||||||
// long-term storage an operator may still want.
|
// may still want.
|
||||||
func (w *archiveWriter) evict() {
|
func (w *archiveWriter) evict() {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
|
|||||||
@@ -17,85 +17,109 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
)
|
)
|
||||||
|
|
||||||
// evictTestEngine builds an engine backed by a temporary data
|
// deliverTo archives one event to a database target, leaving the
|
||||||
// directory and returns it along with that directory.
|
// target's writer cached with its handle open.
|
||||||
func evictTestEngine(t *testing.T) (*delivery.Engine, string) {
|
func deliverTo(
|
||||||
|
t *testing.T, env *archiveEnv, tgt *database.Target,
|
||||||
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
dataDir := t.TempDir()
|
|
||||||
|
|
||||||
eng := delivery.NewTestEngineWithDB(
|
|
||||||
nil,
|
|
||||||
database.NewTestWebhookDBManager(dataDir),
|
|
||||||
archiveTestLogger(),
|
|
||||||
&http.Client{Timeout: 5 * time.Second},
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
|
|
||||||
return eng, dataDir
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEvictWebhook_ClosesAndRemovesWriter proves that evicting
|
|
||||||
// a webhook drops its archive writer from the registry and
|
|
||||||
// closes the open archive handle, rather than leaving both
|
|
||||||
// alive for the process lifetime.
|
|
||||||
func TestEvictWebhook_ClosesAndRemovesWriter(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
eng, dataDir := evictTestEngine(t)
|
|
||||||
|
|
||||||
webhookDB := testWebhookDB(t)
|
webhookDB := testWebhookDB(t)
|
||||||
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
||||||
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
|
|
||||||
|
|
||||||
eng.ExportDeliverDatabase(webhookDB, d)
|
env.eng.ExportDeliverDatabase(
|
||||||
|
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
|
||||||
webhookID := event.WebhookID
|
|
||||||
|
|
||||||
require.True(
|
|
||||||
t, eng.ExportHasArchiveWriter(webhookID),
|
|
||||||
"a delivery should have cached an archive writer",
|
|
||||||
)
|
|
||||||
require.True(
|
|
||||||
t, eng.ExportArchiveHandleOpen(webhookID),
|
|
||||||
"the writer should hold an open handle after a write",
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
eng.EvictWebhook(webhookID)
|
// TestEvictWebhook_ClosesAndRemovesWriter proves that evicting
|
||||||
|
// a webhook drops the archive writers of its database targets
|
||||||
|
// from the registry and closes their open handles, rather than
|
||||||
|
// leaving them alive for the process lifetime, and leaves another
|
||||||
|
// webhook's writer alone.
|
||||||
|
func TestEvictWebhook_ClosesAndRemovesWriter(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
assert.False(
|
env := setupArchiveTest(t)
|
||||||
t, eng.ExportHasArchiveWriter(webhookID),
|
first := env.seedDatabaseTarget(t, "")
|
||||||
"eviction should remove the registry entry",
|
second := env.addDatabaseTarget(t, first.WebhookID, "")
|
||||||
)
|
other := env.seedDatabaseTarget(t, "")
|
||||||
assert.False(
|
|
||||||
t, eng.ExportArchiveHandleOpen(webhookID),
|
|
||||||
"eviction should close the archive handle",
|
|
||||||
)
|
|
||||||
|
|
||||||
archivePath := filepath.Join(
|
for _, tgt := range []*database.Target{first, second, other} {
|
||||||
dataDir, fmt.Sprintf("archive-%s.db", webhookID),
|
deliverTo(t, env, tgt)
|
||||||
|
|
||||||
|
require.True(
|
||||||
|
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
|
||||||
|
"the writer should hold an open handle after a write",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
env.eng.EvictWebhook(first.WebhookID)
|
||||||
|
|
||||||
|
for _, tgt := range []*database.Target{first, second} {
|
||||||
|
assert.False(
|
||||||
|
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||||
|
"eviction should remove the registry entry",
|
||||||
|
)
|
||||||
|
assert.False(
|
||||||
|
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
|
||||||
|
"eviction should close the archive handle",
|
||||||
|
)
|
||||||
|
assert.FileExists(
|
||||||
|
t, env.archivePath(tgt),
|
||||||
|
"eviction must not delete the archive file",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(
|
||||||
|
t, env.eng.ExportArchiveHandleOpen(other.ID),
|
||||||
|
"another webhook's writer must be left alone",
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvictTarget_LeavesOtherTargets proves that evicting one
|
||||||
|
// database target leaves the writer of another target of the same
|
||||||
|
// webhook in place.
|
||||||
|
func TestEvictTarget_LeavesOtherTargets(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupArchiveTest(t)
|
||||||
|
doomed := env.seedDatabaseTarget(t, "")
|
||||||
|
kept := env.addDatabaseTarget(t, doomed.WebhookID, "")
|
||||||
|
|
||||||
|
deliverTo(t, env, doomed)
|
||||||
|
deliverTo(t, env, kept)
|
||||||
|
|
||||||
|
env.eng.EvictTarget(doomed.ID)
|
||||||
|
|
||||||
|
assert.False(t, env.eng.ExportHasArchiveWriter(doomed.ID))
|
||||||
assert.FileExists(
|
assert.FileExists(
|
||||||
t, archivePath,
|
t, env.archivePath(doomed),
|
||||||
"eviction must not delete the archive file",
|
"eviction must not delete the archive file",
|
||||||
)
|
)
|
||||||
|
assert.True(
|
||||||
|
t, env.eng.ExportArchiveHandleOpen(kept.ID),
|
||||||
|
"the other target's writer must be left alone",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEvictWebhook_UnknownWebhookIsNoOp proves eviction is safe
|
// TestEvictWebhook_UnknownWebhookIsNoOp proves eviction is safe
|
||||||
// for the common case of a webhook that never had a database
|
// for the common case of a webhook or target that never had an
|
||||||
// target, and that repeating it does not panic.
|
// archive writer, and that repeating it does not panic.
|
||||||
func TestEvictWebhook_UnknownWebhookIsNoOp(t *testing.T) {
|
func TestEvictWebhook_UnknownWebhookIsNoOp(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
eng, _ := evictTestEngine(t)
|
env := setupArchiveTest(t)
|
||||||
|
|
||||||
assert.NotPanics(t, func() {
|
assert.NotPanics(t, func() {
|
||||||
eng.EvictWebhook("no-such-webhook")
|
env.eng.EvictWebhook("no-such-webhook")
|
||||||
eng.EvictWebhook("no-such-webhook")
|
env.eng.EvictWebhook("no-such-webhook")
|
||||||
|
env.eng.EvictTarget("no-such-target")
|
||||||
|
env.eng.EvictTarget("no-such-target")
|
||||||
})
|
})
|
||||||
|
|
||||||
assert.False(
|
assert.False(
|
||||||
t, eng.ExportHasArchiveWriter("no-such-webhook"),
|
t, env.eng.ExportHasArchiveWriter("no-such-target"),
|
||||||
"eviction must not create a writer",
|
"eviction must not create a writer",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -289,17 +313,14 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
eng, _ := evictTestEngine(t)
|
env := setupArchiveTest(t)
|
||||||
|
tgt := env.seedDatabaseTarget(t, "")
|
||||||
webhookDB := testWebhookDB(t)
|
|
||||||
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
|
||||||
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
|
|
||||||
|
|
||||||
// Prime the registry so the test can hold the very writer the
|
// Prime the registry so the test can hold the very writer the
|
||||||
// eviction is about to detach.
|
// eviction is about to detach.
|
||||||
eng.ExportDeliverDatabase(webhookDB, d)
|
deliverTo(t, env, tgt)
|
||||||
|
|
||||||
w := eng.ExportArchiveWriterFor(event.WebhookID)
|
w := env.eng.ExportArchiveWriterFor(tgt.ID)
|
||||||
require.NotNil(t, w)
|
require.NotNil(t, w)
|
||||||
require.True(t, w.HandleOpen())
|
require.True(t, w.HandleOpen())
|
||||||
|
|
||||||
@@ -309,7 +330,7 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
|
|||||||
// eviction has to contend for the writer's mutex.
|
// eviction has to contend for the writer's mutex.
|
||||||
race.awaitFirstWrite()
|
race.awaitFirstWrite()
|
||||||
|
|
||||||
eng.EvictWebhook(event.WebhookID)
|
env.eng.EvictWebhook(tgt.WebhookID)
|
||||||
|
|
||||||
sawEvicted, otherErr := race.wait()
|
sawEvicted, otherErr := race.wait()
|
||||||
|
|
||||||
@@ -324,41 +345,33 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
|
|||||||
"been evicted",
|
"been evicted",
|
||||||
)
|
)
|
||||||
assert.False(
|
assert.False(
|
||||||
t, eng.ExportHasArchiveWriter(event.WebhookID),
|
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||||
"the registry entry must stay gone",
|
"the registry entry must stay gone",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEvictWebhook_LaterDeliveryRecreatesWriter proves eviction
|
// TestEvictWebhook_LaterDeliveryRecreatesWriter proves eviction
|
||||||
// does not break archiving for a webhook that is still alive: a
|
// does not break archiving for a target that is still alive: a
|
||||||
// subsequent delivery gets a brand new writer from the registry.
|
// subsequent delivery gets a brand new writer from the registry.
|
||||||
// It says nothing about the evicted writer itself — that is what
|
// It says nothing about the evicted writer itself — that is what
|
||||||
// TestEvictedWriter_WriteDoesNotReopenFile covers.
|
// TestEvictedWriter_WriteDoesNotReopenFile covers.
|
||||||
func TestEvictWebhook_LaterDeliveryRecreatesWriter(t *testing.T) {
|
func TestEvictWebhook_LaterDeliveryRecreatesWriter(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
eng, _ := evictTestEngine(t)
|
env := setupArchiveTest(t)
|
||||||
|
tgt := env.seedDatabaseTarget(t, "")
|
||||||
|
|
||||||
webhookDB := testWebhookDB(t)
|
deliverTo(t, env, tgt)
|
||||||
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
require.True(t, env.eng.ExportHasArchiveWriter(tgt.ID))
|
||||||
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
|
|
||||||
|
|
||||||
eng.ExportDeliverDatabase(webhookDB, d)
|
env.eng.EvictWebhook(tgt.WebhookID)
|
||||||
require.True(
|
|
||||||
t, eng.ExportHasArchiveWriter(event.WebhookID),
|
|
||||||
)
|
|
||||||
|
|
||||||
eng.EvictWebhook(event.WebhookID)
|
// A fresh delivery for the same target gets a brand new
|
||||||
|
|
||||||
// A fresh delivery for the same webhook gets a brand new
|
|
||||||
// writer from the registry, so archiving keeps working.
|
// writer from the registry, so archiving keeps working.
|
||||||
second := seedDatabaseTargetDelivery(
|
deliverTo(t, env, tgt)
|
||||||
t, webhookDB, event, "",
|
|
||||||
)
|
|
||||||
eng.ExportDeliverDatabase(webhookDB, second)
|
|
||||||
|
|
||||||
assert.True(
|
assert.True(
|
||||||
t, eng.ExportHasArchiveWriter(event.WebhookID),
|
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||||
"a later delivery should recreate the writer",
|
"a later delivery should recreate the writer",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -370,19 +383,16 @@ func TestEvictWebhook_LaterDeliveryRecreatesWriter(t *testing.T) {
|
|||||||
func TestEngineStop_WriteAfterStopIsRefused(t *testing.T) {
|
func TestEngineStop_WriteAfterStopIsRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
eng, _ := evictTestEngine(t)
|
env := setupArchiveTest(t)
|
||||||
|
tgt := env.seedDatabaseTarget(t, "")
|
||||||
|
|
||||||
webhookDB := testWebhookDB(t)
|
deliverTo(t, env, tgt)
|
||||||
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
|
||||||
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
|
|
||||||
|
|
||||||
eng.ExportDeliverDatabase(webhookDB, d)
|
w := env.eng.ExportArchiveWriterFor(tgt.ID)
|
||||||
|
|
||||||
w := eng.ExportArchiveWriterFor(event.WebhookID)
|
|
||||||
require.NotNil(t, w)
|
require.NotNil(t, w)
|
||||||
require.True(t, w.HandleOpen())
|
require.True(t, w.HandleOpen())
|
||||||
|
|
||||||
require.NoError(t, eng.ExportStop(context.Background()))
|
require.NoError(t, env.eng.ExportStop(context.Background()))
|
||||||
|
|
||||||
err := w.Write(evictTestRow("ev-after-stop"), 0)
|
err := w.Write(evictTestRow("ev-after-stop"), 0)
|
||||||
|
|
||||||
@@ -395,7 +405,7 @@ func TestEngineStop_WriteAfterStopIsRefused(t *testing.T) {
|
|||||||
"a refused write must not reopen the archive",
|
"a refused write must not reopen the archive",
|
||||||
)
|
)
|
||||||
assert.False(
|
assert.False(
|
||||||
t, eng.ExportHasArchiveWriter(event.WebhookID),
|
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||||
"the stop should empty the registry",
|
"the stop should empty the registry",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
@@ -74,25 +73,18 @@ func removeArchiveFiles(t *testing.T, path string) {
|
|||||||
|
|
||||||
// TestDeliverDatabase_ArchivesEvent verifies that delivering to
|
// TestDeliverDatabase_ArchivesEvent verifies that delivering to
|
||||||
// a database target marks the delivery delivered and archives
|
// a database target marks the delivery delivered and archives
|
||||||
// the full event into a separate per-webhook archive file.
|
// the full event into the target's own archive file.
|
||||||
func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
|
func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
dataDir := t.TempDir()
|
env := setupArchiveTest(t)
|
||||||
dbMgr := database.NewTestWebhookDBManager(dataDir)
|
tgt := env.seedDatabaseTarget(t, "")
|
||||||
|
|
||||||
e := delivery.NewTestEngineWithDB(
|
|
||||||
nil, dbMgr,
|
|
||||||
archiveTestLogger(),
|
|
||||||
&http.Client{Timeout: 5 * time.Second},
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
|
|
||||||
webhookDB := testWebhookDB(t)
|
webhookDB := testWebhookDB(t)
|
||||||
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
||||||
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
|
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
|
||||||
|
|
||||||
e.ExportDeliverDatabase(webhookDB, d)
|
env.eng.ExportDeliverDatabase(webhookDB, d)
|
||||||
|
|
||||||
var updated database.Delivery
|
var updated database.Delivery
|
||||||
|
|
||||||
@@ -105,8 +97,7 @@ func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
archivePath := filepath.Join(
|
archivePath := filepath.Join(
|
||||||
dataDir,
|
env.dataDir, "archive-sweep-test-archive-"+tgt.ID+".db",
|
||||||
fmt.Sprintf("archive-%s.db", event.WebhookID),
|
|
||||||
)
|
)
|
||||||
assert.FileExists(t, archivePath)
|
assert.FileExists(t, archivePath)
|
||||||
|
|
||||||
@@ -288,31 +279,31 @@ func TestParseArchiveExpiry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedDatabaseTargetDelivery seeds a pending delivery for a
|
// seedDatabaseTargetDelivery seeds a pending delivery of an event
|
||||||
// database target with the given config JSON and returns the
|
// to a database target and returns the in-memory delivery the
|
||||||
// in-memory delivery the target handler is invoked with.
|
// target handler is invoked with.
|
||||||
func seedDatabaseTargetDelivery(
|
func seedDatabaseTargetDelivery(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
webhookDB *gorm.DB,
|
webhookDB *gorm.DB,
|
||||||
event database.Event,
|
event database.Event,
|
||||||
config string,
|
tgt *database.Target,
|
||||||
) *database.Delivery {
|
) *database.Delivery {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
dlv := seedDelivery(
|
dlv := seedDelivery(
|
||||||
t, webhookDB, event.ID, uuid.New().String(),
|
t, webhookDB, event.ID, tgt.ID,
|
||||||
database.DeliveryStatusPending,
|
database.DeliveryStatusPending,
|
||||||
)
|
)
|
||||||
|
|
||||||
d := &database.Delivery{
|
d := &database.Delivery{
|
||||||
EventID: event.ID,
|
EventID: event.ID,
|
||||||
TargetID: dlv.TargetID,
|
TargetID: tgt.ID,
|
||||||
Status: database.DeliveryStatusPending,
|
Status: database.DeliveryStatusPending,
|
||||||
Event: event,
|
Event: event,
|
||||||
Target: database.Target{
|
Target: database.Target{
|
||||||
Name: "test-db",
|
Name: tgt.Name,
|
||||||
Type: database.TargetTypeDatabase,
|
Type: database.TargetTypeDatabase,
|
||||||
Config: config,
|
Config: tgt.Config,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
d.ID = dlv.ID
|
d.ID = dlv.ID
|
||||||
@@ -330,22 +321,14 @@ func TestDeliverDatabase_ArchiveFailureFailsDelivery(
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
dataDir := t.TempDir()
|
env := setupArchiveTest(t)
|
||||||
|
tgt := env.seedDatabaseTarget(t, `{"expiry":"nonsense"}`)
|
||||||
e := delivery.NewTestEngineWithDB(
|
|
||||||
nil, database.NewTestWebhookDBManager(dataDir),
|
|
||||||
archiveTestLogger(),
|
|
||||||
&http.Client{Timeout: 5 * time.Second},
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
|
|
||||||
webhookDB := testWebhookDB(t)
|
webhookDB := testWebhookDB(t)
|
||||||
event := seedEvent(t, webhookDB, `{"archived":false}`)
|
event := seedEvent(t, webhookDB, `{"archived":false}`)
|
||||||
d := seedDatabaseTargetDelivery(
|
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
|
||||||
t, webhookDB, event, `{"expiry":"nonsense"}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
e.ExportDeliverDatabase(webhookDB, d)
|
env.eng.ExportDeliverDatabase(webhookDB, d)
|
||||||
|
|
||||||
var updated database.Delivery
|
var updated database.Delivery
|
||||||
|
|
||||||
@@ -373,10 +356,7 @@ func TestDeliverDatabase_ArchiveFailureFailsDelivery(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert.NoFileExists(t,
|
assert.NoFileExists(t,
|
||||||
filepath.Join(
|
env.archivePath(tgt),
|
||||||
dataDir,
|
|
||||||
fmt.Sprintf("archive-%s.db", event.WebhookID),
|
|
||||||
),
|
|
||||||
"no archive file should exist for a failed config",
|
"no archive file should exist for a failed config",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -400,3 +380,247 @@ func TestValidateArchiveExpiry(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestArchiveFileName pins the archive file name and the rules
|
||||||
|
// that make a webhook or target name safe to put in it.
|
||||||
|
func TestArchiveFileName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const id = "3f2a1c9e-8d4b-4c1a-9e2f-0a1b2c3d4e5f"
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
webhook string
|
||||||
|
target string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"plain names", "orders", "archive",
|
||||||
|
"archive-orders-archive-" + id + ".db",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lowercased", "Orders", "Main Archive",
|
||||||
|
"archive-orders-main-archive-" + id + ".db",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a run of other characters is one dash",
|
||||||
|
`a /\..b`, "c__--d",
|
||||||
|
"archive-a-b-c-d-" + id + ".db",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"no dash at either end", " --orders!! ", "(archive)",
|
||||||
|
"archive-orders-archive-" + id + ".db",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path separators", "../../etc/passwd", "a/b",
|
||||||
|
"archive-etc-passwd-a-b-" + id + ".db",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letters outside ASCII are dropped",
|
||||||
|
"Bestellungen Größe", "café",
|
||||||
|
"archive-bestellungen-gr-e-caf-" + id + ".db",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nothing left is unnamed", "", "!!!",
|
||||||
|
"archive-unnamed-unnamed-" + id + ".db",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cut to 40 characters", strings.Repeat("a", 50), "x",
|
||||||
|
"archive-" + strings.Repeat("a", 40) + "-x-" + id + ".db",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"no dash left by the cut",
|
||||||
|
strings.Repeat("a", 39) + " b", "x",
|
||||||
|
"archive-" + strings.Repeat("a", 39) + "-x-" + id + ".db",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, tc.want,
|
||||||
|
delivery.ArchiveFileName(tc.webhook, tc.target, id),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeliverDatabase_EachTargetHasItsOwnArchive proves two
|
||||||
|
// database targets of one webhook archive into separate files.
|
||||||
|
func TestDeliverDatabase_EachTargetHasItsOwnArchive(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupArchiveTest(t)
|
||||||
|
first := env.seedDatabaseTarget(t, "")
|
||||||
|
second := env.addDatabaseTarget(t, first.WebhookID, "")
|
||||||
|
|
||||||
|
webhookDB := testWebhookDB(t)
|
||||||
|
event := seedEvent(t, webhookDB, `{"n":1}`)
|
||||||
|
|
||||||
|
for _, tgt := range []*database.Target{first, second} {
|
||||||
|
env.eng.ExportDeliverDatabase(
|
||||||
|
webhookDB,
|
||||||
|
seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NotEqual(
|
||||||
|
t, env.archivePath(first), env.archivePath(second),
|
||||||
|
)
|
||||||
|
assert.Equal(
|
||||||
|
t, []string{event.ID},
|
||||||
|
archivedEventIDs(t, env.archivePath(first)),
|
||||||
|
)
|
||||||
|
assert.Equal(
|
||||||
|
t, []string{event.ID},
|
||||||
|
archivedEventIDs(t, env.archivePath(second)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRename_MovesTheFile proves a rename moves the archive, rows
|
||||||
|
// and all, and that later writes go to the new name.
|
||||||
|
func TestRename_MovesTheFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupArchiveTest(t)
|
||||||
|
tgt := env.seedDatabaseTarget(t, "")
|
||||||
|
oldPath := env.archivePath(tgt)
|
||||||
|
|
||||||
|
webhookDB := testWebhookDB(t)
|
||||||
|
first := seedEvent(t, webhookDB, `{"n":1}`)
|
||||||
|
env.eng.ExportDeliverDatabase(
|
||||||
|
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, first, tgt),
|
||||||
|
)
|
||||||
|
require.FileExists(t, oldPath)
|
||||||
|
|
||||||
|
require.NoError(
|
||||||
|
t, env.eng.Rename(tgt.ID, "Orders", "Long Term"),
|
||||||
|
)
|
||||||
|
|
||||||
|
newPath := filepath.Join(
|
||||||
|
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.NoFileExists(t, oldPath)
|
||||||
|
assert.Equal(t, []string{first.ID}, archivedEventIDs(t, newPath))
|
||||||
|
|
||||||
|
second := seedEvent(t, webhookDB, `{"n":2}`)
|
||||||
|
env.eng.ExportDeliverDatabase(
|
||||||
|
webhookDB,
|
||||||
|
seedDatabaseTargetDelivery(t, webhookDB, second, tgt),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.ElementsMatch(
|
||||||
|
t, []string{first.ID, second.ID},
|
||||||
|
archivedEventIDs(t, newPath),
|
||||||
|
)
|
||||||
|
assert.NoFileExists(
|
||||||
|
t, oldPath, "a write after the rename must use the new name",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRename_NeverReplacesAFile plants a file at the new name and
|
||||||
|
// proves the rename is refused, the planted file survives, and the
|
||||||
|
// archive keeps its name and its rows.
|
||||||
|
func TestRename_NeverReplacesAFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupArchiveTest(t)
|
||||||
|
tgt := env.seedDatabaseTarget(t, "")
|
||||||
|
oldPath := env.archivePath(tgt)
|
||||||
|
|
||||||
|
webhookDB := testWebhookDB(t)
|
||||||
|
first := seedEvent(t, webhookDB, `{"n":1}`)
|
||||||
|
env.eng.ExportDeliverDatabase(
|
||||||
|
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, first, tgt),
|
||||||
|
)
|
||||||
|
|
||||||
|
newPath := filepath.Join(
|
||||||
|
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
|
||||||
|
)
|
||||||
|
require.NoError(t, os.WriteFile(newPath, []byte("planted"), 0o600))
|
||||||
|
|
||||||
|
require.ErrorIs(
|
||||||
|
t, env.eng.Rename(tgt.ID, "Orders", "Long Term"),
|
||||||
|
delivery.ErrArchiveNameTaken,
|
||||||
|
)
|
||||||
|
|
||||||
|
//nolint:gosec // reads the file the test planted under t.TempDir()
|
||||||
|
planted, err := os.ReadFile(newPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "planted", string(planted))
|
||||||
|
|
||||||
|
second := seedEvent(t, webhookDB, `{"n":2}`)
|
||||||
|
env.eng.ExportDeliverDatabase(
|
||||||
|
webhookDB,
|
||||||
|
seedDatabaseTargetDelivery(t, webhookDB, second, tgt),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.ElementsMatch(
|
||||||
|
t, []string{first.ID, second.ID},
|
||||||
|
archivedEventIDs(t, oldPath),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRename_BeforeTheNameIsSaved covers the order the handlers
|
||||||
|
// use: they rename before they save the new name, so a delivery in
|
||||||
|
// between must write under the new name although the main database
|
||||||
|
// still has the old one. It also shows that renaming an archive that
|
||||||
|
// does not exist yet is not an error.
|
||||||
|
func TestRename_BeforeTheNameIsSaved(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupArchiveTest(t)
|
||||||
|
tgt := env.seedDatabaseTarget(t, "")
|
||||||
|
|
||||||
|
require.NoError(
|
||||||
|
t, env.eng.Rename(tgt.ID, "Orders", "Archive"),
|
||||||
|
)
|
||||||
|
|
||||||
|
webhookDB := testWebhookDB(t)
|
||||||
|
event := seedEvent(t, webhookDB, `{"n":1}`)
|
||||||
|
env.eng.ExportDeliverDatabase(
|
||||||
|
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.FileExists(
|
||||||
|
t,
|
||||||
|
filepath.Join(
|
||||||
|
env.dataDir, "archive-orders-archive-"+tgt.ID+".db",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert.NoFileExists(t, env.archivePath(tgt))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveWriter_RenameMovesSidecars proves a rename carries
|
||||||
|
// the -wal and -shm a crash can leave beside an archive no handle
|
||||||
|
// has opened since. SQLite finds them by name, so a -wal left
|
||||||
|
// behind would lose the transactions it holds.
|
||||||
|
func TestArchiveWriter_RenameMovesSidecars(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
oldPath := filepath.Join(dir, "archive-old.db")
|
||||||
|
newPath := filepath.Join(dir, "archive-new.db")
|
||||||
|
|
||||||
|
for _, suffix := range archiveFileSuffixes() {
|
||||||
|
require.NoError(
|
||||||
|
t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := delivery.NewExportArchiveWriter(
|
||||||
|
oldPath, archiveTestLogger(), 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, w.Rename("archive-new.db"))
|
||||||
|
|
||||||
|
for _, suffix := range archiveFileSuffixes() {
|
||||||
|
assert.NoFileExists(t, oldPath+suffix)
|
||||||
|
assert.FileExists(t, newPath+suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, newPath, w.Path())
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
|
|||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("failed to parse form", "error", err)
|
h.log.Error("failed to parse form", "error", err)
|
||||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
h.renderError(w, r, http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -212,11 +212,7 @@ func (h *Handlers) authenticateUser(
|
|||||||
|
|
||||||
valid, err := database.VerifyPassword(password, user.Password)
|
valid, err := database.VerifyPassword(password, user.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("failed to verify password", "error", err)
|
h.serverError(w, r, "failed to verify password", err)
|
||||||
http.Error(
|
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return user, err
|
return user, err
|
||||||
}
|
}
|
||||||
@@ -288,24 +284,14 @@ func (h *Handlers) createAuthenticatedSession(
|
|||||||
) error {
|
) error {
|
||||||
oldSess, err := h.session.Get(r)
|
oldSess, err := h.session.Get(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("failed to get session", "error", err)
|
h.serverError(w, r, "failed to get session", err)
|
||||||
http.Error(
|
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
sess, err := h.session.Regenerate(r, w, oldSess)
|
sess, err := h.session.Regenerate(r, w, oldSess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error(
|
h.serverError(w, r, "failed to regenerate session", err)
|
||||||
"failed to regenerate session", "error", err,
|
|
||||||
)
|
|
||||||
http.Error(
|
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -314,11 +300,7 @@ func (h *Handlers) createAuthenticatedSession(
|
|||||||
|
|
||||||
err = h.session.Save(r, w, sess)
|
err = h.session.Save(r, w, sess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("failed to save session", "error", err)
|
h.serverError(w, r, "failed to save session", err)
|
||||||
http.Error(
|
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,9 +105,7 @@ func (h *Handlers) HandleDeliveryReplay() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(
|
h.renderError(w, r, http.StatusBadRequest)
|
||||||
w, "Bad request", http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -124,14 +122,14 @@ func (h *Handlers) replayDelivery(
|
|||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
) {
|
) {
|
||||||
if !h.dbMgr.DBExists(webhook.ID) {
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to get webhook database", err)
|
h.serverError(w, r, "failed to get webhook database", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -173,7 +171,7 @@ func (h *Handlers) loadReplaySource(
|
|||||||
&original, "id = ?", chi.URLParam(r, "deliveryID"),
|
&original, "id = ?", chi.URLParam(r, "deliveryID"),
|
||||||
).Error
|
).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
@@ -195,7 +193,7 @@ func (h *Handlers) queueReplay(
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, "failed to count in-flight deliveries", err,
|
w, r, "failed to count in-flight deliveries", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -212,7 +210,7 @@ func (h *Handlers) queueReplay(
|
|||||||
err = webhookDB.
|
err = webhookDB.
|
||||||
First(&event, "id = ?", original.EventID).Error
|
First(&event, "id = ?", original.EventID).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to load event for replay", err)
|
h.serverError(w, r, "failed to load event for replay", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -222,7 +220,7 @@ func (h *Handlers) queueReplay(
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, "failed to create replay delivery", err,
|
w, r, "failed to create replay delivery", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestErrorPage_RenderFailureKeepsStatus proves that an error page
|
||||||
|
// which cannot render answers with the status it was reporting, as
|
||||||
|
// plain text, and is not attempted again: a page whose own render
|
||||||
|
// fails reaches the error page, and the error page failing as well
|
||||||
|
// ends there with the 500.
|
||||||
|
func TestErrorPage_RenderFailureKeepsStatus(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var h *handlers.Handlers
|
||||||
|
|
||||||
|
app := newTestApp(t, &h)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
// .Status is an int, so asking it for a field fails the render.
|
||||||
|
failing := `{{.Status.Missing}}`
|
||||||
|
h.AddTemplateForTest("error.html", template.Must(
|
||||||
|
template.New("error").Parse(failing),
|
||||||
|
))
|
||||||
|
h.AddTemplateForTest("failing.html", template.Must(
|
||||||
|
template.New("failing").Parse(`{{.Data.Missing}}`),
|
||||||
|
))
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/", nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleErrorPage(http.StatusNotFound).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
assert.Equal(t, "Not Found\n", w.Body.String())
|
||||||
|
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
h.RenderTemplateForTest(w, req, "failing.html", 0)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||||
|
assert.Equal(t, "Internal Server Error\n", w.Body.String())
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc {
|
|||||||
// steered by a client.
|
// steered by a client.
|
||||||
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -103,21 +103,21 @@ func (h *Handlers) serveEventBody(
|
|||||||
eventID string,
|
eventID string,
|
||||||
) {
|
) {
|
||||||
if !h.dbMgr.DBExists(webhook.ID) {
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to get webhook database", err)
|
h.serverError(w, r, "failed to get webhook database", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
body, found, err := eventBody(webhookDB, webhook.ID, eventID)
|
body, found, err := eventBody(webhookDB, webhook.ID, eventID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to read event body", err)
|
h.serverError(w, r, "failed to read event body", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,7 @@ func (h *Handlers) serveEventBody(
|
|||||||
// row and the whole body is served, or it does not and the
|
// row and the whole body is served, or it does not and the
|
||||||
// response is a clean 404.
|
// response is a clean 404.
|
||||||
if !found {
|
if !found {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ func (h *Handlers) HandleEventResubmit() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
h.renderError(w, r, http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -120,20 +120,20 @@ func (h *Handlers) resubmitEvent(
|
|||||||
// alphabet rather than from the request.
|
// alphabet rather than from the request.
|
||||||
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !h.dbMgr.DBExists(webhook.ID) {
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to get webhook database", err)
|
h.serverError(w, r, "failed to get webhook database", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -147,7 +147,7 @@ func (h *Handlers) resubmitEvent(
|
|||||||
webhookDB, webhook.ID, eventID.String(),
|
webhookDB, webhook.ID, eventID.String(),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to load event to resubmit", err)
|
h.serverError(w, r, "failed to load event to resubmit", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -155,7 +155,7 @@ func (h *Handlers) resubmitEvent(
|
|||||||
// A miss is a 404 whether the event was reaped, belongs to
|
// A miss is a 404 whether the event was reaped, belongs to
|
||||||
// another webhook, or never existed.
|
// another webhook, or never existed.
|
||||||
if !found {
|
if !found {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -207,7 +207,7 @@ func (h *Handlers) queueResubmit(
|
|||||||
// inactive one is skipped rather than refused.
|
// inactive one is skipped rather than refused.
|
||||||
targets, err := h.loadActiveTargets(webhook.ID)
|
targets, err := h.loadActiveTargets(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to query targets", err)
|
h.serverError(w, r, "failed to query targets", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -225,7 +225,7 @@ func (h *Handlers) queueResubmit(
|
|||||||
targets,
|
targets,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to store resubmitted event", err)
|
h.serverError(w, r, "failed to store resubmitted event", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -65,7 +67,7 @@ func (s *Handlers) LoadEventLogViewsForTest(
|
|||||||
page int,
|
page int,
|
||||||
) []EventLogView {
|
) []EventLogView {
|
||||||
views, _, _ := s.loadEventsWithDeliveries(
|
views, _, _ := s.loadEventsWithDeliveries(
|
||||||
w, webhook, nil, page,
|
w, newRequestForTest(), webhook, nil, page,
|
||||||
)
|
)
|
||||||
|
|
||||||
return views
|
return views
|
||||||
@@ -94,6 +96,14 @@ func FinishedByTargetForTest(
|
|||||||
return finishedByTarget(webhookDB, since)
|
return finishedByTarget(webhookDB, since)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newRequestForTest is the request the helpers here pass on for
|
||||||
|
// callers that have none: it is used only to render the error page.
|
||||||
|
func newRequestForTest() *http.Request {
|
||||||
|
return httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/", nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// AddTemplateForTest registers a template under a page name so that
|
// AddTemplateForTest registers a template under a page name so that
|
||||||
// the handlers_test package can drive the render path with a
|
// the handlers_test package can drive the render path with a
|
||||||
// template of its own.
|
// template of its own.
|
||||||
@@ -147,5 +157,5 @@ func (s *Handlers) BuildDatabaseTargetConfigForTest(
|
|||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
expiry string,
|
expiry string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
return s.buildDatabaseTargetConfig(w, expiry)
|
return s.buildDatabaseTargetConfig(w, newRequestForTest(), expiry)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ type HandlersParams struct {
|
|||||||
Session *session.Session
|
Session *session.Session
|
||||||
Middleware *middleware.Middleware
|
Middleware *middleware.Middleware
|
||||||
Notifier delivery.Notifier
|
Notifier delivery.Notifier
|
||||||
Evictor delivery.WebhookEvictor
|
Archives delivery.Archives
|
||||||
SSRFGuard *delivery.Guard
|
SSRFGuard *delivery.Guard
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ type Handlers struct {
|
|||||||
session *session.Session
|
session *session.Session
|
||||||
mw *middleware.Middleware
|
mw *middleware.Middleware
|
||||||
notifier delivery.Notifier
|
notifier delivery.Notifier
|
||||||
evictor delivery.WebhookEvictor
|
archives delivery.Archives
|
||||||
mtr *metrics.Set
|
mtr *metrics.Set
|
||||||
templates map[string]*template.Template
|
templates map[string]*template.Template
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ func New(
|
|||||||
s.session = params.Session
|
s.session = params.Session
|
||||||
s.mw = params.Middleware
|
s.mw = params.Middleware
|
||||||
s.notifier = params.Notifier
|
s.notifier = params.Notifier
|
||||||
s.evictor = params.Evictor
|
s.archives = params.Archives
|
||||||
s.mtr = metrics.Default()
|
s.mtr = metrics.Default()
|
||||||
s.ssrf = params.SSRFGuard
|
s.ssrf = params.SSRFGuard
|
||||||
|
|
||||||
@@ -142,6 +142,7 @@ func New(
|
|||||||
"source_edit.html": parsePageTemplate("source_edit.html"),
|
"source_edit.html": parsePageTemplate("source_edit.html"),
|
||||||
"source_logs.html": parsePageTemplate("source_logs.html"),
|
"source_logs.html": parsePageTemplate("source_logs.html"),
|
||||||
"target_edit.html": parsePageTemplate("target_edit.html"),
|
"target_edit.html": parsePageTemplate("target_edit.html"),
|
||||||
|
"error.html": parsePageTemplate("error.html"),
|
||||||
}
|
}
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
@@ -153,6 +154,16 @@ func New(
|
|||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleErrorPage returns a handler that answers every request with
|
||||||
|
// the error page for status. The router uses it for unknown paths, the
|
||||||
|
// CSRF middleware for a refused form, and each admin page route
|
||||||
|
// group's recoverer for a panic.
|
||||||
|
func (s *Handlers) HandleErrorPage(status int) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.renderError(w, r, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Handlers) respondJSON(
|
func (s *Handlers) respondJSON(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
_ *http.Request,
|
_ *http.Request,
|
||||||
@@ -170,15 +181,76 @@ func (s *Handlers) respondJSON(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// serverError logs an error and sends a 500 response.
|
// serverError logs an error and answers with the 500 error page.
|
||||||
func (s *Handlers) serverError(
|
func (s *Handlers) serverError(
|
||||||
w http.ResponseWriter, msg string, err error,
|
w http.ResponseWriter, r *http.Request, msg string, err error,
|
||||||
) {
|
) {
|
||||||
s.log.Error(msg, "error", err)
|
s.log.Error(msg, "error", err)
|
||||||
http.Error(
|
s.renderError(w, r, http.StatusInternalServerError)
|
||||||
w, "Internal server error",
|
}
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
// renderError answers with status and the error page: the normal
|
||||||
|
// layout, one fixed line explaining the status, and a link back to the
|
||||||
|
// webhook list, or to sign-in when nobody is signed in.
|
||||||
|
//
|
||||||
|
// It renders the page itself rather than through renderTemplate,
|
||||||
|
// whose own failure comes here. If the error page cannot render
|
||||||
|
// either, the answer is the same status in plain text: never a second
|
||||||
|
// attempt, and never a different status.
|
||||||
|
func (s *Handlers) renderError(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
status int,
|
||||||
|
) {
|
||||||
|
// The page names the signed-in user, and some error pages are
|
||||||
|
// served outside the routes where NoCache runs.
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
|
||||||
|
data := s.pageData(r, map[string]any{
|
||||||
|
"Status": status,
|
||||||
|
"StatusText": http.StatusText(status),
|
||||||
|
"Message": errorPageText(status),
|
||||||
|
})
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
err := s.templates["error.html"].Execute(&buf, data)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("failed to render error page", "error", err)
|
||||||
|
http.Error(w, http.StatusText(status), status)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
|
||||||
|
_, err = buf.WriteTo(w)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("failed to write error page", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// errorPageText is the line the error page shows for status. It is
|
||||||
|
// fixed per status, so the page tells the reader no more than the
|
||||||
|
// plain-text answers it replaced did.
|
||||||
|
func errorPageText(status int) string {
|
||||||
|
switch status {
|
||||||
|
case http.StatusBadRequest:
|
||||||
|
return "The request could not be read."
|
||||||
|
case http.StatusForbidden:
|
||||||
|
return "The request was refused. If it came from a form " +
|
||||||
|
"left open for a long time, reload the page and try " +
|
||||||
|
"again."
|
||||||
|
case http.StatusNotFound:
|
||||||
|
return "There is nothing here. It may have been deleted, " +
|
||||||
|
"or the address may be wrong."
|
||||||
|
case http.StatusServiceUnavailable:
|
||||||
|
return "The server is busy. Please try again in a moment."
|
||||||
|
default: // http.StatusInternalServerError
|
||||||
|
return "Something went wrong on the server. Please try " +
|
||||||
|
"again."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserInfo represents user information for templates
|
// UserInfo represents user information for templates
|
||||||
@@ -231,14 +303,17 @@ func (s *Handlers) renderTemplate(
|
|||||||
"template not found",
|
"template not found",
|
||||||
"template", pageTemplate,
|
"template", pageTemplate,
|
||||||
)
|
)
|
||||||
http.Error(
|
s.renderError(w, r, http.StatusInternalServerError)
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.executeTemplate(w, r, tmpl, s.pageData(r, data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// pageData adds the fields the shared layout renders to a page's own
|
||||||
|
// data.
|
||||||
|
func (s *Handlers) pageData(r *http.Request, data any) any {
|
||||||
userInfo := s.getUserInfo(r)
|
userInfo := s.getUserInfo(r)
|
||||||
csrfToken := middleware.CSRFToken(r)
|
csrfToken := middleware.CSRFToken(r)
|
||||||
|
|
||||||
@@ -252,19 +327,16 @@ func (s *Handlers) renderTemplate(
|
|||||||
m["User"] = userInfo
|
m["User"] = userInfo
|
||||||
m["CSRFToken"] = csrfToken
|
m["CSRFToken"] = csrfToken
|
||||||
m["Version"] = version
|
m["Version"] = version
|
||||||
s.executeTemplate(w, tmpl, m)
|
|
||||||
|
|
||||||
return
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
wrapper := templateDataWrapper{
|
return templateDataWrapper{
|
||||||
User: userInfo,
|
User: userInfo,
|
||||||
CSRFToken: csrfToken,
|
CSRFToken: csrfToken,
|
||||||
Version: version,
|
Version: version,
|
||||||
Data: data,
|
Data: data,
|
||||||
}
|
}
|
||||||
|
|
||||||
s.executeTemplate(w, tmpl, wrapper)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// executeTemplate renders the template into a buffer and writes to
|
// executeTemplate renders the template into a buffer and writes to
|
||||||
@@ -277,6 +349,7 @@ func (s *Handlers) renderTemplate(
|
|||||||
// this reason.
|
// this reason.
|
||||||
func (s *Handlers) executeTemplate(
|
func (s *Handlers) executeTemplate(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
tmpl *template.Template,
|
tmpl *template.Template,
|
||||||
data any,
|
data any,
|
||||||
) {
|
) {
|
||||||
@@ -287,10 +360,7 @@ func (s *Handlers) executeTemplate(
|
|||||||
s.log.Error(
|
s.log.Error(
|
||||||
"failed to execute template", "error", err,
|
"failed to execute template", "error", err,
|
||||||
)
|
)
|
||||||
http.Error(
|
s.renderError(w, r, http.StatusInternalServerError)
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handlers_test
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -51,23 +52,73 @@ func (n *recordingNotifier) Tasks() []delivery.Task {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordingEvictor is a delivery.WebhookEvictor that records
|
// recordingArchives is a delivery.Archives that records what it
|
||||||
// the webhook ids it was asked to evict, so a test can prove
|
// was asked to do, so a test can prove that a deletion or rename
|
||||||
// that a deletion path reached the delivery engine.
|
// path reached the delivery engine. After FailRenames, every
|
||||||
type recordingEvictor struct {
|
// rename fails with the given error.
|
||||||
mu sync.Mutex
|
type recordingArchives struct {
|
||||||
evicted []string
|
mu sync.Mutex
|
||||||
|
evicted []string
|
||||||
|
evictedTargets []string
|
||||||
|
renames []archiveRename
|
||||||
|
renameErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *recordingEvictor) EvictWebhook(webhookID string) {
|
// errInjectedRename is the failure a test hands FailRenames.
|
||||||
|
var errInjectedRename = errors.New("injected rename failure")
|
||||||
|
|
||||||
|
// errNameTaken is what the delivery engine returns when a file
|
||||||
|
// already has an archive's new name, here archive-taken.db.
|
||||||
|
var errNameTaken = fmt.Errorf(
|
||||||
|
"%w: archive-taken.db", delivery.ErrArchiveNameTaken,
|
||||||
|
)
|
||||||
|
|
||||||
|
// archiveRename is one recorded Rename call.
|
||||||
|
type archiveRename struct {
|
||||||
|
TargetID string
|
||||||
|
WebhookName string
|
||||||
|
TargetName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingArchives) EvictWebhook(webhookID string) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
r.evicted = append(r.evicted, webhookID)
|
r.evicted = append(r.evicted, webhookID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *recordingArchives) EvictTarget(targetID string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
r.evictedTargets = append(r.evictedTargets, targetID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingArchives) Rename(
|
||||||
|
targetID, webhookName, targetName string,
|
||||||
|
) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
r.renames = append(r.renames, archiveRename{
|
||||||
|
TargetID: targetID,
|
||||||
|
WebhookName: webhookName,
|
||||||
|
TargetName: targetName,
|
||||||
|
})
|
||||||
|
|
||||||
|
return r.renameErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// FailRenames makes every later rename fail with err.
|
||||||
|
func (r *recordingArchives) FailRenames(err error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
r.renameErr = err
|
||||||
|
}
|
||||||
|
|
||||||
// Evicted returns a copy of the recorded webhook ids.
|
// Evicted returns a copy of the recorded webhook ids.
|
||||||
func (r *recordingEvictor) Evicted() []string {
|
func (r *recordingArchives) Evicted() []string {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
@@ -77,6 +128,28 @@ func (r *recordingEvictor) Evicted() []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EvictedTargets returns a copy of the recorded target ids.
|
||||||
|
func (r *recordingArchives) EvictedTargets() []string {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
out := make([]string, len(r.evictedTargets))
|
||||||
|
copy(out, r.evictedTargets)
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renames returns a copy of the recorded renames.
|
||||||
|
func (r *recordingArchives) Renames() []archiveRename {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
out := make([]archiveRename, len(r.renames))
|
||||||
|
copy(out, r.renames)
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func newTestApp(
|
func newTestApp(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
targets ...any,
|
targets ...any,
|
||||||
@@ -103,10 +176,10 @@ func newTestApp(
|
|||||||
func(n *recordingNotifier) delivery.Notifier {
|
func(n *recordingNotifier) delivery.Notifier {
|
||||||
return n
|
return n
|
||||||
},
|
},
|
||||||
func() *recordingEvictor {
|
func() *recordingArchives {
|
||||||
return &recordingEvictor{}
|
return &recordingArchives{}
|
||||||
},
|
},
|
||||||
func(r *recordingEvictor) delivery.WebhookEvictor {
|
func(r *recordingArchives) delivery.Archives {
|
||||||
return r
|
return r
|
||||||
},
|
},
|
||||||
middleware.New,
|
middleware.New,
|
||||||
@@ -307,10 +380,14 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
|
|||||||
t, http.StatusInternalServerError, w.Code,
|
t, http.StatusInternalServerError, w.Code,
|
||||||
"a failed render must report a 500",
|
"a failed render must report a 500",
|
||||||
)
|
)
|
||||||
assert.Equal(
|
assert.NotContains(
|
||||||
t, "Internal server error\n", w.Body.String(),
|
t, w.Body.String(), partialPageMarker,
|
||||||
"the response must carry no part of the aborted page",
|
"the response must carry no part of the aborted page",
|
||||||
)
|
)
|
||||||
|
assert.Contains(
|
||||||
|
t, w.Body.String(), "500 Internal Server Error",
|
||||||
|
"a failed render must answer with the error page",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
|
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
@@ -37,14 +36,14 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
|
|||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("failed to parse form", "error", err)
|
h.log.Error("failed to parse form", "error", err)
|
||||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
h.renderError(w, r, http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
successMessage, errorMessage, handled := h.applyPasswordChange(
|
successMessage, errorMessage, handled := h.applyPasswordChange(
|
||||||
r.Context(),
|
|
||||||
w,
|
w,
|
||||||
|
r,
|
||||||
sessionUsername,
|
sessionUsername,
|
||||||
// PostFormValue, not FormValue: the credential must
|
// PostFormValue, not FormValue: the credential must
|
||||||
// come from the body, never from the query string.
|
// come from the body, never from the query string.
|
||||||
@@ -66,12 +65,12 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
|
|||||||
// applyPasswordChange verifies the current password and, on success,
|
// applyPasswordChange verifies the current password and, on success,
|
||||||
// persists a fresh hash for the user, reusing the same helpers that
|
// persists a fresh hash for the user, reusing the same helpers that
|
||||||
// bootstrap the admin user. It returns the success and error messages
|
// bootstrap the admin user. It returns the success and error messages
|
||||||
// to display on the profile page. On an internal failure it writes a
|
// to display on the profile page. On an internal failure it writes the
|
||||||
// 500 response itself and returns handled=false, signalling the caller
|
// error page itself and returns handled=false, signalling the caller
|
||||||
// to stop without re-rendering the page.
|
// to stop without re-rendering the page.
|
||||||
func (h *Handlers) applyPasswordChange(
|
func (h *Handlers) applyPasswordChange(
|
||||||
ctx context.Context,
|
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
username, currentPassword, newPassword, confirmPassword string,
|
username, currentPassword, newPassword, confirmPassword string,
|
||||||
) (string, string, bool) {
|
) (string, string, bool) {
|
||||||
// This endpoint verifies one password and hashes another, at
|
// This endpoint verifies one password and hashes another, at
|
||||||
@@ -79,15 +78,10 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
// endpoint uses. The bound is per hash, not per endpoint: leaving
|
// endpoint uses. The bound is per hash, not per endpoint: leaving
|
||||||
// this path outside it would leave a hole in it. The slot is held
|
// this path outside it would leave a hole in it. The slot is held
|
||||||
// across both hashes.
|
// across both hashes.
|
||||||
release, ok := h.mw.BeginPasswordVerification(ctx)
|
release, ok := h.mw.BeginPasswordVerification(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
h.log.Warn("password verification capacity exhausted")
|
h.log.Warn("password verification capacity exhausted")
|
||||||
http.Error(
|
h.renderError(w, r, http.StatusServiceUnavailable)
|
||||||
w,
|
|
||||||
"The server is busy verifying credentials. "+
|
|
||||||
"Please try again.",
|
|
||||||
http.StatusServiceUnavailable,
|
|
||||||
)
|
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -103,7 +97,7 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
).First(&user).Error
|
).First(&user).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, "failed to load user for password change", err,
|
w, r, "failed to load user for password change", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
@@ -113,7 +107,7 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
currentPassword, user.Password,
|
currentPassword, user.Password,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to verify password", err)
|
h.serverError(w, r, "failed to verify password", err)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -132,7 +126,7 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
|
|
||||||
hashedPassword, err := database.HashPassword(newPassword)
|
hashedPassword, err := database.HashPassword(newPassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to hash new password", err)
|
h.serverError(w, r, "failed to hash new password", err)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -141,7 +135,7 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
"password", hashedPassword,
|
"password", hashedPassword,
|
||||||
).Error
|
).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to update password", err)
|
h.serverError(w, r, "failed to update password", err)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -162,7 +156,7 @@ func (h *Handlers) profileOwnerOrDeny(
|
|||||||
) (string, string, bool) {
|
) (string, string, bool) {
|
||||||
requestedUsername := chi.URLParam(r, "username")
|
requestedUsername := chi.URLParam(r, "username")
|
||||||
if requestedUsername == "" {
|
if requestedUsername == "" {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -172,7 +166,7 @@ func (h *Handlers) profileOwnerOrDeny(
|
|||||||
// unexpected retrieval error.
|
// unexpected retrieval error.
|
||||||
sess, err := h.session.Get(r)
|
sess, err := h.session.Get(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to get session", err)
|
h.serverError(w, r, "failed to get session", err)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -180,10 +174,7 @@ func (h *Handlers) profileOwnerOrDeny(
|
|||||||
sessionUsername, ok := h.session.GetUsername(sess)
|
sessionUsername, ok := h.session.GetUsername(sess)
|
||||||
if !ok {
|
if !ok {
|
||||||
h.log.Error("authenticated session missing username")
|
h.log.Error("authenticated session missing username")
|
||||||
http.Error(
|
h.renderError(w, r, http.StatusInternalServerError)
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -191,17 +182,14 @@ func (h *Handlers) profileOwnerOrDeny(
|
|||||||
sessionUserID, ok := h.session.GetUserID(sess)
|
sessionUserID, ok := h.session.GetUserID(sess)
|
||||||
if !ok {
|
if !ok {
|
||||||
h.log.Error("authenticated session missing user ID")
|
h.log.Error("authenticated session missing user ID")
|
||||||
http.Error(
|
h.renderError(w, r, http.StatusInternalServerError)
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only allow users to act on their own profile.
|
// Only allow users to act on their own profile.
|
||||||
if requestedUsername != sessionUsername {
|
if requestedUsername != sessionUsername {
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
h.renderError(w, r, http.StatusForbidden)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,9 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
|
|||||||
|
|
||||||
var sess *session.Session
|
var sess *session.Session
|
||||||
|
|
||||||
app := newTestApp(t, &log, &cfg, &sess)
|
var h *handlers.Handlers
|
||||||
|
|
||||||
|
app := newTestApp(t, &log, &cfg, &sess, &h)
|
||||||
app.RequireStart()
|
app.RequireStart()
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
t.Cleanup(app.RequireStop)
|
||||||
@@ -139,7 +141,7 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
|
|||||||
|
|
||||||
router := chi.NewRouter()
|
router := chi.NewRouter()
|
||||||
router.Route("/user/{username}", func(r chi.Router) {
|
router.Route("/user/{username}", func(r chi.Router) {
|
||||||
r.Use(mw.CSRF())
|
r.Use(mw.CSRF(h.HandleErrorPage(http.StatusForbidden)))
|
||||||
r.Use(mw.RequireAuth())
|
r.Use(mw.RequireAuth())
|
||||||
r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
|
r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
handlerReached = true
|
handlerReached = true
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
)
|
)
|
||||||
@@ -147,18 +148,19 @@ func failDeleteOnTable(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// archivePathFor returns the archive database path the
|
// archivePathFor returns the archive database path the
|
||||||
// delivery engine would use for a webhook: beside the webhook's
|
// delivery engine would use for a database target: beside the
|
||||||
// event database in the data directory.
|
// webhook's event database in the data directory.
|
||||||
func archivePathFor(
|
func archivePathFor(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
mgr *database.WebhookDBManager,
|
mgr *database.WebhookDBManager,
|
||||||
webhookID string,
|
wh *database.Webhook,
|
||||||
|
tgt *database.Target,
|
||||||
) string {
|
) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return filepath.Join(
|
return filepath.Join(
|
||||||
filepath.Dir(mgr.DBPath(webhookID)),
|
filepath.Dir(mgr.DBPath(wh.ID)),
|
||||||
"archive-"+webhookID+".db",
|
delivery.ArchiveFileName(wh.Name, tgt.Name, tgt.ID),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,8 +197,8 @@ func postRequest(
|
|||||||
|
|
||||||
// TestHandleSourceDelete_EvictsArchiveWriter proves that
|
// TestHandleSourceDelete_EvictsArchiveWriter proves that
|
||||||
// deleting a webhook reaches the delivery engine and releases
|
// deleting a webhook reaches the delivery engine and releases
|
||||||
// the webhook's archive writer, exercised through the real
|
// the webhook's archive writers, exercised through the real
|
||||||
// deletion handler rather than by calling the evictor directly.
|
// deletion handler rather than by calling the engine directly.
|
||||||
func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) {
|
func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -204,7 +206,7 @@ func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) {
|
|||||||
h *handlers.Handlers
|
h *handlers.Handlers
|
||||||
sess *session.Session
|
sess *session.Session
|
||||||
db *database.Database
|
db *database.Database
|
||||||
ev *recordingEvictor
|
ev *recordingArchives
|
||||||
)
|
)
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db, &ev)
|
app := newTestApp(t, &h, &sess, &db, &ev)
|
||||||
@@ -254,9 +256,10 @@ func TestHandleSourceDelete_KeepsArchiveFile(t *testing.T) {
|
|||||||
t.Cleanup(app.RequireStop)
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
wh := seedWebhook(t, db)
|
wh := seedWebhook(t, db)
|
||||||
|
tgt := seedTarget(t, db, wh.ID, database.TargetTypeDatabase)
|
||||||
|
|
||||||
// Place an archive file where the delivery engine would.
|
// Place an archive file where the delivery engine would.
|
||||||
archivePath := archivePathFor(t, mgr, wh.ID)
|
archivePath := archivePathFor(t, mgr, wh, tgt)
|
||||||
require.NoError(
|
require.NoError(
|
||||||
t,
|
t,
|
||||||
writeArchivePlaceholder(archivePath),
|
writeArchivePlaceholder(archivePath),
|
||||||
@@ -435,68 +438,17 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleTargetDelete_EvictsWhenLastDatabaseTargetGone
|
// TestHandleTargetDelete_EvictsThatTarget proves that deleting a
|
||||||
// proves that removing the last database target releases the
|
// database target releases that target's archive writer and no
|
||||||
// archive writer.
|
// other: the webhook's other database target keeps its own.
|
||||||
func TestHandleTargetDelete_EvictsWhenLastDatabaseTargetGone(
|
func TestHandleTargetDelete_EvictsThatTarget(t *testing.T) {
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
h *handlers.Handlers
|
h *handlers.Handlers
|
||||||
sess *session.Session
|
sess *session.Session
|
||||||
db *database.Database
|
db *database.Database
|
||||||
ev *recordingEvictor
|
ev *recordingArchives
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db, &ev)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
wh := seedWebhook(t, db)
|
|
||||||
tgt := seedTarget(
|
|
||||||
t, db, wh.ID, database.TargetTypeDatabase,
|
|
||||||
)
|
|
||||||
|
|
||||||
cookies := authenticatedCookies(
|
|
||||||
t, sess, deleteTestUserID, deleteTestUsername,
|
|
||||||
)
|
|
||||||
|
|
||||||
req := postRequest(
|
|
||||||
"/hook/"+wh.ID+"/targets/"+tgt.ID+"/delete",
|
|
||||||
cookies,
|
|
||||||
map[string]string{
|
|
||||||
paramSourceID: wh.ID,
|
|
||||||
paramTargetID: tgt.ID,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.HandleTargetDelete().ServeHTTP(w, req)
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
||||||
assert.Equal(
|
|
||||||
t, []string{wh.ID}, ev.Evicted(),
|
|
||||||
"removing the last database target should evict",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleTargetDelete_KeepsWriterWhenDatabaseTargetRemains
|
|
||||||
// proves that deleting one of several database targets leaves
|
|
||||||
// the still-needed archive writer alone: the surviving target
|
|
||||||
// keeps archiving to the same file, so the writer must stay.
|
|
||||||
func TestHandleTargetDelete_KeepsWriterWhenDatabaseTargetRemains(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
ev *recordingEvictor
|
|
||||||
)
|
)
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db, &ev)
|
app := newTestApp(t, &h, &sess, &db, &ev)
|
||||||
@@ -527,17 +479,17 @@ func TestHandleTargetDelete_KeepsWriterWhenDatabaseTargetRemains(
|
|||||||
h.HandleTargetDelete().ServeHTTP(w, req)
|
h.HandleTargetDelete().ServeHTTP(w, req)
|
||||||
|
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Empty(
|
assert.Equal(
|
||||||
t, ev.Evicted(),
|
t, []string{doomed.ID}, ev.EvictedTargets(),
|
||||||
"a second database target still needs the writer",
|
"deleting a database target should evict its writer",
|
||||||
)
|
)
|
||||||
|
assert.Empty(t, ev.Evicted(), "the webhook is not deleted")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleTargetDelete_KeepsWriterWhenOtherTypeDeleted proves
|
// TestHandleTargetDelete_IgnoresAnotherWebhooksTarget proves that
|
||||||
// that deleting a target of an unrelated type leaves a
|
// a target id from the URL that is not a target of the webhook
|
||||||
// still-needed archive writer alone: the webhook's database
|
// deletes nothing and so evicts nothing.
|
||||||
// target is untouched, so its writer must stay.
|
func TestHandleTargetDelete_IgnoresAnotherWebhooksTarget(
|
||||||
func TestHandleTargetDelete_KeepsWriterWhenOtherTypeDeleted(
|
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -546,7 +498,7 @@ func TestHandleTargetDelete_KeepsWriterWhenOtherTypeDeleted(
|
|||||||
h *handlers.Handlers
|
h *handlers.Handlers
|
||||||
sess *session.Session
|
sess *session.Session
|
||||||
db *database.Database
|
db *database.Database
|
||||||
ev *recordingEvictor
|
ev *recordingArchives
|
||||||
)
|
)
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db, &ev)
|
app := newTestApp(t, &h, &sess, &db, &ev)
|
||||||
@@ -555,19 +507,20 @@ func TestHandleTargetDelete_KeepsWriterWhenOtherTypeDeleted(
|
|||||||
t.Cleanup(app.RequireStop)
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
wh := seedWebhook(t, db)
|
wh := seedWebhook(t, db)
|
||||||
seedTarget(t, db, wh.ID, database.TargetTypeDatabase)
|
elsewhere := seedTarget(
|
||||||
other := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
t, db, seedWebhook(t, db).ID, database.TargetTypeDatabase,
|
||||||
|
)
|
||||||
|
|
||||||
cookies := authenticatedCookies(
|
cookies := authenticatedCookies(
|
||||||
t, sess, deleteTestUserID, deleteTestUsername,
|
t, sess, deleteTestUserID, deleteTestUsername,
|
||||||
)
|
)
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/hook/"+wh.ID+"/targets/"+other.ID+"/delete",
|
"/hook/"+wh.ID+"/targets/"+elsewhere.ID+"/delete",
|
||||||
cookies,
|
cookies,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
paramSourceID: wh.ID,
|
paramSourceID: wh.ID,
|
||||||
paramTargetID: other.ID,
|
paramTargetID: elsewhere.ID,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -576,7 +529,7 @@ func TestHandleTargetDelete_KeepsWriterWhenOtherTypeDeleted(
|
|||||||
|
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Empty(
|
assert.Empty(
|
||||||
t, ev.Evicted(),
|
t, ev.EvictedTargets(),
|
||||||
"a surviving database target must keep its writer",
|
"another webhook's target must not be evicted",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,13 +149,7 @@ func (h *Handlers) HandleSourceList() http.HandlerFunc {
|
|||||||
"user_id = ?", userID,
|
"user_id = ?", userID,
|
||||||
).Order("created_at DESC").Find(&webhooks).Error
|
).Order("created_at DESC").Find(&webhooks).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error(
|
h.serverError(w, r, "failed to list webhooks", err)
|
||||||
"failed to list webhooks", "error", err,
|
|
||||||
)
|
|
||||||
http.Error(
|
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -249,9 +243,7 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(
|
h.renderError(w, r, http.StatusBadRequest)
|
||||||
w, "Bad request", http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -311,7 +303,7 @@ func (h *Handlers) createWebhookWithEntrypoint(
|
|||||||
|
|
||||||
err := h.commitWebhook(webhook)
|
err := h.commitWebhook(webhook)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to create webhook", err)
|
h.serverError(w, r, "failed to create webhook", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -388,7 +380,7 @@ func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -420,7 +412,7 @@ func (h *Handlers) renderSourceDetail(
|
|||||||
if h.dbMgr.DBExists(webhook.ID) {
|
if h.dbMgr.DBExists(webhook.ID) {
|
||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to get webhook database", err)
|
h.serverError(w, r, "failed to get webhook database", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -429,7 +421,7 @@ func (h *Handlers) renderSourceDetail(
|
|||||||
webhookDB, webhook.ID, singleHTTPTargetID(targets),
|
webhookDB, webhook.ID, singleHTTPTargetID(targets),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to load recent events", err)
|
h.serverError(w, r, "failed to load recent events", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -483,7 +475,7 @@ func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -518,7 +510,7 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -527,9 +519,7 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err = r.ParseForm()
|
err = r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(
|
h.renderError(w, r, http.StatusBadRequest)
|
||||||
w, "Bad request", http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -559,6 +549,7 @@ func (h *Handlers) applyWebhookEdit(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oldName := webhook.Name
|
||||||
webhook.Name = name
|
webhook.Name = name
|
||||||
webhook.Description = r.PostFormValue("description")
|
webhook.Description = r.PostFormValue("description")
|
||||||
|
|
||||||
@@ -581,9 +572,41 @@ func (h *Handlers) applyWebhookEdit(
|
|||||||
|
|
||||||
webhook.RetentionDays = retentionDays
|
webhook.RetentionDays = retentionDays
|
||||||
|
|
||||||
err := h.db.DB().Save(webhook).Error
|
// A new name renames the archive files before it is saved (see
|
||||||
|
// delivery.Engine.Rename). If either step fails, they go back to
|
||||||
|
// the name that is still stored.
|
||||||
|
err := h.renameWebhookArchives(webhook.ID, oldName, webhook.Name)
|
||||||
|
if err == nil {
|
||||||
|
err = h.db.DB().Save(webhook).Error
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to update webhook", err)
|
restoreErr := h.renameWebhookArchives(
|
||||||
|
webhook.ID, webhook.Name, oldName,
|
||||||
|
)
|
||||||
|
if restoreErr != nil {
|
||||||
|
h.log.Error(
|
||||||
|
"failed to rename archives back",
|
||||||
|
"webhook_id", webhook.ID,
|
||||||
|
"error", restoreErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, delivery.ErrArchiveNameTaken) {
|
||||||
|
data := map[string]any{
|
||||||
|
tmplKeyWebhook: webhook,
|
||||||
|
tmplKeyError: "Not saved: " + err.Error() +
|
||||||
|
". Move that file out of the data directory, " +
|
||||||
|
"then save again.",
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusConflict)
|
||||||
|
h.renderTemplate(w, r, "source_edit.html", data)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.serverError(w, r, "failed to update webhook", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -613,7 +636,7 @@ func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -640,7 +663,7 @@ func (h *Handlers) deleteWebhookResources(
|
|||||||
// be removed by hand; deleted history cannot be recovered.
|
// be removed by hand; deleted history cannot be recovered.
|
||||||
err := h.commitWebhookDeletion(&webhook)
|
err := h.commitWebhookDeletion(&webhook)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to delete webhook", err)
|
h.serverError(w, r, "failed to delete webhook", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -666,7 +689,7 @@ func (h *Handlers) deleteWebhookResources(
|
|||||||
// redirecting as though everything succeeded: the file
|
// redirecting as though everything succeeded: the file
|
||||||
// needs removing by hand, and the logged error names it.
|
// needs removing by hand, and the logged error names it.
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, "failed to delete webhook event database", err,
|
w, r, "failed to delete webhook event database", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -717,11 +740,11 @@ func (h *Handlers) commitWebhookDeletion(
|
|||||||
return tx.Commit().Error
|
return tx.Commit().Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// evictArchiveWriter asks the delivery engine to drop its
|
// evictArchiveWriter asks the delivery engine to drop the cached
|
||||||
// cached archive writer for a webhook, closing the archive file
|
// archive writers of a webhook's database targets, closing their
|
||||||
// handle.
|
// archive file handles.
|
||||||
//
|
//
|
||||||
// The archive database file is NOT deleted. Unlike the event
|
// The archive database files are NOT deleted. Unlike the event
|
||||||
// database — which is per-webhook working storage and is
|
// database — which is per-webhook working storage and is
|
||||||
// hard-deleted with the webhook — an archive is explicitly
|
// hard-deleted with the webhook — an archive is explicitly
|
||||||
// long-term storage that an operator may want to keep or move
|
// long-term storage that an operator may want to keep or move
|
||||||
@@ -729,50 +752,58 @@ func (h *Handlers) commitWebhookDeletion(
|
|||||||
// deleting a webhook would be a surprising and unrecoverable
|
// deleting a webhook would be a surprising and unrecoverable
|
||||||
// data loss, so the file is left for the operator to handle.
|
// data loss, so the file is left for the operator to handle.
|
||||||
func (h *Handlers) evictArchiveWriter(webhookID string) {
|
func (h *Handlers) evictArchiveWriter(webhookID string) {
|
||||||
if h.evictor == nil {
|
if h.archives == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.evictor.EvictWebhook(webhookID)
|
h.archives.EvictWebhook(webhookID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// evictArchiveWriterIfUnused releases a webhook's archive
|
// evictTargetArchiveWriter is evictArchiveWriter for one deleted
|
||||||
// writer once the webhook has no database target left to feed
|
// target, and leaves its archive file on disk for the same reason.
|
||||||
// it.
|
// A target that is not a database target has no writer, and
|
||||||
//
|
// evicting it does nothing.
|
||||||
// It is called after any child resource of a webhook is
|
func (h *Handlers) evictTargetArchiveWriter(targetID string) {
|
||||||
// deleted, and is correct without knowing which kind was: it
|
if h.archives == nil {
|
||||||
// evicts only when no database target remains, so deleting one
|
return
|
||||||
// of several database targets — or deleting an unrelated
|
}
|
||||||
// target type — leaves a still-needed writer alone. When no
|
|
||||||
// database target ever existed there is no writer and eviction
|
h.archives.EvictTarget(targetID)
|
||||||
// is a no-op. Soft-deleted targets are excluded by GORM's
|
}
|
||||||
// default scope, so the row just deleted is not counted.
|
|
||||||
func (h *Handlers) evictArchiveWriterIfUnused(webhookID string) {
|
// renameWebhookArchives renames the archive file of every database
|
||||||
var remaining int64
|
// target of a webhook from the webhook name oldName to newName,
|
||||||
|
// keeping each target's own name. It does nothing when the name is
|
||||||
|
// unchanged, and stops at the first failure.
|
||||||
|
func (h *Handlers) renameWebhookArchives(
|
||||||
|
webhookID, oldName, newName string,
|
||||||
|
) error {
|
||||||
|
if h.archives == nil || oldName == newName {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var targets []database.Target
|
||||||
|
|
||||||
err := h.db.DB().
|
err := h.db.DB().
|
||||||
Model(&database.Target{}).
|
|
||||||
Where(
|
Where(
|
||||||
"webhook_id = ? AND type = ?",
|
"webhook_id = ? AND type = ?",
|
||||||
webhookID, database.TargetTypeDatabase,
|
webhookID, database.TargetTypeDatabase,
|
||||||
).
|
).
|
||||||
Count(&remaining).Error
|
Find(&targets).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error(
|
return err
|
||||||
"failed to count remaining database targets",
|
}
|
||||||
"webhook_id", webhookID,
|
|
||||||
"error", err,
|
for i := range targets {
|
||||||
|
err = h.archives.Rename(
|
||||||
|
targets[i].ID, newName, targets[i].Name,
|
||||||
)
|
)
|
||||||
|
if err != nil {
|
||||||
return
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if remaining > 0 {
|
return nil
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
h.evictArchiveWriter(webhookID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ownedWebhook resolves the request's sourceID parameter to a
|
// ownedWebhook resolves the request's sourceID parameter to a
|
||||||
@@ -810,7 +841,7 @@ func (h *Handlers) ownedWebhook(
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return database.Webhook{}, false
|
return database.Webhook{}, false
|
||||||
}
|
}
|
||||||
@@ -832,7 +863,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
|||||||
// Without the map every delivery renders through a
|
// Without the map every delivery renders through a
|
||||||
// zero redactor, so failing the page is the only
|
// zero redactor, so failing the page is the only
|
||||||
// safe answer.
|
// safe answer.
|
||||||
h.serverError(w, "failed to load targets", err)
|
h.serverError(w, r, "failed to load targets", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -840,7 +871,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
|||||||
page := h.parsePage(r)
|
page := h.parsePage(r)
|
||||||
|
|
||||||
evts, total, ok := h.loadEventsWithDeliveries(
|
evts, total, ok := h.loadEventsWithDeliveries(
|
||||||
w, webhook, targets, page,
|
w, r, webhook, targets, page,
|
||||||
)
|
)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -950,6 +981,7 @@ func (h *Handlers) parsePage(r *http.Request) int {
|
|||||||
// caller must then render nothing further.
|
// caller must then render nothing further.
|
||||||
func (h *Handlers) loadEventsWithDeliveries(
|
func (h *Handlers) loadEventsWithDeliveries(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
targetMap map[string]eventLogTarget,
|
targetMap map[string]eventLogTarget,
|
||||||
page int,
|
page int,
|
||||||
@@ -963,7 +995,7 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, "failed to get webhook database", err,
|
w, r, "failed to get webhook database", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil, 0, false
|
return nil, 0, false
|
||||||
@@ -1000,7 +1032,7 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, "failed to load delivery attempts", err,
|
w, r, "failed to load delivery attempts", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil, 0, false
|
return nil, 0, false
|
||||||
@@ -1009,7 +1041,7 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
resubmits, err := resubmitCounts(webhookDB, eventIDs)
|
resubmits, err := resubmitCounts(webhookDB, eventIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, "failed to count event resubmissions", err,
|
w, r, "failed to count event resubmissions", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil, 0, false
|
return nil, 0, false
|
||||||
@@ -1232,7 +1264,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1241,9 +1273,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err = r.ParseForm()
|
err = r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(
|
h.renderError(w, r, http.StatusBadRequest)
|
||||||
w, "Bad request", http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1259,7 +1289,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
|||||||
|
|
||||||
err = h.db.DB().Create(entrypoint).Error
|
err = h.db.DB().Create(entrypoint).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to create entrypoint", err)
|
h.serverError(w, r, "failed to create entrypoint", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1290,7 +1320,7 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1299,9 +1329,7 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err = r.ParseForm()
|
err = r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(
|
h.renderError(w, r, http.StatusBadRequest)
|
||||||
w, "Bad request", http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1372,7 +1400,7 @@ func (h *Handlers) processTargetCreate(
|
|||||||
|
|
||||||
err = h.db.DB().Create(target).Error
|
err = h.db.DB().Create(target).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to create target", err)
|
h.serverError(w, r, "failed to create target", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1466,7 +1494,7 @@ func (h *Handlers) buildTargetConfig(
|
|||||||
case database.TargetTypeSlack:
|
case database.TargetTypeSlack:
|
||||||
return h.buildSlackTargetConfig(w, r, in.URL)
|
return h.buildSlackTargetConfig(w, r, in.URL)
|
||||||
case database.TargetTypeDatabase:
|
case database.TargetTypeDatabase:
|
||||||
return h.buildDatabaseTargetConfig(w, in.Expiry)
|
return h.buildDatabaseTargetConfig(w, r, in.Expiry)
|
||||||
case database.TargetTypeLog:
|
case database.TargetTypeLog:
|
||||||
return "", nil
|
return "", nil
|
||||||
default:
|
default:
|
||||||
@@ -1516,7 +1544,7 @@ func (h *Handlers) buildHTTPTargetConfig(
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return marshalTargetConfig(w, delivery.HTTPTargetConfig{
|
return h.marshalTargetConfig(w, r, delivery.HTTPTargetConfig{
|
||||||
URL: in.URL,
|
URL: in.URL,
|
||||||
Headers: headers,
|
Headers: headers,
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
@@ -1538,7 +1566,7 @@ func (h *Handlers) buildSlackTargetConfig(
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return marshalTargetConfig(w, delivery.SlackTargetConfig{
|
return h.marshalTargetConfig(w, r, delivery.SlackTargetConfig{
|
||||||
WebhookURL: targetURL,
|
WebhookURL: targetURL,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1578,11 +1606,22 @@ func (h *Handlers) validateTargetURL(
|
|||||||
"url", delivery.MaskURL(targetURL),
|
"url", delivery.MaskURL(targetURL),
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
http.Error(
|
|
||||||
w,
|
msg := "Invalid target URL: " + err.Error()
|
||||||
"Invalid target URL: "+err.Error(),
|
|
||||||
http.StatusBadRequest,
|
// Only a private or reserved address's refusal says how
|
||||||
)
|
// to allow it. Metadata refusals never do: link-local and
|
||||||
|
// the other unconditional metadata addresses cannot be
|
||||||
|
// opened, and the default blocklist's public addresses,
|
||||||
|
// which listing does open, hand out credentials.
|
||||||
|
if errors.Is(err, delivery.ErrBlockedPrivateOrReservedIP) {
|
||||||
|
msg += ". Private and reserved addresses are refused " +
|
||||||
|
"by default; the server's ALLOWED_EGRESS_CIDRS " +
|
||||||
|
"setting allows named networks (see \"Allowing " +
|
||||||
|
"egress to your own network\" in the README)."
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(w, msg, http.StatusBadRequest)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1592,16 +1631,14 @@ func (h *Handlers) validateTargetURL(
|
|||||||
|
|
||||||
// marshalTargetConfig serialises a target configuration for storage,
|
// marshalTargetConfig serialises a target configuration for storage,
|
||||||
// writing a 500 itself if it cannot.
|
// writing a 500 itself if it cannot.
|
||||||
func marshalTargetConfig(
|
func (h *Handlers) marshalTargetConfig(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
cfg any,
|
cfg any,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
configBytes, err := json.Marshal(cfg)
|
configBytes, err := json.Marshal(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(
|
h.serverError(w, r, "failed to encode target config", err)
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -1617,6 +1654,7 @@ func marshalTargetConfig(
|
|||||||
// expiry yields an empty config (the keep-forever default).
|
// expiry yields an empty config (the keep-forever default).
|
||||||
func (h *Handlers) buildDatabaseTargetConfig(
|
func (h *Handlers) buildDatabaseTargetConfig(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
expiry string,
|
expiry string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
expiry = strings.TrimSpace(expiry)
|
expiry = strings.TrimSpace(expiry)
|
||||||
@@ -1635,8 +1673,8 @@ func (h *Handlers) buildDatabaseTargetConfig(
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return marshalTargetConfig(
|
return h.marshalTargetConfig(
|
||||||
w, map[string]any{"expiry": expiry},
|
w, r, map[string]any{"expiry": expiry},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1649,27 +1687,26 @@ func (h *Handlers) HandleEntrypointDelete() http.HandlerFunc {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleTargetDelete handles deleting a target. Deleting the
|
// HandleTargetDelete handles deleting a target. A deleted
|
||||||
// last database target of a webhook leaves its archive writer
|
// database target's archive writer is evicted and its handle
|
||||||
// with nothing to write, so the writer is evicted and its
|
// closed; the archive file is left on disk.
|
||||||
// handle closed; the archive file is left on disk.
|
|
||||||
func (h *Handlers) HandleTargetDelete() http.HandlerFunc {
|
func (h *Handlers) HandleTargetDelete() http.HandlerFunc {
|
||||||
return h.deleteChildResource(
|
return h.deleteChildResource(
|
||||||
"targetID", &database.Target{},
|
"targetID", &database.Target{},
|
||||||
"failed to delete target",
|
"failed to delete target",
|
||||||
h.evictArchiveWriterIfUnused,
|
h.evictTargetArchiveWriter,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteChildResource returns a handler that deletes a child
|
// deleteChildResource returns a handler that deletes a child
|
||||||
// resource (entrypoint or target) belonging to a webhook. The
|
// resource (entrypoint or target) belonging to a webhook. The
|
||||||
// optional afterDelete hook runs with the webhook's id once the
|
// optional afterDelete hook runs with the child's id once the
|
||||||
// delete has succeeded, before the redirect.
|
// delete has removed it, before the redirect.
|
||||||
func (h *Handlers) deleteChildResource(
|
func (h *Handlers) deleteChildResource(
|
||||||
idParam string,
|
idParam string,
|
||||||
model any,
|
model any,
|
||||||
errMsg string,
|
errMsg string,
|
||||||
afterDelete func(webhookID string),
|
afterDelete func(childID string),
|
||||||
) http.HandlerFunc {
|
) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, ok := h.getUserID(r)
|
userID, ok := h.getUserID(r)
|
||||||
@@ -1690,7 +1727,7 @@ func (h *Handlers) deleteChildResource(
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1700,17 +1737,15 @@ func (h *Handlers) deleteChildResource(
|
|||||||
childID, webhook.ID,
|
childID, webhook.ID,
|
||||||
).Delete(model)
|
).Delete(model)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
h.log.Error(errMsg, "error", result.Error)
|
h.serverError(w, r, errMsg, result.Error)
|
||||||
http.Error(
|
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if afterDelete != nil {
|
// Only for a row this webhook really had: the id came from
|
||||||
afterDelete(webhook.ID)
|
// the URL and may name another webhook's child.
|
||||||
|
if afterDelete != nil && result.RowsAffected > 0 {
|
||||||
|
afterDelete(childID)
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
@@ -1794,18 +1829,14 @@ func (h *Handlers) toggleChildResource(
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = toggleFn(webhook.ID, childID)
|
err = toggleFn(webhook.ID, childID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error(errMsg, "error", err)
|
h.serverError(w, r, errMsg, err)
|
||||||
http.Error(
|
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ func storedRetentionDays(
|
|||||||
type sourceTestEnv struct {
|
type sourceTestEnv struct {
|
||||||
handlers *handlers.Handlers
|
handlers *handlers.Handlers
|
||||||
db *database.Database
|
db *database.Database
|
||||||
|
archives *recordingArchives
|
||||||
cookies []*http.Cookie
|
cookies []*http.Cookie
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +200,9 @@ func setupSourceTest(t *testing.T) *sourceTestEnv {
|
|||||||
|
|
||||||
var db *database.Database
|
var db *database.Database
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db)
|
var archives *recordingArchives
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &archives)
|
||||||
app.RequireStart()
|
app.RequireStart()
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
t.Cleanup(app.RequireStop)
|
||||||
@@ -207,6 +210,7 @@ func setupSourceTest(t *testing.T) *sourceTestEnv {
|
|||||||
return &sourceTestEnv{
|
return &sourceTestEnv{
|
||||||
handlers: h,
|
handlers: h,
|
||||||
db: db,
|
db: db,
|
||||||
|
archives: archives,
|
||||||
cookies: authenticatedCookies(
|
cookies: authenticatedCookies(
|
||||||
t, sess, sourceTestUserID, "sourceuser",
|
t, sess, sourceTestUserID, "sourceuser",
|
||||||
),
|
),
|
||||||
@@ -498,6 +502,106 @@ func TestHandleSourceEditSubmit_EmptyRetentionLeavesValueUnchanged(
|
|||||||
assert.Equal(t, 7, storedRetentionDays(t, env.db, wh.ID))
|
assert.Equal(t, 7, storedRetentionDays(t, env.db, wh.ID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renamedWebhookName is the name the rename tests give a webhook.
|
||||||
|
const renamedWebhookName = "Renamed"
|
||||||
|
|
||||||
|
// TestHandleSourceEditSubmit_RenamesArchives proves that a save
|
||||||
|
// that keeps the webhook's name renames nothing, and that renaming a
|
||||||
|
// webhook renames the archive of each of its database targets and
|
||||||
|
// asks nothing of its other targets.
|
||||||
|
func TestHandleSourceEditSubmit_RenamesArchives(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
wh := seedWebhookWithRetention(t, env.db, 7)
|
||||||
|
first := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
||||||
|
second := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
||||||
|
seedTarget(t, env.db, wh.ID, database.TargetTypeLog)
|
||||||
|
|
||||||
|
w := submitEdit(t, env, wh, "")
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
|
assert.Empty(t, env.archives.Renames())
|
||||||
|
|
||||||
|
wh.Name = renamedWebhookName
|
||||||
|
|
||||||
|
w = submitEdit(t, env, wh, "")
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
|
|
||||||
|
assert.ElementsMatch(
|
||||||
|
t,
|
||||||
|
[]archiveRename{
|
||||||
|
{first.ID, renamedWebhookName, first.Name},
|
||||||
|
{second.ID, renamedWebhookName, second.Name},
|
||||||
|
},
|
||||||
|
env.archives.Renames(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceEditSubmit_FailedRenameKeepsTheName proves that a
|
||||||
|
// webhook whose archive cannot be renamed keeps its stored name, so
|
||||||
|
// the name on disk and the name in the UI do not part, and that the
|
||||||
|
// handler puts back what it may already have moved.
|
||||||
|
func TestHandleSourceEditSubmit_FailedRenameKeepsTheName(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
wh := seedWebhookWithRetention(t, env.db, 7)
|
||||||
|
tgt := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
||||||
|
|
||||||
|
env.archives.FailRenames(errInjectedRename)
|
||||||
|
|
||||||
|
oldName := wh.Name
|
||||||
|
wh.Name = renamedWebhookName
|
||||||
|
|
||||||
|
w := submitEdit(t, env, wh, "")
|
||||||
|
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||||
|
|
||||||
|
var stored database.Webhook
|
||||||
|
|
||||||
|
require.NoError(
|
||||||
|
t, env.db.DB().First(&stored, "id = ?", wh.ID).Error,
|
||||||
|
)
|
||||||
|
assert.Equal(t, oldName, stored.Name)
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
[]archiveRename{
|
||||||
|
{tgt.ID, renamedWebhookName, tgt.Name},
|
||||||
|
{tgt.ID, oldName, tgt.Name},
|
||||||
|
},
|
||||||
|
env.archives.Renames(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceEditSubmit_ArchiveNameTaken proves that when a file
|
||||||
|
// already has an archive's new name, the edit is refused with an
|
||||||
|
// error naming that file, and the webhook keeps its stored name.
|
||||||
|
func TestHandleSourceEditSubmit_ArchiveNameTaken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
wh := seedWebhookWithRetention(t, env.db, 7)
|
||||||
|
seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
||||||
|
|
||||||
|
env.archives.FailRenames(errNameTaken)
|
||||||
|
|
||||||
|
oldName := wh.Name
|
||||||
|
wh.Name = renamedWebhookName
|
||||||
|
|
||||||
|
w := submitEdit(t, env, wh, "")
|
||||||
|
require.Equal(t, http.StatusConflict, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "archive-taken.db")
|
||||||
|
|
||||||
|
var stored database.Webhook
|
||||||
|
|
||||||
|
require.NoError(
|
||||||
|
t, env.db.DB().First(&stored, "id = ?", wh.ID).Error,
|
||||||
|
)
|
||||||
|
assert.Equal(t, oldName, stored.Name)
|
||||||
|
}
|
||||||
|
|
||||||
// TestSourceEditForm_ForeverWebhookRoundTrips walks the exact path that
|
// TestSourceEditForm_ForeverWebhookRoundTrips walks the exact path that
|
||||||
// the removed max="365" cap used to break: render the edit form for a
|
// the removed max="365" cap used to break: render the edit form for a
|
||||||
// retain-forever webhook, confirm the pre-filled sentinel is not capped
|
// retain-forever webhook, confirm the pre-filled sentinel is not capped
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
@@ -88,9 +89,7 @@ func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(
|
h.renderError(w, r, http.StatusBadRequest)
|
||||||
w, "Bad request", http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -152,12 +151,43 @@ func (h *Handlers) applyTargetEdit(
|
|||||||
target.MaxRetries = retries
|
target.MaxRetries = retries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oldName := target.Name
|
||||||
target.Name = name
|
target.Name = name
|
||||||
target.Config = configJSON
|
target.Config = configJSON
|
||||||
|
|
||||||
err = h.db.DB().Save(target).Error
|
// A new name renames the archive file before it is saved (see
|
||||||
|
// delivery.Engine.Rename). If either step fails, it goes back to
|
||||||
|
// the name that is still stored.
|
||||||
|
err = h.renameTargetArchive(target, webhook.Name, oldName, name)
|
||||||
|
if err == nil {
|
||||||
|
err = h.db.DB().Save(target).Error
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to update target", err)
|
restoreErr := h.renameTargetArchive(
|
||||||
|
target, webhook.Name, name, oldName,
|
||||||
|
)
|
||||||
|
if restoreErr != nil {
|
||||||
|
h.log.Error(
|
||||||
|
"failed to rename archive back",
|
||||||
|
"target_id", target.ID,
|
||||||
|
"error", restoreErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, delivery.ErrArchiveNameTaken) {
|
||||||
|
http.Error(
|
||||||
|
w,
|
||||||
|
"Not saved: "+err.Error()+
|
||||||
|
". Move that file out of the data directory, "+
|
||||||
|
"then save again.",
|
||||||
|
http.StatusConflict,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.serverError(w, r, "failed to update target", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -167,6 +197,21 @@ func (h *Handlers) applyTargetEdit(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renameTargetArchive renames a database target's archive file from
|
||||||
|
// the target name oldName to newName. It does nothing when the name
|
||||||
|
// is unchanged; other target types have no archive.
|
||||||
|
func (h *Handlers) renameTargetArchive(
|
||||||
|
target *database.Target,
|
||||||
|
webhookName, oldName, newName string,
|
||||||
|
) error {
|
||||||
|
if h.archives == nil || oldName == newName ||
|
||||||
|
target.Type != database.TargetTypeDatabase {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.archives.Rename(target.ID, webhookName, newName)
|
||||||
|
}
|
||||||
|
|
||||||
// renderTargetEdit renders the target edit page with an optional
|
// renderTargetEdit renders the target edit page with an optional
|
||||||
// error message.
|
// error message.
|
||||||
func (h *Handlers) renderTargetEdit(
|
func (h *Handlers) renderTargetEdit(
|
||||||
@@ -220,7 +265,7 @@ func (h *Handlers) ownedTarget(
|
|||||||
chi.URLParam(r, "targetID"), webhook.ID,
|
chi.URLParam(r, "targetID"), webhook.ID,
|
||||||
).First(&target).Error
|
).First(&target).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return database.Webhook{}, nil, false
|
return database.Webhook{}, nil, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -635,3 +635,68 @@ func assertWebhookOfAnotherUser404s(
|
|||||||
|
|
||||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renamedTargetName is the name the rename tests give a target.
|
||||||
|
const renamedTargetName = "Long Term"
|
||||||
|
|
||||||
|
// TestHandleTargetEditSubmit_RenamesArchive proves that renaming a
|
||||||
|
// database target renames its archive, that a save that keeps the
|
||||||
|
// name renames nothing, that a target of another type has no archive
|
||||||
|
// to rename, and that a target whose archive cannot be renamed keeps
|
||||||
|
// its stored name. When a file already has the archive's new name,
|
||||||
|
// the edit is refused with an error naming that file.
|
||||||
|
func TestHandleTargetEditSubmit_RenamesArchive(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
wh := seedWebhookWithRetention(t, env.db, 7)
|
||||||
|
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
||||||
|
rename := url.Values{"name": {renamedTargetName}}
|
||||||
|
|
||||||
|
w := submitTargetEdit(env, wh.ID, archive.ID, rename)
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
[]archiveRename{{archive.ID, wh.Name, renamedTargetName}},
|
||||||
|
env.archives.Renames(),
|
||||||
|
)
|
||||||
|
|
||||||
|
w = submitTargetEdit(env, wh.ID, archive.ID, rename)
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||||
|
assert.Len(
|
||||||
|
t, env.archives.Renames(), 1,
|
||||||
|
"a save that keeps the name renames nothing",
|
||||||
|
)
|
||||||
|
|
||||||
|
httpWebhook, httpTarget := seedHTTPTarget(t, env, "", "")
|
||||||
|
|
||||||
|
w = submitTargetEdit(
|
||||||
|
env, httpWebhook.ID, httpTarget.ID,
|
||||||
|
editForm(editOriginalURL, "", ""),
|
||||||
|
)
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||||
|
assert.Len(
|
||||||
|
t, env.archives.Renames(), 1,
|
||||||
|
"an HTTP target has no archive to rename",
|
||||||
|
)
|
||||||
|
|
||||||
|
again := url.Values{"name": {"Again"}}
|
||||||
|
|
||||||
|
env.archives.FailRenames(errInjectedRename)
|
||||||
|
|
||||||
|
w = submitTargetEdit(env, wh.ID, archive.ID, again)
|
||||||
|
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||||
|
assert.Equal(
|
||||||
|
t, renamedTargetName, storedTarget(t, env, archive.ID).Name,
|
||||||
|
"a target whose archive was not renamed keeps its name",
|
||||||
|
)
|
||||||
|
|
||||||
|
env.archives.FailRenames(errNameTaken)
|
||||||
|
|
||||||
|
w = submitTargetEdit(env, wh.ID, archive.ID, again)
|
||||||
|
require.Equal(t, http.StatusConflict, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "archive-taken.db")
|
||||||
|
assert.Equal(
|
||||||
|
t, renamedTargetName, storedTarget(t, env, archive.ID).Name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// privateRefusalHint is the sentence that tells an operator a private
|
||||||
|
// destination is refused on purpose, and how to allow one.
|
||||||
|
const privateRefusalHint = "Private and reserved addresses are " +
|
||||||
|
"refused by default; the server's ALLOWED_EGRESS_CIDRS setting " +
|
||||||
|
"allows named networks (see \"Allowing egress to your own " +
|
||||||
|
"network\" in the README)."
|
||||||
|
|
||||||
|
// TestTargetRefusal_PrivateDestinationSaysHowToAllowIt covers both
|
||||||
|
// target types that take a URL, on add and on edit.
|
||||||
|
func TestTargetRefusal_PrivateDestinationSaysHowToAllowIt(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
targetTypes := []database.TargetType{
|
||||||
|
database.TargetTypeHTTP,
|
||||||
|
database.TargetTypeSlack,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, targetType := range targetTypes {
|
||||||
|
t.Run(string(targetType), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
webhook := seedWebhookWithRetention(t, env.db, 30)
|
||||||
|
targetsPath := "/hook/" + webhook.ID + "/targets"
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("name", "private")
|
||||||
|
form.Set("type", string(targetType))
|
||||||
|
form.Set("url", editBlockedURL)
|
||||||
|
|
||||||
|
added := serveTarget(
|
||||||
|
env, http.MethodPost, targetsPath, form,
|
||||||
|
)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, added.Code)
|
||||||
|
assert.Contains(
|
||||||
|
t, added.Body.String(), privateRefusalHint,
|
||||||
|
)
|
||||||
|
|
||||||
|
form.Set("url", editOriginalURL)
|
||||||
|
|
||||||
|
created := serveTarget(
|
||||||
|
env, http.MethodPost, targetsPath, form,
|
||||||
|
)
|
||||||
|
require.Equal(
|
||||||
|
t, http.StatusSeeOther, created.Code,
|
||||||
|
created.Body.String(),
|
||||||
|
)
|
||||||
|
|
||||||
|
targets := targetsForWebhook(t, env.db, webhook.ID)
|
||||||
|
require.Len(t, targets, 1)
|
||||||
|
|
||||||
|
form.Set("url", editBlockedURL)
|
||||||
|
|
||||||
|
edited := submitTargetEdit(
|
||||||
|
env, webhook.ID, targets[0].ID, form,
|
||||||
|
)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, edited.Code)
|
||||||
|
assert.Contains(
|
||||||
|
t, edited.Body.String(), privateRefusalHint,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetRefusal_MetadataDestinationDoesNotSayHowToAllowIt: no
|
||||||
|
// setting opens a link-local address, and Azure's WireServer hands out
|
||||||
|
// VM credentials, so neither refusal points at the setting.
|
||||||
|
func TestTargetRefusal_MetadataDestinationDoesNotSayHowToAllowIt(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
metadataURLs := map[string]string{
|
||||||
|
"link-local": "http://169.254.169.254/latest/meta-data/",
|
||||||
|
"wireserver": "http://168.63.129.16/?comp=versions",
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, metadataURL := range metadataURLs {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
webhook := seedWebhookWithRetention(t, env.db, 30)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("name", "metadata")
|
||||||
|
form.Set("type", string(database.TargetTypeHTTP))
|
||||||
|
form.Set("url", metadataURL)
|
||||||
|
|
||||||
|
w := serveTarget(
|
||||||
|
env, http.MethodPost,
|
||||||
|
"/hook/"+webhook.ID+"/targets", form,
|
||||||
|
)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
assert.NotContains(
|
||||||
|
t, w.Body.String(), privateRefusalHint,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,14 +88,14 @@ func (h *Handlers) processWebhookRequest(
|
|||||||
|
|
||||||
headersJSON, err := json.Marshal(r.Header)
|
headersJSON, err := json.Marshal(r.Header)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to serialize headers", err)
|
h.receiverError(w, "failed to serialize headers", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
targets, err := h.loadActiveTargets(entrypoint.WebhookID)
|
targets, err := h.loadActiveTargets(entrypoint.WebhookID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to query targets", err)
|
h.receiverError(w, "failed to query targets", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -196,7 +196,7 @@ func (h *Handlers) createAndDeliverEvent(
|
|||||||
targets,
|
targets,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to store webhook event", err)
|
h.receiverError(w, "failed to store webhook event", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -204,6 +204,19 @@ func (h *Handlers) createAndDeliverEvent(
|
|||||||
h.finishWebhookResponse(w, event, entrypoint, tasks)
|
h.finishWebhookResponse(w, event, entrypoint, tasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// receiverError logs an error and answers the sender with a plain-text
|
||||||
|
// 500. The receiver's answers are for programs, so it never sends the
|
||||||
|
// error page the web UI uses.
|
||||||
|
func (h *Handlers) receiverError(
|
||||||
|
w http.ResponseWriter, msg string, err error,
|
||||||
|
) {
|
||||||
|
h.log.Error(msg, "error", err)
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// eventSource carries the fields a new event is built from. The
|
// eventSource carries the fields a new event is built from. The
|
||||||
// receiver fills it from the live request; the resubmit handler fills
|
// receiver fills it from the live request; the resubmit handler fills
|
||||||
// it from a stored event. Both then go through createAndFanOut, so an
|
// it from a stored event. Both then go through createAndFanOut, so an
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func CSRFToken(r *http.Request) string {
|
|||||||
// key to sign a CSRF cookie and validates a masked token submitted via
|
// key to sign a CSRF cookie and validates a masked token submitted via
|
||||||
// the "csrf_token" form field (or the "X-CSRF-Token" header) on
|
// the "csrf_token" form field (or the "X-CSRF-Token" header) on
|
||||||
// POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing
|
// POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing
|
||||||
// token receive a 403 Forbidden response.
|
// token are logged and answered by forbidden, which must write the 403.
|
||||||
//
|
//
|
||||||
// The middleware detects the client-facing transport protocol
|
// The middleware detects the client-facing transport protocol
|
||||||
// per-request via reqtls.IsTLS, the single TLS predicate the session
|
// per-request via reqtls.IsTLS, the single TLS predicate the session
|
||||||
@@ -36,7 +36,9 @@ func CSRFToken(r *http.Request) string {
|
|||||||
// Two gorilla/csrf instances are maintained — one with Secure cookies
|
// Two gorilla/csrf instances are maintained — one with Secure cookies
|
||||||
// (for TLS) and one without (for plaintext HTTP) — because the
|
// (for TLS) and one without (for plaintext HTTP) — because the
|
||||||
// csrf.Secure option is set at creation time, not per-request.
|
// csrf.Secure option is set at creation time, not per-request.
|
||||||
func (m *Middleware) CSRF() func(http.Handler) http.Handler {
|
func (m *Middleware) CSRF(
|
||||||
|
forbidden http.Handler,
|
||||||
|
) func(http.Handler) http.Handler {
|
||||||
csrfErrorHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
csrfErrorHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// CSRF is registered ahead of RequireAuth on every route
|
// CSRF is registered ahead of RequireAuth on every route
|
||||||
// group that uses it, so this WARN is reachable by an
|
// group that uses it, so this WARN is reachable by an
|
||||||
@@ -57,7 +59,7 @@ func (m *Middleware) CSRF() func(http.Handler) http.Handler {
|
|||||||
"remote_addr", r.RemoteAddr,
|
"remote_addr", r.RemoteAddr,
|
||||||
"reason", csrf.FailureReason(r),
|
"reason", csrf.FailureReason(r),
|
||||||
)
|
)
|
||||||
http.Error(w, "Forbidden - invalid CSRF token", http.StatusForbidden)
|
forbidden.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
|
|
||||||
key := m.session.GetKey()
|
key := m.session.GetKey()
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ import (
|
|||||||
// csrfCookieName is the gorilla/csrf cookie name.
|
// csrfCookieName is the gorilla/csrf cookie name.
|
||||||
const csrfCookieName = "_gorilla_csrf"
|
const csrfCookieName = "_gorilla_csrf"
|
||||||
|
|
||||||
|
// forbidden stands in for the error page the server hands CSRF to
|
||||||
|
// answer a refused request with.
|
||||||
|
func forbidden(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
// csrfGetToken performs a GET request through the CSRF middleware
|
// csrfGetToken performs a GET request through the CSRF middleware
|
||||||
// and returns the token and cookies.
|
// and returns the token and cookies.
|
||||||
func csrfGetToken(
|
func csrfGetToken(
|
||||||
@@ -98,7 +104,7 @@ func TestCSRF_GETSetsToken(t *testing.T) {
|
|||||||
|
|
||||||
var gotToken string
|
var gotToken string
|
||||||
|
|
||||||
handler := m.CSRF()(http.HandlerFunc(
|
handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc(
|
||||||
func(_ http.ResponseWriter, r *http.Request) {
|
func(_ http.ResponseWriter, r *http.Request) {
|
||||||
gotToken = middleware.CSRFToken(r)
|
gotToken = middleware.CSRFToken(r)
|
||||||
},
|
},
|
||||||
@@ -120,7 +126,7 @@ func TestCSRF_POSTWithValidToken(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||||
csrfMW := m.CSRF()
|
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||||
|
|
||||||
getReq := httptest.NewRequestWithContext(
|
getReq := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
@@ -152,7 +158,7 @@ func csrfPOSTWithoutTokenTest(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, env)
|
m, _ := testMiddleware(t, env)
|
||||||
csrfMW := m.CSRF()
|
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||||
|
|
||||||
// GET to establish the CSRF cookie
|
// GET to establish the CSRF cookie
|
||||||
getHandler := csrfMW(http.HandlerFunc(
|
getHandler := csrfMW(http.HandlerFunc(
|
||||||
@@ -209,7 +215,7 @@ func TestCSRF_POSTWithInvalidToken(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||||
csrfMW := m.CSRF()
|
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||||
|
|
||||||
// GET to establish the CSRF cookie
|
// GET to establish the CSRF cookie
|
||||||
getHandler := csrfMW(http.HandlerFunc(
|
getHandler := csrfMW(http.HandlerFunc(
|
||||||
@@ -265,7 +271,7 @@ func TestCSRF_GETDoesNotValidate(t *testing.T) {
|
|||||||
|
|
||||||
var called bool
|
var called bool
|
||||||
|
|
||||||
handler := m.CSRF()(http.HandlerFunc(
|
handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc(
|
||||||
func(_ http.ResponseWriter, _ *http.Request) {
|
func(_ http.ResponseWriter, _ *http.Request) {
|
||||||
called = true
|
called = true
|
||||||
},
|
},
|
||||||
@@ -328,7 +334,7 @@ func csrfTookStrictPath(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, env)
|
m, _ := testMiddleware(t, env)
|
||||||
csrfMW := m.CSRF()
|
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||||
|
|
||||||
newReq := func(method string) *http.Request {
|
newReq := func(method string) *http.Request {
|
||||||
r := httptest.NewRequestWithContext(
|
r := httptest.NewRequestWithContext(
|
||||||
@@ -477,7 +483,7 @@ func TestCSRF_ProdMode_PlaintextHTTP_POSTWithValidToken(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentProd)
|
m, _ := testMiddleware(t, config.EnvironmentProd)
|
||||||
csrfMW := m.CSRF()
|
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||||
|
|
||||||
getReq := httptest.NewRequestWithContext(
|
getReq := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
@@ -517,7 +523,7 @@ func TestCSRF_ProdMode_BehindProxy_POSTWithValidToken(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentProd)
|
m, _ := testMiddleware(t, config.EnvironmentProd)
|
||||||
csrfMW := m.CSRF()
|
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||||
|
|
||||||
getReq := httptest.NewRequestWithContext(
|
getReq := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
@@ -562,7 +568,7 @@ func TestCSRF_ProdMode_DirectTLS_POSTWithValidToken(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentProd)
|
m, _ := testMiddleware(t, config.EnvironmentProd)
|
||||||
csrfMW := m.CSRF()
|
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||||
|
|
||||||
getReq := httptest.NewRequestWithContext(
|
getReq := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
|
|||||||
@@ -260,7 +260,9 @@ func logSites() map[string]logSite {
|
|||||||
) http.Handler {
|
) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return m.CSRF()(unreachable(t))
|
return m.CSRF(http.HandlerFunc(forbidden))(
|
||||||
|
unreachable(t),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
send: postNoToken,
|
send: postNoToken,
|
||||||
wantStatus: http.StatusForbidden,
|
wantStatus: http.StatusForbidden,
|
||||||
|
|||||||
@@ -109,7 +109,8 @@ func (w *recoverResponseWriter) Unwrap() http.ResponseWriter {
|
|||||||
|
|
||||||
// Recoverer returns middleware that turns a handler panic into one
|
// Recoverer returns middleware that turns a handler panic into one
|
||||||
// structured ERROR record and a 500, rather than a dropped
|
// structured ERROR record and a 500, rather than a dropped
|
||||||
// connection.
|
// connection. The 500 is page when page is not nil, and plain text
|
||||||
|
// when it is nil or when page panics before writing anything.
|
||||||
//
|
//
|
||||||
// It replaces chi's middleware.Recoverer, which does neither on a
|
// It replaces chi's middleware.Recoverer, which does neither on a
|
||||||
// current Go release. chi v1.5.5's pretty-printer scans the stack for
|
// current Go release. chi v1.5.5's pretty-printer scans the stack for
|
||||||
@@ -136,9 +137,13 @@ func (w *recoverResponseWriter) Unwrap() http.ResponseWriter {
|
|||||||
//
|
//
|
||||||
// Unlike http.Error on its own, it deletes any Set-Cookie the handler
|
// Unlike http.Error on its own, it deletes any Set-Cookie the handler
|
||||||
// set before panicking, because a request that failed must not hand
|
// set before panicking, because a request that failed must not hand
|
||||||
// the client a credential; every other header is left to http.Error.
|
// the client a credential. It touches no other header: when page
|
||||||
|
// answers, every other header the handler set goes out with it, apart
|
||||||
|
// from any page sets itself; otherwise they are left to http.Error.
|
||||||
// See https://git.eeqj.de/sneak/webhooker/issues/193.
|
// See https://git.eeqj.de/sneak/webhooker/issues/193.
|
||||||
func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
|
func (s *Middleware) Recoverer(
|
||||||
|
page http.Handler,
|
||||||
|
) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(
|
return http.HandlerFunc(func(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
@@ -171,6 +176,14 @@ func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
|
|||||||
|
|
||||||
rw.Header().Del("Set-Cookie")
|
rw.Header().Del("Set-Cookie")
|
||||||
|
|
||||||
|
if page != nil {
|
||||||
|
s.servePage(rw, r, page)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rw.committed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
http.Error(
|
http.Error(
|
||||||
rw,
|
rw,
|
||||||
http.StatusText(
|
http.StatusText(
|
||||||
@@ -185,6 +198,27 @@ func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// servePage answers with page. A panic in page itself is logged and
|
||||||
|
// recovered here, so the Recoverer can still send its plain 500.
|
||||||
|
func (s *Middleware) servePage(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
page http.Handler,
|
||||||
|
) {
|
||||||
|
defer func() {
|
||||||
|
rvr := recover()
|
||||||
|
if rvr != nil {
|
||||||
|
s.log.Error("error page panic",
|
||||||
|
"panic", logfield.Truncate(
|
||||||
|
fmt.Sprint(rvr), maxPanicValueBytes,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
page.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
// logPanic writes the record. Every field it can grow is truncated to
|
// logPanic writes the record. Every field it can grow is truncated to
|
||||||
// a fixed budget, so MaxPanicLogLineBytes holds.
|
// a fixed budget, so MaxPanicLogLineBytes holds.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func newRecovererProbe(
|
|||||||
// Logging outside so the recovered 500 is the status it records.
|
// Logging outside so the recovered 500 is the status it records.
|
||||||
router.Use(chimw.RequestID)
|
router.Use(chimw.RequestID)
|
||||||
router.Use(m.Logging())
|
router.Use(m.Logging())
|
||||||
router.Use(m.Recoverer())
|
router.Use(m.Recoverer(nil))
|
||||||
router.Get("/probe", handler)
|
router.Get("/probe", handler)
|
||||||
|
|
||||||
serverErrors := new(bytes.Buffer)
|
serverErrors := new(bytes.Buffer)
|
||||||
@@ -637,7 +637,7 @@ func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
|
|||||||
|
|
||||||
m, _ := capturingMiddleware(t)
|
m, _ := capturingMiddleware(t)
|
||||||
|
|
||||||
handler := m.Recoverer()(http.HandlerFunc(
|
handler := m.Recoverer(nil)(http.HandlerFunc(
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
_, _ = w.Write([]byte("chunk"))
|
_, _ = w.Write([]byte("chunk"))
|
||||||
|
|
||||||
@@ -672,3 +672,59 @@ func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
assert.Equal(t, "chunk", string(body))
|
assert.Equal(t, "chunk", string(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRecovererAnswersWithThePage covers a recoverer given a page:
|
||||||
|
// the panic is logged as before, and the 500 is that page.
|
||||||
|
func TestRecovererAnswersWithThePage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
m, logs := capturingMiddleware(t)
|
||||||
|
|
||||||
|
page := http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte("the error page"))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
m.Recoverer(page)(http.HandlerFunc(panicProbe)).ServeHTTP(
|
||||||
|
w, httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, "/", nil,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||||
|
assert.Equal(t, "the error page", w.Body.String())
|
||||||
|
assert.Contains(t, logs.String(), `"msg":"handler panic"`)
|
||||||
|
assert.Contains(t, logs.String(), panicMarker)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecovererFallsBackWhenThePagePanics covers a page that panics
|
||||||
|
// before writing anything: both panics are logged, and the client
|
||||||
|
// still gets the plain 500.
|
||||||
|
func TestRecovererFallsBackWhenThePagePanics(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
m, logs := capturingMiddleware(t)
|
||||||
|
|
||||||
|
const pagePanic = "QQERRORPAGEPANICQQ"
|
||||||
|
|
||||||
|
page := http.HandlerFunc(
|
||||||
|
func(http.ResponseWriter, *http.Request) {
|
||||||
|
panic(pagePanic)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
m.Recoverer(page)(http.HandlerFunc(panicProbe)).ServeHTTP(
|
||||||
|
w, httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, "/", nil,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||||
|
assert.Equal(t, "Internal Server Error\n", w.Body.String())
|
||||||
|
assert.Contains(t, logs.String(), panicMarker)
|
||||||
|
assert.Contains(t, logs.String(), pagePanic)
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,9 +131,15 @@ type noopNotifier struct{}
|
|||||||
|
|
||||||
func (n *noopNotifier) Notify([]delivery.Task) {}
|
func (n *noopNotifier) Notify([]delivery.Task) {}
|
||||||
|
|
||||||
type noopEvictor struct{}
|
type noopArchives struct{}
|
||||||
|
|
||||||
func (n *noopEvictor) EvictWebhook(string) {}
|
func (n *noopArchives) EvictWebhook(string) {}
|
||||||
|
|
||||||
|
func (n *noopArchives) EvictTarget(string) {}
|
||||||
|
|
||||||
|
func (n *noopArchives) Rename(_, _, _ string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -162,7 +168,7 @@ func newServerApp(
|
|||||||
healthcheck.New,
|
healthcheck.New,
|
||||||
session.New,
|
session.New,
|
||||||
func() delivery.Notifier { return &noopNotifier{} },
|
func() delivery.Notifier { return &noopNotifier{} },
|
||||||
func() delivery.WebhookEvictor { return &noopEvictor{} },
|
func() delivery.Archives { return &noopArchives{} },
|
||||||
middleware.New,
|
middleware.New,
|
||||||
delivery.NewGuard,
|
delivery.NewGuard,
|
||||||
handlers.New,
|
handlers.New,
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package server_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/getsentry/sentry-go"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
"sneak.berlin/go/webhooker/internal/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The link back the error page offers: to the webhook list for a
|
||||||
|
// signed-in user, to sign-in for anyone else.
|
||||||
|
const (
|
||||||
|
backToWebhooks = `<a href="/hooks" class="btn-secondary">` +
|
||||||
|
`Back to webhooks</a>`
|
||||||
|
backToSignIn = `<a href="/pages/login" class="btn-primary">` +
|
||||||
|
`Sign in</a>`
|
||||||
|
)
|
||||||
|
|
||||||
|
// assertErrorPage checks that w is the error page for status, in the
|
||||||
|
// normal layout, offering link.
|
||||||
|
func assertErrorPage(
|
||||||
|
t *testing.T,
|
||||||
|
w *httptest.ResponseRecorder,
|
||||||
|
status int,
|
||||||
|
link string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
|
||||||
|
assert.Equal(t, status, w.Code)
|
||||||
|
assert.Equal(
|
||||||
|
t, "text/html; charset=utf-8", w.Header().Get("Content-Type"),
|
||||||
|
)
|
||||||
|
assert.Equal(t, "no-store", w.Header().Get("Cache-Control"))
|
||||||
|
assert.Contains(t, body, `<nav class="app-bar"`)
|
||||||
|
assert.Contains(
|
||||||
|
t, body, strconv.Itoa(status)+" "+http.StatusText(status),
|
||||||
|
)
|
||||||
|
assert.Contains(t, body, link)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorPage_DeletedWebhook(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
userID, _ := env.seedUser(t, "owner", "somepassword")
|
||||||
|
cookies := env.authCookies(t, userID, "owner")
|
||||||
|
|
||||||
|
wh := env.seedWebhook(t, userID)
|
||||||
|
require.NoError(t, env.db.DB().Delete(wh).Error)
|
||||||
|
|
||||||
|
w := env.get("/hook/"+wh.ID, cookies)
|
||||||
|
|
||||||
|
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorPage_DeletedTarget(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
userID, _ := env.seedUser(t, "owner", "somepassword")
|
||||||
|
cookies := env.authCookies(t, userID, "owner")
|
||||||
|
|
||||||
|
wh := env.seedWebhook(t, userID)
|
||||||
|
tgt := env.seedTarget(t, wh.ID)
|
||||||
|
require.NoError(t, env.db.DB().Delete(tgt).Error)
|
||||||
|
|
||||||
|
w := env.get(
|
||||||
|
"/hook/"+wh.ID+"/targets/"+tgt.ID+"/edit", cookies,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorPage_UnknownPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
userID, _ := env.seedUser(t, "owner", "somepassword")
|
||||||
|
cookies := env.authCookies(t, userID, "owner")
|
||||||
|
|
||||||
|
assertErrorPage(
|
||||||
|
t, env.get("/no-such-page", nil),
|
||||||
|
http.StatusNotFound, backToSignIn,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Outside every route group there is no form token, so the
|
||||||
|
// page leaves out the logout form rather than offer one that
|
||||||
|
// would be refused.
|
||||||
|
w := env.get("/no-such-page", cookies)
|
||||||
|
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
||||||
|
assert.NotContains(t, w.Body.String(), `action="/pages/logout"`)
|
||||||
|
|
||||||
|
// Inside a route group the page has a token, and logout works.
|
||||||
|
wh := env.seedWebhook(t, userID)
|
||||||
|
w = env.get("/hook/"+wh.ID+"/no-such-page", cookies)
|
||||||
|
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
||||||
|
assert.Contains(t, w.Body.String(), `action="/pages/logout"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorPage_BadCSRFToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("username", "someone")
|
||||||
|
form.Set("password", "irrelevant")
|
||||||
|
form.Set("csrf_token", "not-a-token")
|
||||||
|
|
||||||
|
assertErrorPage(
|
||||||
|
t, env.post("/pages/login", form, nil),
|
||||||
|
http.StatusForbidden, backToSignIn,
|
||||||
|
)
|
||||||
|
|
||||||
|
userID, _ := env.seedUser(t, "owner", "somepassword")
|
||||||
|
cookies := env.authCookies(t, userID, "owner")
|
||||||
|
wh := env.seedWebhook(t, userID)
|
||||||
|
|
||||||
|
edit := url.Values{}
|
||||||
|
edit.Set("name", "renamed")
|
||||||
|
|
||||||
|
assertErrorPage(
|
||||||
|
t, env.post("/hook/"+wh.ID+"/edit", edit, cookies),
|
||||||
|
http.StatusForbidden, backToWebhooks,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestErrorPage_PanicOnAdminPage sends a panicking handler in an
|
||||||
|
// admin page route group through the real router, with error
|
||||||
|
// tracking on: the client gets the 500 error page, and the tracker
|
||||||
|
// still gets the panic, once. The same panic outside the admin page
|
||||||
|
// route groups keeps the plain 500.
|
||||||
|
func TestErrorPage_PanicOnAdminPage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
transport := &captureTransport{}
|
||||||
|
|
||||||
|
opts := server.SentryClientOptionsForTest(
|
||||||
|
"https://public@sentry.invalid/1", "webhooker-test",
|
||||||
|
)
|
||||||
|
opts.Transport = transport
|
||||||
|
|
||||||
|
client, err := sentry.NewClient(opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
serve := func(router http.Handler, path string) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
sentry.SetHubOnContext(
|
||||||
|
context.Background(),
|
||||||
|
sentry.NewHub(client, sentry.NewScope()),
|
||||||
|
),
|
||||||
|
http.MethodGet, path, nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
w := serve(
|
||||||
|
server.NewRouterWithPageProbeForTest(
|
||||||
|
env.log.Get(), env.cfg, env.mw, env.hnd,
|
||||||
|
true, panicProbeHandler,
|
||||||
|
),
|
||||||
|
server.PageProbePattern,
|
||||||
|
)
|
||||||
|
assertErrorPage(t, w, http.StatusInternalServerError, backToSignIn)
|
||||||
|
|
||||||
|
w = serve(
|
||||||
|
server.NewRouterWithProbeForTest(
|
||||||
|
env.log.Get(), env.cfg, env.mw, env.hnd,
|
||||||
|
true, panicProbeHandler,
|
||||||
|
),
|
||||||
|
server.ProbePattern,
|
||||||
|
)
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||||
|
assert.Equal(t, "Internal Server Error\n", w.Body.String())
|
||||||
|
|
||||||
|
require.Len(t, transport.events, 2)
|
||||||
|
|
||||||
|
for _, event := range transport.events {
|
||||||
|
assert.Contains(t, marshalEvent(t, event), panicProbeMarker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestErrorPage_ReceiverStaysPlain pins that the error page is for
|
||||||
|
// the web UI only: a sender posting to an entrypoint that does not
|
||||||
|
// exist still gets the plain-text answer.
|
||||||
|
func TestErrorPage_ReceiverStaysPlain(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// newTestEnv leaves the receiver rate limit at zero, which
|
||||||
|
// refuses every request before it reaches the receiver.
|
||||||
|
env := newTestEnvWithConfig(t, &config.Config{
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
Environment: config.EnvironmentDev,
|
||||||
|
ReceiverRateLimit: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
w := env.post(
|
||||||
|
"/h/0b8f3c1e-7d2a-4e6b-9f15-3a9c2d4e6f70", url.Values{}, nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
assert.Equal(t, "404 page not found\n", w.Body.String())
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/getsentry/sentry-go"
|
"github.com/getsentry/sentry-go"
|
||||||
|
"github.com/go-chi/chi"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
"sneak.berlin/go/webhooker/internal/middleware"
|
"sneak.berlin/go/webhooker/internal/middleware"
|
||||||
@@ -101,3 +102,39 @@ func NewRouterWithProbeForTest(
|
|||||||
|
|
||||||
return s.router
|
return s.router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PageProbePattern is where NewRouterWithPageProbeForTest serves its
|
||||||
|
// probe: inside the /pages route group, the admin page group a
|
||||||
|
// request reaches without signing in.
|
||||||
|
const PageProbePattern = "/pages/probe"
|
||||||
|
|
||||||
|
// NewRouterWithPageProbeForTest is NewRouterWithProbeForTest with the
|
||||||
|
// probe added to the /pages route group once SetupRoutes has built
|
||||||
|
// it, so the probe runs behind that group's own middleware exactly as
|
||||||
|
// the group's real routes do.
|
||||||
|
func NewRouterWithPageProbeForTest(
|
||||||
|
log *slog.Logger,
|
||||||
|
cfg *config.Config,
|
||||||
|
mw *middleware.Middleware,
|
||||||
|
h *handlers.Handlers,
|
||||||
|
sentryEnabled bool,
|
||||||
|
probe http.HandlerFunc,
|
||||||
|
) http.Handler {
|
||||||
|
s := &Server{
|
||||||
|
log: log,
|
||||||
|
mw: mw,
|
||||||
|
h: h,
|
||||||
|
params: ServerParams{Config: cfg},
|
||||||
|
}
|
||||||
|
s.sentryEnabled.Store(sentryEnabled)
|
||||||
|
s.SetupRoutes()
|
||||||
|
|
||||||
|
for _, route := range s.router.Routes() {
|
||||||
|
pages, ok := route.SubRoutes.(chi.Router)
|
||||||
|
if ok && route.Pattern == "/pages/*" {
|
||||||
|
pages.Get("/probe", probe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.router
|
||||||
|
}
|
||||||
|
|||||||
+44
-13
@@ -15,9 +15,10 @@ import (
|
|||||||
// bytes) for form POST endpoints. 1 MB is generous for any form
|
// bytes) for form POST endpoints. 1 MB is generous for any form
|
||||||
// submission while preventing abuse from oversized payloads.
|
// submission while preventing abuse from oversized payloads.
|
||||||
//
|
//
|
||||||
// Every route group below installs MaxBodySize(maxFormBodySize) as
|
// The four admin page route groups below (/pages, /user/{username},
|
||||||
// its FIRST middleware, ahead of both CSRF and RequireAuth. Both
|
// /hooks and /hook/{sourceID}) install MaxBodySize(maxFormBodySize)
|
||||||
// orderings are deliberate.
|
// right after their recoverer and error reporting, ahead of both CSRF
|
||||||
|
// and RequireAuth. Both orderings are deliberate.
|
||||||
//
|
//
|
||||||
// Ahead of CSRF because gorilla/csrf parses the form. The cap has to
|
// Ahead of CSRF because gorilla/csrf parses the form. The cap has to
|
||||||
// be installed before anything reads the body, or the parse runs
|
// be installed before anything reads the body, or the parse runs
|
||||||
@@ -46,6 +47,14 @@ const requestTimeout = 60 * time.Second
|
|||||||
// server's router.
|
// server's router.
|
||||||
func (s *Server) SetupRoutes() {
|
func (s *Server) SetupRoutes() {
|
||||||
s.router = chi.NewRouter()
|
s.router = chi.NewRouter()
|
||||||
|
|
||||||
|
// An unknown path gets the error page. Registered before the
|
||||||
|
// global middleware, because chi wraps a not-found handler in the
|
||||||
|
// middleware already on its router, which would then run twice.
|
||||||
|
// The route groups below wrap it in their own middleware the same
|
||||||
|
// way; running theirs twice is harmless.
|
||||||
|
s.router.NotFound(s.h.HandleErrorPage(http.StatusNotFound))
|
||||||
|
|
||||||
s.setupGlobalMiddleware()
|
s.setupGlobalMiddleware()
|
||||||
s.setupRoutes()
|
s.setupRoutes()
|
||||||
}
|
}
|
||||||
@@ -69,23 +78,33 @@ func (s *Server) setupGlobalMiddleware() {
|
|||||||
// Panic recovery, deliberately here rather than first. It has to
|
// Panic recovery, deliberately here rather than first. It has to
|
||||||
// run inside every middleware that observes the response, so the
|
// run inside every middleware that observes the response, so the
|
||||||
// 500 it writes is the status the access log records and the
|
// 500 it writes is the status the access log records and the
|
||||||
// metrics count, and outside the sentryhttp handler below, whose
|
// metrics count, and outside the sentryhttp handler, whose
|
||||||
// Repanic option needs something further out to catch what it
|
// Repanic option needs something further out to catch what it
|
||||||
// re-raises. chi's own middleware.Recoverer held the first slot
|
// re-raises. chi's own middleware.Recoverer held the first slot
|
||||||
// until it was measured: on a current Go release it crashes
|
// until it was measured: on a current Go release it crashes
|
||||||
// inside its stack pretty-printer instead of recovering, so the
|
// inside its stack pretty-printer instead of recovering, so the
|
||||||
// connection dropped and the original panic was never reported.
|
// connection dropped and the original panic was never reported.
|
||||||
// See https://git.eeqj.de/sneak/webhooker/issues/187.
|
// See https://git.eeqj.de/sneak/webhooker/issues/187.
|
||||||
s.router.Use(s.mw.Recoverer())
|
s.recoverPanics(s.router, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recoverPanics installs on r the recoverer, answering a panic with
|
||||||
|
// page (a plain 500 when page is nil), and inside it the Sentry error
|
||||||
|
// reporting (if SENTRY_DSN is set). Repanic is true so panics still
|
||||||
|
// bubble up to the recoverer.
|
||||||
|
//
|
||||||
|
// Each admin page route group installs its own, with the error page,
|
||||||
|
// as its first middleware. A panic there is logged, reported and
|
||||||
|
// answered inside the group and never reaches the global recoverer,
|
||||||
|
// which keeps the plain 500 for every other route.
|
||||||
|
func (s *Server) recoverPanics(r chi.Router, page http.Handler) {
|
||||||
|
r.Use(s.mw.Recoverer(page))
|
||||||
|
|
||||||
// Sentry error reporting (if SENTRY_DSN is set). Repanic is
|
|
||||||
// true so panics still bubble up to the Recoverer middleware
|
|
||||||
// registered immediately above.
|
|
||||||
if s.sentryEnabled.Load() {
|
if s.sentryEnabled.Load() {
|
||||||
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
||||||
Repanic: true,
|
Repanic: true,
|
||||||
})
|
})
|
||||||
s.router.Use(sentryHandler.Handle)
|
r.Use(sentryHandler.Handle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,10 +166,13 @@ func (s *Server) setupRoutes() {
|
|||||||
|
|
||||||
func (s *Server) setupPageRoutes() {
|
func (s *Server) setupPageRoutes() {
|
||||||
s.router.Route("/pages", func(r chi.Router) {
|
s.router.Route("/pages", func(r chi.Router) {
|
||||||
|
s.recoverPanics(
|
||||||
|
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
||||||
|
)
|
||||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||||
// see maxFormBodySize for why, and for what it costs.
|
// see maxFormBodySize for why, and for what it costs.
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||||
r.Use(s.mw.CSRF())
|
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
||||||
r.Use(s.mw.NoCache())
|
r.Use(s.mw.NoCache())
|
||||||
|
|
||||||
// The login POST carries no pre-emptive rate limiter. Behind
|
// The login POST carries no pre-emptive rate limiter. Behind
|
||||||
@@ -169,10 +191,13 @@ func (s *Server) setupPageRoutes() {
|
|||||||
|
|
||||||
func (s *Server) setupUserRoutes() {
|
func (s *Server) setupUserRoutes() {
|
||||||
s.router.Route("/user/{username}", func(r chi.Router) {
|
s.router.Route("/user/{username}", func(r chi.Router) {
|
||||||
|
s.recoverPanics(
|
||||||
|
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
||||||
|
)
|
||||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||||
// see maxFormBodySize for why, and for what it costs.
|
// see maxFormBodySize for why, and for what it costs.
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||||
r.Use(s.mw.CSRF())
|
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
||||||
r.Use(s.mw.NoCache())
|
r.Use(s.mw.NoCache())
|
||||||
r.Use(s.mw.RequireAuth())
|
r.Use(s.mw.RequireAuth())
|
||||||
r.Get("/", s.h.HandleProfile())
|
r.Get("/", s.h.HandleProfile())
|
||||||
@@ -184,10 +209,13 @@ func (s *Server) setupUserRoutes() {
|
|||||||
|
|
||||||
func (s *Server) setupSourceRoutes() {
|
func (s *Server) setupSourceRoutes() {
|
||||||
s.router.Route("/hooks", func(r chi.Router) {
|
s.router.Route("/hooks", func(r chi.Router) {
|
||||||
|
s.recoverPanics(
|
||||||
|
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
||||||
|
)
|
||||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||||
// see maxFormBodySize for why, and for what it costs.
|
// see maxFormBodySize for why, and for what it costs.
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||||
r.Use(s.mw.CSRF())
|
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
||||||
r.Use(s.mw.NoCache())
|
r.Use(s.mw.NoCache())
|
||||||
r.Use(s.mw.RequireAuth())
|
r.Use(s.mw.RequireAuth())
|
||||||
r.Get("/", s.h.HandleSourceList())
|
r.Get("/", s.h.HandleSourceList())
|
||||||
@@ -196,10 +224,13 @@ func (s *Server) setupSourceRoutes() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
s.router.Route("/hook/{sourceID}", func(r chi.Router) {
|
s.router.Route("/hook/{sourceID}", func(r chi.Router) {
|
||||||
|
s.recoverPanics(
|
||||||
|
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
||||||
|
)
|
||||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||||
// see maxFormBodySize for why, and for what it costs.
|
// see maxFormBodySize for why, and for what it costs.
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||||
r.Use(s.mw.CSRF())
|
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
||||||
r.Use(s.mw.NoCache())
|
r.Use(s.mw.NoCache())
|
||||||
r.Use(s.mw.RequireAuth())
|
r.Use(s.mw.RequireAuth())
|
||||||
r.Get("/", s.h.HandleSourceDetail())
|
r.Get("/", s.h.HandleSourceDetail())
|
||||||
|
|||||||
@@ -46,12 +46,18 @@ type noopNotifier struct{}
|
|||||||
|
|
||||||
func (n *noopNotifier) Notify([]delivery.Task) {}
|
func (n *noopNotifier) Notify([]delivery.Task) {}
|
||||||
|
|
||||||
// noopEvictor satisfies handlers.New's delivery.WebhookEvictor
|
// noopArchives satisfies handlers.New's delivery.Archives
|
||||||
// dependency. No test here checks what gets evicted, so it records
|
// dependency. No test here checks what gets evicted or renamed, so
|
||||||
// nothing.
|
// it records nothing.
|
||||||
type noopEvictor struct{}
|
type noopArchives struct{}
|
||||||
|
|
||||||
func (e *noopEvictor) EvictWebhook(string) {}
|
func (e *noopArchives) EvictWebhook(string) {}
|
||||||
|
|
||||||
|
func (e *noopArchives) EvictTarget(string) {}
|
||||||
|
|
||||||
|
func (e *noopArchives) Rename(_, _, _ string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -112,7 +118,7 @@ func newTestEnvWithConfig(
|
|||||||
healthcheck.New,
|
healthcheck.New,
|
||||||
session.New,
|
session.New,
|
||||||
func() delivery.Notifier { return &noopNotifier{} },
|
func() delivery.Notifier { return &noopNotifier{} },
|
||||||
func() delivery.WebhookEvictor { return &noopEvictor{} },
|
func() delivery.Archives { return &noopArchives{} },
|
||||||
middleware.New,
|
middleware.New,
|
||||||
delivery.NewGuard,
|
delivery.NewGuard,
|
||||||
handlers.New,
|
handlers.New,
|
||||||
|
|||||||
@@ -115,8 +115,8 @@ func TestVersion_EnclosingRepositoryIsNotUsed(t *testing.T) {
|
|||||||
require.Equal(t, unknown, runScript(t, inner, nil))
|
require.Equal(t, unknown, runScript(t, inner, nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Docker build has no git metadata, so the version arrives as an
|
// An explicit VERSION, such as the Dockerfile's build arg, wins over
|
||||||
// environment override. It wins over anything derivable.
|
// anything derivable.
|
||||||
func TestVersion_EnvironmentOverrideWins(t *testing.T) {
|
func TestVersion_EnvironmentOverrideWins(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -128,8 +128,8 @@ func TestVersion_EnvironmentOverrideWins(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// An empty VERSION is treated as unset rather than stamping an empty
|
// An empty VERSION is treated as unset rather than stamping an empty
|
||||||
// string: the Dockerfile's build arg has a non-empty default, but a
|
// string: a caller exporting VERSION= must not produce a binary
|
||||||
// caller exporting VERSION= must not produce a binary reporting "".
|
// reporting "".
|
||||||
func TestVersion_EmptyOverrideFallsBackToGit(t *testing.T) {
|
func TestVersion_EmptyOverrideFallsBackToGit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -168,8 +168,8 @@ func TestMakefile_BuildComposesVersionAndExtraFlags(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A caller can define VERSION as the empty string -- `make build
|
// A caller can define VERSION as the empty string -- `make build
|
||||||
// VERSION=`, or a `--build-arg VERSION=` reaching the Dockerfile's `make
|
// VERSION=`, or the Dockerfile's `make build VERSION="$VERSION"` when no
|
||||||
// build VERSION="$VERSION"`. script/version's own guard does not cover
|
// VERSION build arg was given. script/version's own guard does not cover
|
||||||
// that: the value never passes through the script. Stamping "" would
|
// that: the value never passes through the script. Stamping "" would
|
||||||
// leave the binary reporting no version and the footer on "dev", which
|
// leave the binary reporting no version and the footer on "dev", which
|
||||||
// is the defect this package exists for.
|
// is the defect this package exists for.
|
||||||
@@ -231,7 +231,7 @@ func TestDockerfile_BuildsThroughTheMakeTarget(t *testing.T) {
|
|||||||
|
|
||||||
require.NotContains(t, dockerfile, "go build",
|
require.NotContains(t, dockerfile, "go build",
|
||||||
"a raw go build bypasses the Makefile's -X flag")
|
"a raw go build bypasses the Makefile's -X flag")
|
||||||
require.Contains(t, dockerfile, "ARG VERSION=")
|
require.Contains(t, dockerfile, "ARG VERSION")
|
||||||
require.Contains(t, dockerfile,
|
require.Contains(t, dockerfile,
|
||||||
`make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"'`)
|
`make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"'`)
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -2,9 +2,9 @@
|
|||||||
# script/docker: build the Docker image tagged with the project name.
|
# script/docker: build the Docker image tagged with the project name.
|
||||||
# The tag comes from script/projectname.
|
# The tag comes from script/projectname.
|
||||||
#
|
#
|
||||||
# .dockerignore excludes .git/, so the builder stage cannot derive the
|
# The version script/version resolves here goes in as the VERSION build
|
||||||
# version itself. It is resolved here, where the checkout is, and passed
|
# arg, which takes precedence over what the build would derive from the
|
||||||
# in as a build arg; without it the image would stamp itself "unknown".
|
# .git in its context.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
|||||||
+5
-7
@@ -7,18 +7,16 @@
|
|||||||
#
|
#
|
||||||
# Order of precedence:
|
# Order of precedence:
|
||||||
#
|
#
|
||||||
# 1. $VERSION, if set and non-empty. This is how the value reaches a
|
# 1. $VERSION, if set and non-empty: an explicit value, such as the
|
||||||
# build that cannot derive it: .dockerignore excludes .git/, so the
|
# Dockerfile's VERSION build arg.
|
||||||
# builder stage has no git metadata and the Dockerfile takes the
|
|
||||||
# value as a build arg instead.
|
|
||||||
# 2. `git describe --tags --always --dirty` against this checkout. At
|
# 2. `git describe --tags --always --dirty` against this checkout. At
|
||||||
# a clean tagged commit that is exactly the tag; otherwise it
|
# a clean tagged commit that is exactly the tag; otherwise it
|
||||||
# carries the short SHA, the commit distance when a tag is
|
# carries the short SHA, the commit distance when a tag is
|
||||||
# reachable, and a -dirty suffix for uncommitted changes.
|
# reachable, and a -dirty suffix for uncommitted changes.
|
||||||
# 3. "unknown", for a tree with no git metadata and no $VERSION -- a
|
# 3. "unknown", for a tree with no git metadata and no $VERSION -- a
|
||||||
# source tarball, or `docker build .` with no --build-arg. That
|
# source tarball, or a `docker build` with no .git in its context
|
||||||
# case must not fail the build and must not name a tag the tree may
|
# and no VERSION build arg. That case must not fail the build and
|
||||||
# not be at, so it names nothing.
|
# must not name a tag the tree may not be at, so it names nothing.
|
||||||
#
|
#
|
||||||
# The git step insists the enclosing repository is this checkout, not
|
# The git step insists the enclosing repository is this checkout, not
|
||||||
# merely some repository above it: an unpacked tarball sitting inside an
|
# merely some repository above it: an unpacked tarball sitting inside an
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "title"}}{{.StatusText}} - Webhooker{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="max-w-4xl mx-auto px-6 py-12">
|
||||||
|
<h1 class="text-2xl font-medium text-gray-900 mb-4">{{.Status}} {{.StatusText}}</h1>
|
||||||
|
<p class="text-gray-600 mb-6">{{.Message}}</p>
|
||||||
|
{{if .User}}
|
||||||
|
<a href="/hooks" class="btn-secondary">Back to webhooks</a>
|
||||||
|
{{else}}
|
||||||
|
<a href="/pages/login" class="btn-primary">Sign in</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -26,11 +26,15 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{{.User.Username}}
|
{{.User.Username}}
|
||||||
</a>
|
</a>
|
||||||
|
{{/* An error page can be served before a form token is issued,
|
||||||
|
and a logout without one is refused. */}}
|
||||||
|
{{if .CSRFToken}}
|
||||||
<form method="POST" action="/pages/logout" class="inline">
|
<form method="POST" action="/pages/logout" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="btn-text">Logout</button>
|
<button type="submit" class="btn-text">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -40,11 +44,13 @@
|
|||||||
{{if .User}}
|
{{if .User}}
|
||||||
<a href="/hooks" class="btn-text w-full text-left">Webhooks</a>
|
<a href="/hooks" class="btn-text w-full text-left">Webhooks</a>
|
||||||
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
|
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
|
||||||
|
{{if .CSRFToken}}
|
||||||
<form method="POST" action="/pages/logout">
|
<form method="POST" action="/pages/logout">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="btn-text w-full text-left">Logout</button>
|
<button type="submit" class="btn-text w-full text-left">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
Reference in New Issue
Block a user