29 Commits
Author SHA1 Message Date
clawbot a8fc0c5d32 Merge main into next after 416 (closes #497)
check / check (push) Successful in 4m34s
#416 put a `main`-only version of fixes `next` already had onto `main`, so `next` no longer merged into `main`: `script/test` conflicted. This merge makes `main` an ancestor of `next` and keeps `next`'s files throughout, which already hold everything 416 changed, so `next`'s tree is unchanged.

Model: opus-5-5
2026-10-03 14:29:50 +02:00
clawbot 7963188c1e Merge main into next after 416 (closes #497)
check / check (push) Successful in 6m8s
Makes main an ancestor of next, so the milestone PR merges again.
script/test conflicted and keeps next's version, which already runs
without -v, caps memory with -p 4 -parallel 8, adds coverage and reruns
failed tests with -v. password.go, password_test.go, export_test.go and
resetpw_test.go merged on their own: main's lines there are the same
as next's. The merged tree equals next's.

Model: opus-5-5
2026-10-03 12:17:30 +00:00
clawbotandsneak 16ed356b68 main side of 414: cheaper test hashing, and failing tests visible in the build log (closes #414) (#416)
check / check (push) Successful in 3m21s
The `main` side of #414: the two changes that make `next` green, and nothing else from `next`. Each is its own commit, so it can be compared with its `next` counterpart.

- #404, as merged to `next`: a test binary hashes passwords at a 1 MB Argon2id cost instead of 64 MB, `TestHashPassword_ShippedParameters` keeps the shipped cost covered, and `script/test` runs at most four packages and eight parallel tests at once. Every test that starts a database hashed the admin password at 64 MB, which on a busy host made `internal/handlers` overrun its application start and its 90-second timeout. That is what turned `main` red.
- #415: `script/test` runs without `-v`, so the build log, which the Docker build cuts off at 2 MiB, carries one result line per package and, for a package that fails, everything its tests wrote, application log lines included, instead of only passing packages.

What the diff does not show: `script/test` differs from `next` by one line. `main` has no `script/assets` yet, so it is not called. Several packages failing at once can still reach the 2 MiB limit.

- Judgement call: both commits keep their subjects from `next`, including their `closes` references.
- Deviation and not fixed here: the same two as on #415 (no `-v` rerun on failure, #315; remaining sensitivity to extreme CPU load, #225).

Model: opus-5-5
Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #416
Co-authored-by: clawbot <35+clawbot@noreply.example.org>
2026-10-03 14:08:51 +02:00
clawbot fe5e0d4173 Install ESLint and prettier with yarn 4 through corepack (closes #493)
check / check (push) Successful in 3m48s
The `js-deps` stage installed ESLint and prettier with yarn 1, which is no longer developed and made node print a `url.parse()` deprecation warning on every install. `package.json` now pins yarn 4 by version and hash in its `packageManager` field; the stage enables it through the node image's own corepack and installs with `yarn install --immutable`. `yarn.lock` is regenerated in yarn 4's format with every package at its previously locked version, and `.yarnrc.yml` keeps the install in `node_modules/`, where the lint and Markdown stages run the tools from. The install prints no warning and stays cached until the manifests change.

Model: opus-5-5
2026-10-03 07:03:31 +02:00
clawbot 9ccaa8ce01 Break long values on the webhook page and event log at phone width (closes #391)
check / check (push) Successful in 3m17s
At phone width, the event log cut off long event IDs and content types along with the statuses and times after them, and the webhook page ran long names past their cards and scrolled sideways. Elements that hold only a long value (a webhook, target or entrypoint name or description, an event ID, a content type) now break inside it, and the event log's title row wraps; rows themselves still wrap rather than squeeze. Wide screens are unchanged. A 390-pixel browser check of both pages, an event's attempts open, fails when anything runs past the page's or a card's edge or the page scrolls sideways.

Model: opus-5-5
2026-10-03 06:56:43 +02:00
clawbot 935e18c6f1 Format the Markdown with prettier in make fmt and make fmt-check (closes #215)
check / check (push) Successful in 3m28s
`make fmt` formatted only Go, so the org's Markdown settings were unenforced and Markdown was wrapped by hand. prettier, pinned in `package.json` and `yarn.lock` beside ESLint, now formats the Markdown with `.prettierrc` (4-space tabs, `proseWrap: always`). It runs only in Docker: `make fmt` writes the formatted files back without a bind mount, and `make fmt-check`, `make check` and the image build fail on unformatted Markdown. The image build's lint stage now runs the Go format check directly and no longer installs `make`. `README.md` and `TODO.md` are reformatted with no word changed: the README reflows from 72 to 80 columns.

Model: opus-5-5
2026-10-03 06:32:27 +02:00
clawbot af91d8a723 Split source_management.go along its CRUD seams (closes #274)
check / check (push) Successful in 3m22s
`internal/handlers/source_management.go` had grown to about 2,370 lines holding the webhook, event log, entrypoint and target handlers, so unrelated units had to wait on each other to touch it. It is split, as pure code movement, into files named for what they hold: `webhook_list.go`, `webhook_create.go`, `webhook_detail.go`, `webhook_edit.go`, `webhook_delete.go`, `event_log.go`, `entrypoint.go`, `target_create.go`, `target_delete.go`, `target_toggle.go` and `shared.go`. No function body, signature, comment or behaviour changed. The README's file tree and log-line caveat, and one middleware comment, name the new files.

Model: opus-5-5
2026-10-03 06:19:46 +02:00
clawbot 7e779f7fce Event log: show only the events with a failed or a pending delivery (closes #390)
check / check (push) Successful in 3m18s
The event log had no way to list only the events whose delivery failed, and once it showed only the 50 newest, an older failure could not be found at all. It now has All, Failed (N) and Pending (N) links, carried in a `show` query parameter, so they work without the page's script library. Each filtered list keeps the 50-row limit and newest-first order, and lists an event once. It finds matching deliveries through `idx_deliveries_status` and looks their events up by ID, so its cost follows the matches, not the webhook's size. Replay returns to the list it was pressed in. The heading line says what a filter counts.

Model: opus-5-5
2026-10-03 05:40:34 +02:00
clawbot 9079a3219d Show an event's entrypoint and request headers in the event log and on its page (closes #389)
check / check (push) Successful in 3m26s
The receiver stored each event's request headers and entrypoint, but no page showed them, so with several entrypoints the operator could not tell which sender sent an event. An expanded event in the event log, and the event's own page, now show the entrypoint by its description ("Entrypoint" without one, "deleted entrypoint" once deleted), never its URL, and the headers as one escaped block, sorted, whitespace kept. A resubmitted copy says the request it copies arrived there. The event log reads at most 32 KiB of headers per event, the body's limit, and links to the event's page beyond it. Both pages share one template, `event_request.html`.

Model: opus-5-5
2026-10-03 05:22:14 +02:00
clawbot b9ec91c0f7 Use one name for each thing the UI shows (closes #399)
check / check (push) Successful in 3m21s
The UI gave one thing several names. The `database` type is now Archive in the type list, on its badge and on the edit page, and its settings read "Archive expiry" and "Archive rotation" everywhere. The retry field is "Delivery attempts", with help text, errors and the target list saying it counts every attempt; a stored 0 shows as one attempt. The target list uses one capitalisation. The navbar says "Sign out", the sign-in page "Sign in", and the resubmit notice "webhook" instead of "source". The README follows. Stored values, their meaning, routes and form field names are unchanged.

Model: opus-5-5
2026-10-03 05:07:41 +02:00
clawbot 74a96b2226 Lint static/js/ with ESLint in Docker (closes #120)
check / check (push) Successful in 3m50s
`REPO_POLICIES.md` binds the repo to a JavaScript styleguide, but nothing checked `static/js/`. ESLint, pinned by `package.json` and `yarn.lock`, now lints it with the styleguide's two checkable rules, `no-var` and `prefer-const`. It runs only in Docker on a digest-pinned node image: a `js-deps` stage installs ESLint and stays cached until the manifests change, and a `js-lint` stage runs it. `script/lint` builds `js-lint`, so `make lint` and `make check` fail on a violation, and the image build depends on it as on the repo's other checks. ESLint, node and yarn are not prerequisites, and `make lint` never uses a host copy.

Model: opus-5-5
2026-10-03 04:24:11 +02:00
clawbot 8b617efa63 Rotate a database target's archive monthly, daily or hourly (closes #379)
check / check (push) Successful in 3m27s
A database target's rotation setting (none, monthly, daily or hourly) puts the UTC period of each event's receive time in its archive file name, so each file holds exactly its period's events. It is on the new webhook page and both target forms, and shown in the target list. Renames move every one of a target's files and move them all back if one fails. The sweep prunes one file at a time under the target's lock and deletes a rotated file it leaves empty. Download opens one file at a time, oldest period first, finding each again under the target's current name. The target list names the current file and totals all of them.

Model: opus-5-5
2026-10-03 04:18:37 +02:00
clawbot d8c60c9b67 Event log: show attempt and delivery times, label replays, zone event times (closes #386)
check / check (push) Successful in 3m21s
In the event log and on an event's page, attempts and deliveries showed no time, event times had no zone, and a replay looked like the original it repeated. Each attempt now shows when its result was recorded and each delivery when it was created, as how long ago with the full UTC time on hover, and the event log's event times read the same way. A delivery created by Replay records it in a new `replay` column and is labelled a replay in the event's summary line and its delivery list; replays made before this change are not labelled. A delivery's row is now drawn by one template, `delivery_row`, that both pages share.

Model: opus-5-5
2026-10-03 04:12:27 +02:00
clawbot ea8384f4a2 Event log: only the newest event expanded, and only the 50 most recent (closes #349)
check / check (push) Successful in 3m17s
The event log now loads only the 50 newest events, limited in its query, and only the newest starts expanded; the rest start collapsed. Paging is removed rather than capped, since 50 events never need a second page: the Previous and Next links, the `page` query parameter and the page number Replay and Resubmit carried back are gone, and a `?page=` left in an old link shows the 50 newest. A webhook with more than 50 events reads "50 most recent of N events" beside the heading. Comments and a README line that called `page` the only query parameter the service reads now name `next` and `notice`.

Model: opus-5-5
2026-10-03 03:43:27 +02:00
clawbot 17e6c85dd8 Name the item and what is lost in each delete prompt (closes #400)
check / check (push) Successful in 3m19s
The three delete prompts on the webhook page were generic ("Delete this target?") and named nothing. Each now names the item and says what is lost: for a webhook, its stored events, with their number (the figure the statistics pane shows), and their deliveries, while any archive files it wrote are kept; for an entrypoint, that senders using its URL get an error from now on and the URL cannot be restored; for a target, that nothing more is delivered to it while its past deliveries stay in the event log. They stay the browser's own prompts, and a name's quotes, backslashes, newlines or a closing script tag reach the prompt escaped.

Model: opus-5-5
2026-10-03 03:33:14 +02:00
clawbot bcdd4791ec Say what a database or log target's attempt did, without a status (closes #388)
check / check (push) Successful in 3m14s
In the event log and on an event's page, every attempt by a `database` or `log` target read "success  Status: — (no response)". Those targets make no HTTP request, so there is no response to have, and "no response" reads like a failed connection, which is what it means for an `http` target. A successful `database` attempt now reads "archived" and a successful `log` attempt "written to the log", with no status shown; a failed one keeps "failure" and its error line, also with no status. `http` and `slack` attempts are unchanged. The change is in the one shared attempt template.

Model: opus-5-5
2026-10-03 02:41:17 +02:00
clawbot f1da5e73dd Event log: an event's ID can be selected without toggling it (closes #348)
check / check (push) Successful in 3m20s
An event's row in the event log was a button element, whose text a browser will not let be selected, so an event's ID could not be copied. The row now has the button role instead: focusable, toggled by Enter and Space, with `aria-expanded` giving its state. A click on its text toggles the event after 500 ms, the usual double-click interval; the second press of a double- or triple-click cancels that, so selecting the ID leaves the event as it was. A drag over text does not toggle, and a click on the caret toggles at once. The browser test clicks as a person does, so toggling on the first click fails it.

Model: opus-5-5
2026-10-03 02:32:09 +02:00
clawbot d2ecb83923 Offer no Replay for a delivery to a deleted target (closes #387)
check / check (push) Successful in 3m18s
In the event log, a delivery to a deleted target still offered Replay, and pressing it answered "Recreate the target, then replay", advice that cannot work: a recreated target is a new one, and the old delivery still names the deleted one. Such a delivery now has no Replay button, and its row still names the target marked "(deleted)". The refusal, which a page loaded before the delete can still reach, now tells the operator to use Resubmit to send the event to the webhook's currently active targets.

Model: opus-5-5
2026-10-03 02:13:17 +02:00
clawbot 643077021d Show a target paused by its circuit breaker (closes #385)
check / check (push) Successful in 3m16s
A target whose circuit breaker had tripped still showed as Active, and its deliveries sat at a bare "retrying" with no attempts. Its row on the webhook page now says deliveries are paused after repeated failures and until when the cooldown ends, adding that one waiting delivery is then sent to test the target; while half-open it says deliveries are held while one tests it, with no time. Waiting deliveries show "next try no earlier than" the later of the cooldown and their own backoff, with the date when not today. The engine gains one read of a breaker's state and remaining cooldown under one lock, and shares the backoff formula.

Model: opus-5-5
2026-10-03 02:06:19 +02:00
clawbot 22fa502638 Remove an http target's max_queue_size (closes #477)
check / check (push) Successful in 3m18s
An http target's max_queue_size was stored and shown in the target list as "Max Queue Size", but nothing in the delivery engine read it, so an operator who set it expecting deliveries to be bounded got nothing. It is removed from the target, the target list and the README's target table; neither form had a field for it. Nothing checks for a leftover value. An existing database keeps its old column, which is no longer read.

Model: opus-5-5
2026-10-03 01:39:13 +02:00
clawbot 6395210474 Show each page's own title in the browser tab (closes #117)
check / check (push) Successful in 3m17s
Every browser tab read "Webhooker": parsePageTemplate parsed each page before htmlheader.html, whose {{block "title"}} fallback then redefined the page's {{define "title"}}. The page file is now parsed last, so its title replaces the fallback (a later definition of a template name replaces an earlier one, and an empty one never does). A test renders every page template and checks its browser tab title.

Model: opus-5-5
2026-10-03 01:30:02 +02:00
clawbot 3489d6909a Offer archive expiry choices on the target forms, show plain units (closes #396)
check / check (push) Successful in 3m11s
A database target's archive expiry was typed by hand as never or a raw duration such as 720h, and the target list showed it back raw. Adding or editing a database target now offers the new-webhook page's list of choices (never, 1h, 12h, 24h, 30d, 90d, 365d), defined once and shared by all three forms. The edit form starts on the stored expiry, or on the submitted one after a refused save; a stored value outside the choices is listed under its own value, so saving unchanged keeps it. The target list shows the expiry in plain units: "30 days", "12 hours", "never".

Model: opus-5-5
2026-10-03 01:16:14 +02:00
clawbot f282c6363d Keep what was typed when a target or webhook edit is refused (closes #381)
check / check (push) Successful in 3m17s
A refused save on the target edit page answered with a bare text page, losing the form and everything typed, and the webhook edit page came back with the stored values instead of the submitted ones. A refused target edit now shows the edit form again with the reason above it and every value submitted, with the same status codes as before; a refused webhook edit keeps the submitted name, description and retention. Target edits use the same validation as new targets, with no second copy; an encoding or database failure stays a logged 500. The browser test covers a refused save on both pages, and its main function is now a plain list of checks.

Model: opus-5-5
2026-10-03 00:51:56 +02:00
clawbot 61371d388e Pin tailwindcss and check the committed stylesheet against it (closes #231)
check / check (push) Successful in 3m25s
make css ran whatever tailwindcss binary was on the host's PATH, so the committed stylesheet depended on the machine that built it, and nothing noticed when a template used a class the stylesheet lacked. make css now runs the standalone tailwindcss v4.2.1, pinned by sha256, in a Dockerfile stage, and a check stage, run by make check and required by the image build, fails when the committed static/css/tailwind.css differs from what the templates need, showing the differing rules. input.css names its sources. The unused .btn-text is removed and the stylesheet regenerated, dropping only unused rules. The README has a Stylesheet section.

Model: opus-5-5
2026-10-03 00:30:57 +02:00
clawbot 19a6705c63 New-webhook page: optional HTTP target URL and archive with pruning (closes #373)
check / check (push) Successful in 3m19s
The new-webhook page gains an optional HTTP target URL, which creates an http target named HTTP, and an archive checkbox whose pruning choice (never, 1h, 12h, 24h, 30d, 90d, 365d) creates a database target named Archive with that expiry. Both are validated by the add target form's own validation, and the webhook, its entrypoint and its targets are created in one transaction or not at all. A refused form comes back with the reason and every value entered, retention included. The targets can be renamed on the webhook page like any other.

Model: opus-5-5
2026-10-03 00:21:56 +02:00
clawbot 93911f28f9 Show a slack target's retry setting in the target list (closes #395)
check / check (push) Successful in 3m24s
A slack target's edit page offers Max Retries and the delivery engine honours it, but the target list showed only its masked webhook URL, so setting retries changed nothing visible. The list now shows a slack target's Max Retries line exactly as an http target's, from the one function both use, so the label and the "0 (fire-and-forget)" wording cannot drift apart. The Max Queue Size line stays on http targets only. Tests cover a slack target with retries set, and one with a queue size stored that shows no queue-size line.

Model: opus-5-5
2026-10-03 00:19:13 +02:00
clawbot 9305af4f85 Show each target's delivered and failed deliveries in the target list (closes #372)
check / check (push) Successful in 3m17s
The target list showed nothing about how a target's deliveries were going. Each target now shows Delivered and Failed, each in total and in the last 24 hours. The totals are the per-target running totals kept for the statistics pane, so retention does not reduce them; the 24-hour figures are one count over the deliveries' final-status index, for all of the webhook's targets at once. Pending and retrying deliveries count in neither. If the event database cannot be read, each row says so instead of showing zeros. The archive details and Download button on database targets are kept.

Model: opus-5-5
2026-10-02 23:47:16 +02:00
clawbot ff24638ba4 Show each entrypoint's last event and event count on the webhook page (closes #393)
check / check (push) Successful in 3m12s
The entrypoint list showed no sign of whether anything uses an entrypoint, so an operator with several could not tell which senders are live before deactivating or deleting one. Each entrypoint now shows when its last event arrived, relative with the UTC time on hover, or "never", and how many events arrived through it within the webhook's retention. The last-event time comes from a new entrypoint_totals row written in the transaction that stores the event and left by retention, so a sender quieter than the retention period does not read "never". The count is one grouped query over a new index. Resubmitted copies count in neither. Pre-1.0: schema changed in place.

Model: opus-5-5
2026-10-02 23:16:07 +02:00
sneak f703b72ce0 Next (#364)
check / check (push) Successful in 3m59s
Reviewed-on: #364
2026-09-29 13:05:57 +02:00
118 changed files with 12181 additions and 5638 deletions
+3
View File
@@ -15,6 +15,9 @@ bin/
# Extracted from 3p/ by `make assets` inside the build; a host copy is not # Extracted from 3p/ by `make assets` inside the build; a host copy is not
# needed. The tarball in 3p/ must stay in the context. # needed. The tarball in 3p/ must stay in the context.
static/js/alpine.min.js static/js/alpine.min.js
# The js-deps stage installs ESLint and prettier; a host copy would overwrite
# them at the `COPY . .` of the stages built on it.
node_modules/
.env .env
.env.* .env.*
*.db *.db
+4 -4
View File
@@ -28,10 +28,10 @@ jobs:
- name: Fingerprint the build context - name: Fingerprint the build context
# Writes the hash of the commit being checked into the context, which # Writes the hash of the commit being checked into the context, which
# invalidates the `COPY . .` layer of both check stages: a commit # invalidates the `COPY . .` layer of every check stage: a commit
# that was never linted, format-checked, tested and built cannot # that was never linted, format-checked, stylesheet-checked, tested
# report success from cache. # and built cannot report success from cache.
run: git rev-parse HEAD > .ci-fingerprint run: git rev-parse HEAD > .ci-fingerprint
- name: Build Docker image (runs make fmt-check, golangci-lint, make test, make build) - name: Build Docker image (runs the gofmt check, golangci-lint, the stylesheet check, ESLint, the Markdown check, make test, make build)
run: script/cibuild run: script/cibuild
+3
View File
@@ -15,6 +15,9 @@ bin/
# Go vendor directory # Go vendor directory
vendor/ vendor/
# ESLint, prettier and their dependencies, installed from yarn.lock
node_modules/
# IDE specific files # IDE specific files
.idea/ .idea/
*.swp *.swp
+4
View File
@@ -0,0 +1,4 @@
{
"tabWidth": 4,
"proseWrap": "always"
}
+3
View File
@@ -0,0 +1,3 @@
# Install into node_modules/: the Dockerfile's lint and Markdown stages run
# ESLint and prettier from node_modules/.bin.
nodeLinker: node-modules
+88 -9
View File
@@ -4,8 +4,6 @@
# compile on Alpine musl (off64_t is a glibc type). # compile on Alpine musl (off64_t is a glibc type).
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
RUN apt-get update && apt-get install -y --no-install-recommends make && rm -rf /var/lib/apt/lists/*
WORKDIR /src WORKDIR /src
# Copy go mod files first for better layer caching # Copy go mod files first for better layer caching
@@ -19,24 +17,105 @@ RUN go mod download
# .dockerignore. # .dockerignore.
COPY . . COPY . .
# Run formatting check and linter. golangci-lint is invoked directly rather # Run the Go formatting check and the linter. gofmt and golangci-lint are
# than through `make lint`: this stage is already the pinned linter image, and # invoked directly rather than through `make fmt-check` and `make lint`: this
# script/lint is a wrapper that builds Dockerfile.lint, so calling it here # stage is already the pinned linter image, and both scripts build docker
# would need a docker daemon inside the build. Keep these steps in step with # stages, so calling them here would need a docker daemon inside the build.
# Dockerfile.lint, including --network=none (see its header for why). # The Markdown half of `make fmt-check` is the markdown-check stage below.
RUN make fmt-check # Keep the golangci-lint steps in step with Dockerfile.lint, including
# --network=none (see its header for why).
RUN if [ -n "$(gofmt -s -l .)" ]; then echo "gofmt needed on:"; gofmt -s -l .; exit 1; fi
RUN script/assets RUN script/assets
RUN --network=none golangci-lint config verify --config .golangci.yml RUN --network=none golangci-lint config verify --config .golangci.yml
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./... RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
# Stylesheet stages. static/css/tailwind.css is generated, by this pinned
# tailwindcss, from static/css/input.css and the files its @source lines
# name. `make css` (script/css) writes it out from the css-output stage.
# The css-check stage fails when the committed file differs from what is
# generated; `make check` runs it, and so does the build stage below.
#
# tailwindcss v4.2.1 standalone CLI, released 2026-02-23: one binary per
# architecture, each pinned by its sha256 from the release's sha256sums.txt.
# debian:bookworm-slim, 2026-10-02: the binary needs glibc.
FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 AS tailwind-amd64
ADD --checksum=sha256:39e8d4e24b3c83b0a6e69e100a972fbc75d5fef8dce47b3ddac3cf92dea81fe3 --chmod=755 \
https://github.com/tailwindlabs/tailwindcss/releases/download/v4.2.1/tailwindcss-linux-x64 /usr/local/bin/tailwindcss
FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 AS tailwind-arm64
ADD --checksum=sha256:d87e6486bb3f70b04ef1dcaacc4ee6548a5a15fbf521b31bc24d2c774f68a951 --chmod=755 \
https://github.com/tailwindlabs/tailwindcss/releases/download/v4.2.1/tailwindcss-linux-arm64 /usr/local/bin/tailwindcss
# TARGETARCH, set by docker, is the architecture being built for.
FROM tailwind-${TARGETARCH} AS css
WORKDIR /src
COPY . .
RUN tailwindcss -i static/css/input.css -o /out/tailwind.css --minify
FROM scratch AS css-output
COPY --from=css /out/tailwind.css /
# Both files are split after each "}", one rule per line, so that when they
# differ the diff shows the rules that differ.
FROM css AS css-check
RUN sed 's/}/}\n/g' static/css/tailwind.css > /tmp/committed.css \
&& sed 's/}/}\n/g' /out/tailwind.css > /tmp/generated.css \
&& diff -U0 /tmp/committed.css /tmp/generated.css || { \
echo "static/css/tailwind.css is not what make css generates; run make css" >&2; \
exit 1; \
}
# JavaScript lint stages: ESLint, at the version package.json and yarn.lock
# pin, checks static/js/ against eslint.config.mjs. js-deps installs it, and
# prettier for the Markdown stages below, and stays cached until package.json,
# yarn.lock or .yarnrc.yml changes. script/lint forces only js-lint to re-run,
# and the build stage below runs it too. COPY . . brings in the CI cache
# barrier described in the lint stage above.
#
# The image's own corepack runs the yarn that package.json's packageManager
# field names, yarn 4.18.1 (released 2026-09-24), and checks it against the
# hash there. The image also ships yarn 1, which `corepack enable yarn`
# replaces.
# node:24.21.0-alpine (LTS), 2026-09-18
FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS js-deps
WORKDIR /src
COPY package.json yarn.lock .yarnrc.yml ./
RUN corepack enable yarn && yarn install --immutable --mode=skip-build
FROM js-deps AS js-lint
COPY . .
RUN --network=none node_modules/.bin/eslint static/js
# Markdown stages: prettier, at the version package.json and yarn.lock pin,
# formats every Markdown file in the tree with the settings in .prettierrc.
# `make fmt` (script/fmt) writes the formatted files out from markdown-output.
# markdown-check fails on any file prettier would change; `make fmt-check`
# runs it, and so does the build stage below.
FROM js-deps AS markdown
COPY . .
RUN --network=none node_modules/.bin/prettier --write '**/*.md' \
&& mkdir /out \
&& find . -name '*.md' ! -path './node_modules/*' -exec cp -p --parents {} /out \;
FROM scratch AS markdown-output
COPY --from=markdown /out /
FROM js-deps AS markdown-check
COPY . .
RUN --network=none node_modules/.bin/prettier --check '**/*.md'
# Build stage # Build stage
# golang:1.26.1-bookworm (Debian-based), 2026-03-17 # golang:1.26.1-bookworm (Debian-based), 2026-03-17
# Using Debian-based image because gorm.io/driver/sqlite pulls in # Using Debian-based image because gorm.io/driver/sqlite pulls in
# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl. # mattn/go-sqlite3 (CGO), which does not compile on Alpine musl.
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder
# Depend on lint stage passing # Depend on the lint, stylesheet check, JavaScript lint and Markdown check
# stages passing
COPY --from=lint /src/go.sum /dev/null COPY --from=lint /src/go.sum /dev/null
COPY --from=css-check /out/tailwind.css /dev/null
COPY --from=js-lint /src/yarn.lock /dev/null
COPY --from=markdown-check /src/yarn.lock /dev/null
# jq is a runtime dependency of script/ci-mark-superseded, which the test # jq is a runtime dependency of script/ci-mark-superseded, which the test
# suite executes. git is what script/version derives the version with. # suite executes. git is what script/version derives the version with.
+5 -2
View File
@@ -1,4 +1,4 @@
.PHONY: bootstrap setup assets test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css version .PHONY: bootstrap setup assets test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css css-check version
# Default target # Default target
.DEFAULT_GOAL := check .DEFAULT_GOAL := check
@@ -74,4 +74,7 @@ hooks:
@script/install-precommit @script/install-precommit
css: css:
tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify @script/css
css-check:
@script/css-check
+2197 -2242
View File
File diff suppressed because it is too large Load Diff
+313 -349
View File
@@ -2,403 +2,367 @@
One issue per unit of work, one branch and one PR per issue: One issue per unit of work, one branch and one PR per issue:
* ensure a tracked issue exists with a definition of done - ensure a tracked issue exists with a definition of done
* branch from `next` (never from `main`) - branch from `next` (never from `main`)
* do the work; open a PR based on `next` (never on `main`) - do the work; open a PR based on `next` (never on `main`)
* pass an independent review, then the manager squash-merges into `next` - pass an independent review, then the manager squash-merges into `next`
* push; nothing stays local-only - push; nothing stays local-only
`next` is the branch for the next milestone and must stay green and `next` is the branch for the next milestone and must stay green and mergeable to
mergeable to `main` without notice. One `next` -> `main` PR accumulates `main` without notice. One `next` -> `main` PR accumulates the milestone;
the milestone; releases are cut from `main` separately. releases are cut from `main` separately.
Issue branches do NOT touch this file — the manager maintains it on Issue branches do NOT touch this file — the manager maintains it on `next`.
`next`. Every branch editing `TODO.md` conflicts with every other Every branch editing `TODO.md` conflicts with every other (#112).
(#112).
# Status # Status
The milestone (https://git.eeqj.de/sneak/webhooker/milestone/9) is the The milestone (https://git.eeqj.de/sneak/webhooker/milestone/9) is the
authoritative list, and the only place to read a count or a state of authoritative list, and the only place to read a count or a state of play from.
play from. This file records where the project is, not what is in This file records where the project is, not what is in flight: a sentence whose
flight: a sentence whose truth depends on a branch being unmerged is truth depends on a branch being unmerged is wrong the moment it merges, and this
wrong the moment it merges, and this file has been wrong that way file has been wrong that way before.
before.
The durability defect that held the tag has landed The durability defect that held the tag has landed
(https://git.eeqj.de/sneak/webhooker/issues/256, commit `8d64259`). (https://git.eeqj.de/sneak/webhooker/issues/256, commit `8d64259`). Every SQLite
Every SQLite handle opens with WAL journaling and a busy timeout, a handle opens with WAL journaling and a busy timeout, a bookkeeping write that
bookkeeping write that fails leaves its delivery in a recoverable fails leaves its delivery in a recoverable state rather than a lying one, and
state rather than a lying one, and recovery skips a delivery that recovery skips a delivery that already has a successful result row. Final
already has a successful result row. Final pre-tag verification pre-tag verification exercised it and confirmed it holds. Whatever the milestone
exercised it and confirmed it holds. Whatever the milestone still still shows open is what remains before `v1.0.0`.
shows open is what remains before `v1.0.0`.
Delivery is at-least-once by design, not by accident: a send whose Delivery is at-least-once by design, not by accident: a send whose result row
result row does not land is attempted again, so a receiver can see a does not land is attempted again, so a receiver can see a duplicate. That is
duplicate. That is deliberate — the alternative is a silent lost deliberate — the alternative is a silent lost delivery — and the README says so
delivery — and the README says so under Rationale. It is not a defect under Rationale. It is not a defect to re-file.
to re-file.
# Next Step # Next Step
Clear the rest of the open 1.0.0 milestone Clear the rest of the open 1.0.0 milestone
(https://git.eeqj.de/sneak/webhooker/milestone/9) and tag `v1.0.0`. (https://git.eeqj.de/sneak/webhooker/milestone/9) and tag `v1.0.0`. Merging
Merging `next` into `main` is a separate act from tagging and waits on `next` into `main` is a separate act from tagging and waits on neither of those:
neither of those: `next` is kept mergeable at all times, which is the `next` is kept mergeable at all times, which is the point of the branch.
point of the branch.
# Completed Steps # Completed Steps
- 2026-08-24 Bind the plaintext HTTP listener deliberately, via - 2026-08-24 Bind the plaintext HTTP listener deliberately, via `BIND_ADDRESS`
`BIND_ADDRESS` defaulting to `127.0.0.1`, and document the defaulting to `127.0.0.1`, and document the reverse-proxy deployment. A
reverse-proxy deployment. A hostname, an empty value or a value hostname, an empty value or a value carrying a port is a startup error, and
carrying a port is a startup error, and the `Dockerfile` sets the `Dockerfile` sets `0.0.0.0` because a loopback bind inside a container is
`0.0.0.0` because a loopback bind inside a container is unreachable unreachable (https://git.eeqj.de/sneak/webhooker/issues/268). The same commit
(https://git.eeqj.de/sneak/webhooker/issues/268). The same commit removed the shutdown race: `httpServer` is built in the constructor rather
removed the shutdown race: `httpServer` is built in the constructor than assigned from the serving goroutine, which orders the write before every
rather than assigned from the serving goroutine, which orders the fx hook and rules out the nil dereference a SIGTERM arriving first would have
write before every fx hook and rules out the nil dereference a caused, and `sentryEnabled` is an `atomic.Bool`
SIGTERM arriving first would have caused, and `sentryEnabled` is an (https://git.eeqj.de/sneak/webhooker/issues/226)
`atomic.Bool` (https://git.eeqj.de/sneak/webhooker/issues/226) - 2026-08-24 Remove inbound request signature verification. The entrypoint UUID
- 2026-08-24 Remove inbound request signature verification. The is the authentication secret, so the per-entrypoint shared secret, the
entrypoint UUID is the authentication secret, so the per-entrypoint `internal/signature` package, the receiver check, the model fields and the
shared secret, the `internal/signature` package, the receiver check, forms are all gone. This reverses the feature that landed earlier in the same
the model fields and the forms are all gone. This reverses the milestone (https://git.eeqj.de/sneak/webhooker/issues/67,
feature that landed earlier in the same milestone
(https://git.eeqj.de/sneak/webhooker/issues/67,
https://git.eeqj.de/sneak/webhooker/issues/279) https://git.eeqj.de/sneak/webhooker/issues/279)
- 2026-08-24 Stamp the build version into the binary and render it in - 2026-08-24 Stamp the build version into the binary and render it in the UI
the UI footer. `script/version` is the single source — `$VERSION`, footer. `script/version` is the single source — `$VERSION`, else
else `git describe --tags --always --dirty`, else `unknown` — so a `git describe --tags --always --dirty`, else `unknown` — so a `make build`
`make build` binary and a `make docker` image from one checkout binary and a `make docker` image from one checkout report the same thing, and
report the same thing, and nothing in it varies between two builds nothing in it varies between two builds of the same commit, which the release
of the same commit, which the release gate's byte-identical gate's byte-identical assertion would catch
assertion would catch
(https://git.eeqj.de/sneak/webhooker/issues/253) (https://git.eeqj.de/sneak/webhooker/issues/253)
- 2026-08-24 Derive cookie `Secure` and CSRF strictness from the - 2026-08-24 Derive cookie `Secure` and CSRF strictness from the request
request transport rather than from `WEBHOOKER_ENVIRONMENT`. Behind a transport rather than from `WEBHOOKER_ENVIRONMENT`. Behind a real TLS proxy
real TLS proxy with the environment left at its `dev` default, the with the environment left at its `dev` default, the session cookie silently
session cookie silently lost `Secure` while the CSRF cookie on the lost `Secure` while the CSRF cookie on the same response kept it.
same response kept it. `X-Forwarded-Proto` is now matched `X-Forwarded-Proto` is now matched case-insensitively on its first
case-insensitively on its first comma-separated element, so `HTTPS` comma-separated element, so `HTTPS` and `https, http` no longer fall to the
and `https, http` no longer fall to the relaxed CSRF path relaxed CSRF path (https://git.eeqj.de/sneak/webhooker/issues/269)
(https://git.eeqj.de/sneak/webhooker/issues/269) - 2026-08-24 Roll back a failed webhook deletion instead of committing it. A
- 2026-08-24 Roll back a failed webhook deletion instead of committing failing delete committed whatever had already succeeded, hard-deleted the
it. A failing delete committed whatever had already succeeded, per-webhook event database anyway, and redirected as though it had worked —
hard-deleted the per-webhook event database anyway, and redirected as orphaned config plus permanently destroyed history, reported as success. All
though it had worked — orphaned config plus permanently destroyed three delete positions now roll back with the event database intact
history, reported as success. All three delete positions now roll
back with the event database intact
(https://git.eeqj.de/sneak/webhooker/issues/262) (https://git.eeqj.de/sneak/webhooker/issues/262)
- 2026-08-24 Name a deleted target on its historical deliveries, marked - 2026-08-24 Name a deleted target on its historical deliveries, marked
`(deleted)`, rather than leaving the event log unable to say where a `(deleted)`, rather than leaving the event log unable to say where a delivery
delivery went. A deleted target's credentials stay masked exactly as went. A deleted target's credentials stay masked exactly as a live one's, and
a live one's, and it cannot become deliverable again through the it cannot become deliverable again through the receiver, resubmit, replay, the
receiver, resubmit, replay, the edit form or the toggle edit form or the toggle (https://git.eeqj.de/sneak/webhooker/issues/211)
(https://git.eeqj.de/sneak/webhooker/issues/211) - 2026-08-24 Bound both request-controlled `/metrics` label dimensions, so the
- 2026-08-24 Bound both request-controlled `/metrics` label dimensions, unauthenticated receiver is no longer a memory-exhaustion vector: `handler`
so the unauthenticated receiver is no longer a memory-exhaustion carries the chi route pattern, and `method` folds anything chi cannot route
vector: `handler` carries the chi route pattern, and `method` folds onto a single `(unmatched)` sentinel. Both were reproduced before the fix —
anything chi cannot route onto a single `(unmatched)` sentinel. Both 300 random method tokens took the series count from 106 to 7,631, and path
were reproduced before the fix — 300 random method tokens took the flooding reached 62,532 — and a label audit across a live scrape found no
series count from 106 to 7,631, and path flooding reached 62,532 — third unbounded dimension (https://git.eeqj.de/sneak/webhooker/issues/254,
and a label audit across a live scrape found no third unbounded
dimension (https://git.eeqj.de/sneak/webhooker/issues/254,
https://git.eeqj.de/sneak/webhooker/issues/261) https://git.eeqj.de/sneak/webhooker/issues/261)
- 2026-08-24 Validate `max_retries` on both target forms. `abc`, `2.7` - 2026-08-24 Validate `max_retries` on both target forms. `abc`, `2.7` and `-5`
and `-5` silently became 0 — fire-and-forget — including on the edit silently became 0 — fire-and-forget — including on the edit path, where it
path, where it destroyed a working value, and `999999999` stored destroyed a working value, and `999999999` stored verbatim. The ceiling of 20
verbatim. The ceiling of 20 is the `max` both templates already is the `max` both templates already declared
declared (https://git.eeqj.de/sneak/webhooker/issues/221) (https://git.eeqj.de/sneak/webhooker/issues/221)
- 2026-08-24 Resubmit a stored event as a new undelivered event, so a - 2026-08-24 Resubmit a stored event as a new undelivered event, so a backend
backend under development can be tested against real captured under development can be tested against real captured traffic. Per-delivery
traffic. Per-delivery replay cannot serve that: it re-sends one replay cannot serve that: it re-sends one finished delivery to its own
finished delivery to its own original target, and a target created original target, and a target created for a dev backend has no prior delivery
for a dev backend has no prior delivery to replay. Resubmit to replay. Resubmit re-injects the stored event at the top of the receiver
re-injects the stored event at the top of the receiver path and fans path and fans it out to whatever targets are active now
it out to whatever targets are active now
(https://git.eeqj.de/sneak/webhooker/issues/250) (https://git.eeqj.de/sneak/webhooker/issues/250)
- 2026-08-20 Take an exclusive lock on `DATA_DIR` at startup, so two - 2026-08-20 Take an exclusive lock on `DATA_DIR` at startup, so two instances
instances on one directory cannot both deliver on one directory cannot both deliver
(https://git.eeqj.de/sneak/webhooker/issues/201) (https://git.eeqj.de/sneak/webhooker/issues/201)
- 2026-08-20 Shut down the app when the HTTP listener fails. The - 2026-08-20 Shut down the app when the HTTP listener fails. The `OnStart` hook
`OnStart` hook returned as soon as the serving goroutine was returned as soon as the serving goroutine was spawned, so a failed listen left
spawned, so a failed listen left fx reporting RUNNING and a live fx reporting RUNNING and a live process with nothing bound — invisible to
process with nothing bound — invisible to systemd and Docker restart systemd and Docker restart policies
policies (https://git.eeqj.de/sneak/webhooker/issues/200) (https://git.eeqj.de/sneak/webhooker/issues/200)
- 2026-08-20 Stop target credentials leaking into the per-webhook event - 2026-08-20 Stop target credentials leaking into the per-webhook event
databases (https://git.eeqj.de/sneak/webhooker/issues/206), log SQL databases (https://git.eeqj.de/sneak/webhooker/issues/206), log SQL with
with placeholders rather than bound values placeholders rather than bound values
(https://git.eeqj.de/sneak/webhooker/issues/207), and fail loudly on (https://git.eeqj.de/sneak/webhooker/issues/207), and fail loudly on half-set
half-set metrics auth credentials metrics auth credentials (https://git.eeqj.de/sneak/webhooker/issues/205)
(https://git.eeqj.de/sneak/webhooker/issues/205) - 2026-08-20 Read queue depths with `Find`, not `Scan`. `Scan` swaps GORM's own
- 2026-08-20 Read queue depths with `Find`, not `Scan`. `Scan` swaps trace recorder in for the logging adapter, and that recorder does not
GORM's own trace recorder in for the logging adapter, and that implement `gorm.ParamsFilter`, so those statements logged their bound values
recorder does not implement `gorm.ParamsFilter`, so those statements interpolated and bypassed the suppression above. The two units gated green
logged their bound values interpolated and bypassed the suppression against a `next` that lacked the other, and `next` went red when both landed
above. The two units gated green against a `next` that lacked the
other, and `next` went red when both landed
(https://git.eeqj.de/sneak/webhooker/issues/234) (https://git.eeqj.de/sneak/webhooker/issues/234)
- 2026-08-20 Render per-attempt delivery detail in the event log - 2026-08-20 Render per-attempt delivery detail in the event log
(https://git.eeqj.de/sneak/webhooker/issues/202) and add replay of a (https://git.eeqj.de/sneak/webhooker/issues/202) and add replay of a
terminally failed delivery terminally failed delivery (https://git.eeqj.de/sneak/webhooker/issues/203)
(https://git.eeqj.de/sneak/webhooker/issues/203)
- 2026-08-20 Expose delivery metrics on `/metrics` - 2026-08-20 Expose delivery metrics on `/metrics`
(https://git.eeqj.de/sneak/webhooker/issues/209) and document the (https://git.eeqj.de/sneak/webhooker/issues/209) and document the backup,
backup, restore and upgrade procedures restore and upgrade procedures
(https://git.eeqj.de/sneak/webhooker/issues/210) (https://git.eeqj.de/sneak/webhooker/issues/210)
- 2026-08-20 Add a `webhooker resetpw` subcommand and a bootstrap - 2026-08-20 Add a `webhooker resetpw` subcommand and a bootstrap banner. The
banner. The admin bootstrap password was printed once among roughly admin bootstrap password was printed once among roughly 45 fx lines, and under
45 fx lines, and under `docker run -d` went to container logs subject `docker run -d` went to container logs subject to rotation; there was no reset
to rotation; there was no reset path at all, so recovery meant path at all, so recovery meant hand-deleting the users row, documented
hand-deleting the users row, documented nowhere. The password is read nowhere. The password is read from stdin or generated, never from argv where
from stdin or generated, never from argv where `/proc` would publish `/proc` would publish it (https://git.eeqj.de/sneak/webhooker/issues/208)
it (https://git.eeqj.de/sneak/webhooker/issues/208) - 2026-08-20 Add `ALLOWED_EGRESS_CIDRS`, an allowlist-only escape hatch for the
- 2026-08-20 Add `ALLOWED_EGRESS_CIDRS`, an allowlist-only escape hatch SSRF guard, so a self-hosted proxy can forward into the operator's own
for the SSRF guard, so a self-hosted proxy can forward into the network. The guard's always-blocked set cannot be reopened by configuration
operator's own network. The guard's always-blocked set cannot be
reopened by configuration
(https://git.eeqj.de/sneak/webhooker/issues/204) (https://git.eeqj.de/sneak/webhooker/issues/204)
- 2026-08-20 Harden operator-set target headers, which were carried - 2026-08-20 Harden operator-set target headers, which were carried unsafely
unsafely across a redirect across a redirect (https://git.eeqj.de/sneak/webhooker/issues/233)
(https://git.eeqj.de/sneak/webhooker/issues/233)
- 2026-08-20 Add a target edit form with headers and timeout fields - 2026-08-20 Add a target edit form with headers and timeout fields
(https://git.eeqj.de/sneak/webhooker/issues/127) (https://git.eeqj.de/sneak/webhooker/issues/127)
- 2026-08-18 Raise `script/test`'s per-package timeout from 30s to 90s, - 2026-08-18 Raise `script/test`'s per-package timeout from 30s to 90s, matching
matching the org-wide backstop. `go test` applies `-timeout` per the org-wide backstop. `go test` applies `-timeout` per package, and
package, and `internal/handlers` had grown past the old budget: a `internal/handlers` had grown past the old budget: a cache-defeated build
cache-defeated build failed outright at `GOMAXPROCS=4`, and every run failed outright at `GOMAXPROCS=4`, and every run under deliberate host load
under deliberate host load breached 30s. The measurement table lives breached 30s. The measurement table lives in the script (#194)
in the script (#194) - 2026-08-18 Re-sync `REPO_POLICIES.md` from `prompts`. The local copy was stale
- 2026-08-18 Re-sync `REPO_POLICIES.md` from `prompts`. The local copy and still mandated a 20s test target with a 30s timeout, which the org
was stale and still mandated a 20s test target with a 30s timeout, replaced with a 60s cap and a 90s backstop. A synced copy is not a source;
which the org replaced with a 60s cap and a 90s backstop. A synced reading it as one nearly produced a PR against `prompts` proposing a change
copy is not a source; reading it as one nearly produced a PR against already merged there (#196)
`prompts` proposing a change already merged there (#196) - 2026-08-18 Report handler panics through the logger and answer 500. chi
- 2026-08-18 Report handler panics through the logger and answer 500. v1.5.5's `Recoverer` scans for a `panic(0x` frame the runtime no longer emits,
chi v1.5.5's `Recoverer` scans for a `panic(0x` frame the runtime no then indexes `pkg[-1:]`, so it panicked inside its own stack printer before
longer emits, then indexes `pkg[-1:]`, so it panicked inside its own writing a byte: the recovery never ran, the client got a dropped connection
stack printer before writing a byte: the recovery never ran, the instead of a 500, and the original panic was lost. A local middleware replaces
client got a dropped connection instead of a 500, and the original it, bounded by `MaxPanicLogLineBytes` (#187)
panic was lost. A local middleware replaces it, bounded by - 2026-08-18 Route GORM's logger through `slog` and bound it. Every `gorm.Open`
`MaxPanicLogLineBytes` (#187) left `logger.Default` in place at `Warn` with `IgnoreRecordNotFoundError`
- 2026-08-18 Route GORM's logger through `slog` and bound it. Every false, so **every record-not-found printed the fully interpolated SQL to
`gorm.Open` left `logger.Default` in place at `Warn` with stdout** — including the client-chosen path on `/webhook/{uuid}` and the
`IgnoreRecordNotFoundError` false, so **every record-not-found submitted username on the login form, at no level the operator set and outside
printed the fully interpolated SQL to stdout** — including the `internal/logger` entirely. Three call sites, not the two the issue named
client-chosen path on `/webhook/{uuid}` and the submitted username on (#178)
the login form, at no level the operator set and outside - 2026-08-18 Bound every `slog` line against client-chosen text. Eight sites
`internal/logger` entirely. Three call sites, not the two the issue reachable unauthenticated, found by reading every `slog` call in the tree
named (#178) rather than only the one reported; the budget moved to a shared
- 2026-08-18 Bound every `slog` line against client-chosen text. Eight `internal/logfield` so no second truncation exists. `DEBUG` being off by
sites reachable unauthenticated, found by reading every `slog` call in default is not a bound and is not treated as one (#176)
the tree rather than only the one reported; the budget moved to a - 2026-08-18 Stop a slow host turning a login-guard test into a segfault. A
shared `internal/logfield` so no second truncation exists. `DEBUG` non-fatal `assert` on an acquire result was dereferenced on the next line, so
being off by default is not a bound and is not treated as one (#176) one timing miss killed the whole `internal/middleware` binary and reddened CI
- 2026-08-18 Stop a slow host turning a login-guard test into a for unrelated PRs. The fix also removed a real production race — `acquire`
segfault. A non-fatal `assert` on an acquire result was dereferenced could shed a request with a slot standing free, because Go picks uniformly
on the next line, so one timing miss killed the whole among ready `select` cases (#186)
`internal/middleware` binary and reddened CI for unrelated PRs. The - 2026-08-18 Send the chi route pattern to Sentry rather than the concrete path.
fix also removed a real production race — `acquire` could shed a The receiver's path carries the entrypoint capability token, so every Sentry
request with a slot standing free, because Go picks uniformly among event from `/webhook/{uuid}` shipped a live credential to a third party.
ready `select` cases (#186) Request `Data`, `QueryString`, `Cookies` and `Env` are dropped and headers
- 2026-08-18 Send the chi route pattern to Sentry rather than the reduced to an allowlist (#179)
concrete path. The receiver's path carries the entrypoint capability - 2026-08-18 Read form fields from the POST body only. `r.FormValue` merges the
token, so every Sentry event from `/webhook/{uuid}` shipped a live query string, so a login could be driven by URL parameters — putting the
credential to a third party. Request `Data`, `QueryString`, `Cookies` password somewhere that lands in access logs, proxy logs and browser history
and `Env` are dropped and headers reduced to an allowlist (#179) (#160)
- 2026-08-18 Read form fields from the POST body only. `r.FormValue` - 2026-08-18 Verify login credentials before spending rate-limit budget, so a
merges the query string, so a login could be driven by URL parameters flood of wrong passwords cannot lock out the account it is guessing at. The
— putting the password somewhere that lands in access logs, proxy manager took this decision rather than stall the queue; it is flagged on the
logs and browser history (#160) issue for reversal (#150)
- 2026-08-18 Verify login credentials before spending rate-limit - 2026-08-18 Run all linting in Docker via `Dockerfile.lint`. Host lint was
budget, so a flood of wrong passwords cannot lock out the account it wrong in both directions from version skew and shared caches. `script/lint`
is guessing at. The manager took this decision rather than stall the asserts the summary line, because `--no-cache-filter` silently ignores a stage
queue; it is flagged on the issue for reversal (#150) name it does not match — the flag that makes the gate meaningful fails open
- 2026-08-18 Run all linting in Docker via `Dockerfile.lint`. Host lint (#109)
was wrong in both directions from version skew and shared caches. - 2026-08-18 Serve an event's full stored body over HTTP. The list query
`script/lint` asserts the summary line, because `--no-cache-filter` truncates for rendering, and that truncated value was the only way to read a
silently ignores a stage name it does not match — the flag that makes body, so the full payload was unreachable (#157)
the gate meaningful fails open (#109)
- 2026-08-18 Serve an event's full stored body over HTTP. The list
query truncates for rendering, and that truncated value was the only
way to read a body, so the full payload was unreachable (#157)
- 2026-08-18 Bound the access log line against client-chosen text. - 2026-08-18 Bound the access log line against client-chosen text.
`internal/logfield` budgets by *encoded* bytes, not runes, so a `internal/logfield` budgets by _encoded_ bytes, not runes, so a handler's JSON
handler's JSON escaping cannot multiply a field past its allowance escaping cannot multiply a field past its allowance (#146)
(#146) - 2026-08-18 Mark superseded CI commits `failure` rather than `skipped`. A
- 2026-08-18 Mark superseded CI commits `failure` rather than skipped run rolls up green, so a commit that was never tested reported success
`skipped`. A skipped run rolls up green, so a commit that was never (#152)
tested reported success (#152) - 2026-08-18 Set `fx.StopTimeout` inside the container stop grace, so shutdown
- 2026-08-18 Set `fx.StopTimeout` inside the container stop grace, so hooks are bounded by a deadline the orchestrator will actually honour rather
shutdown hooks are bounded by a deadline the orchestrator will than being killed mid-flush (#134)
actually honour rather than being killed mid-flush (#134) - 2026-08-17 Bucket IPv6 rate-limit keys by `/64`. A single allocation hands out
- 2026-08-17 Bucket IPv6 rate-limit keys by `/64`. A single allocation 2^64 addresses, so per-address keying let one client mint unlimited buckets.
hands out 2^64 addresses, so per-address keying let one client mint Manager decision, recorded on the issue (#125)
unlimited buckets. Manager decision, recorded on the issue (#125) - 2026-08-17 Correct release-blocking README and startup-warning inaccuracies,
- 2026-08-17 Correct release-blocking README and startup-warning including claims about behaviour the code does not have (#151)
inaccuracies, including claims about behaviour the code does not have
(#151)
- 2026-08-17 Fetch and verify Alpine.js at build time against - 2026-08-17 Fetch and verify Alpine.js at build time against
`static/vendor.sha256` instead of committing the minified blob, so `static/vendor.sha256` instead of committing the minified blob, so the
the dependency is pinned by hash rather than by trust (#145) dependency is pinned by hash rather than by trust (#145)
- 2026-08-17 Bound the event log's rendered bodies in the query itself, - 2026-08-17 Bound the event log's rendered bodies in the query itself, so a
so a large stored payload cannot be read into memory just to be large stored payload cannot be read into memory just to be truncated for
truncated for display (#135) display (#135)
- 2026-08-17 Mask the `http` target's destination URL in the UI: it can - 2026-08-17 Mask the `http` target's destination URL in the UI: it can carry a
carry a bearer credential in its path or query, and was rendered bearer credential in its path or query, and was rendered verbatim. Manager
verbatim. Manager decision to mask unconditionally (#115) decision to mask unconditionally (#115)
- 2026-08-14 Bound shutdown hooks by their stop context, so a hook that - 2026-08-14 Bound shutdown hooks by their stop context, so a hook that hangs
hangs cannot hold the process past its grace period (#102) cannot hold the process past its grace period (#102)
- 2026-08-14 Render templates via a buffer rather than the - 2026-08-14 Render templates via a buffer rather than the `ResponseWriter`, so
`ResponseWriter`, so a template error part-way through cannot commit a template error part-way through cannot commit a 200 and then fail — the
a 200 and then fail — the response is written only once it is whole response is written only once it is whole (#123)
(#123) - 2026-08-14 Align the session codec's max-age with the 7-day absolute cap. The
- 2026-08-14 Align the session codec's max-age with the 7-day absolute codec accepted cookies the session layer considered expired, so the cap was
cap. The codec accepted cookies the session layer considered expired, enforced in one place and not the other (#108)
so the cap was enforced in one place and not the other (#108) - 2026-08-12 Warn when `TRUSTED_PROXIES` is empty in production, where the safe
- 2026-08-12 Warn when `TRUSTED_PROXIES` is empty in production, where default silently discards forwarded headers and every client rate-limits as
the safe default silently discards forwarded headers and every client the proxy's address (#149)
rate-limits as the proxy's address (#149) - 2026-08-12 Bound the receiver rate limit per client IP across the whole
- 2026-08-12 Bound the receiver rate limit per client IP across the `/webhook/*` route. The existing limiter keyed on the request path and
whole `/webhook/*` route. The existing limiter keyed on the request `/webhook/{uuid}` matches any single segment, so a client that invented a
path and `/webhook/{uuid}` matches any single segment, so a client fresh path per request minted a fresh bucket per request: the limit on the
that invented a fresh path per request minted a fresh bucket per only unauthenticated endpoint bounded nothing in aggregate, and every request
request: the limit on the only unauthenticated endpoint bounded still cost an entrypoint lookup before it 404ed. An outer limiter keyed on the
nothing in aggregate, and every request still cost an entrypoint client address alone now bounds that, chained in front of the unchanged
lookup before it 404ed. An outer limiter keyed on the client address
alone now bounds that, chained in front of the unchanged
per-entrypoint limiter (#139) per-entrypoint limiter (#139)
- 2026-08-12 Correct release-blocking documentation inaccuracies: the - 2026-08-12 Correct release-blocking documentation inaccuracies: the README
README promised manual redelivery in the present tense in three promised manual redelivery in the present tense in three places when nothing
places when nothing implements it (the same false claim also sat in implements it (the same false claim also sat in the doc comment that was its
the doc comment that was its source text), the env table omitted source text), the env table omitted `RETENTION_SWEEP_INTERVAL`, and `TODO.md`
`RETENTION_SWEEP_INTERVAL`, and `TODO.md` itself omitted five landed itself omitted five landed units (#141)
units (#141) - 2026-08-12 Make the CI gate execute the checks it reports on. The workflow now
- 2026-08-12 Make the CI gate execute the checks it reports on. The writes a build-context fingerprint before calling `script/cibuild`, so a code
workflow now writes a build-context fingerprint before calling commit invalidates the `COPY` layer of the lint and builder stages while a
`script/cibuild`, so a code commit invalidates the `COPY` layer of docs-only commit still replays from cache; a superseding run also rewrites the
the lint and builder stages while a docs-only commit still replays `failure` status Gitea leaves on commits it cancelled and never tested.
from cache; a superseding run also rewrites the `failure` status Verified by pushing a deliberately broken test and watching CI go red (#119)
Gitea leaves on commits it cancelled and never tested. Verified by - 2026-08-12 Require a positive `RETENTION_SWEEP_INTERVAL`: a non-positive value
pushing a deliberately broken test and watching CI go red (#119) reached `time.NewTicker` in both the retention reaper and the archive sweeper,
- 2026-08-12 Require a positive `RETENTION_SWEEP_INTERVAL`: a panicking two goroutines with no recover after startup had already reported
non-positive value reached `time.NewTicker` in both the retention success (#140)
reaper and the archive sweeper, panicking two goroutines with no - 2026-08-12 Bound the `X-Forwarded-For` scan's allocation to the hop cap: the
recover after startup had already reported success (#140) reverse walk cuts entries with `strings.LastIndexByte` instead of joining and
- 2026-08-12 Bound the `X-Forwarded-For` scan's allocation to the hop splitting, so a 1 MB header allocates 16 bytes rather than 1.6 MB per request
cap: the reverse walk cuts entries with `strings.LastIndexByte` on the unauthenticated receiver. Semantics proven unchanged by differential
instead of joining and splitting, so a 1 MB header allocates 16 bytes testing against the previous implementation (#133)
rather than 1.6 MB per request on the unauthenticated receiver.
Semantics proven unchanged by differential testing against the
previous implementation (#133)
- 2026-08-12 Cap the `X-Forwarded-For` hop walk at 64 entries, so an - 2026-08-12 Cap the `X-Forwarded-For` hop walk at 64 entries, so an
attacker-supplied chain cannot burn unbounded CPU in the rate-limit attacker-supplied chain cannot burn unbounded CPU in the rate-limit key
key function; running off the end falls back to the peer address function; running off the end falls back to the peer address (#124)
(#124) - 2026-08-12 Gate forwarded-header trust behind a `TRUSTED_PROXIES` CIDR list:
- 2026-08-12 Gate forwarded-header trust behind a `TRUSTED_PROXIES` CIDR all three rate limiters key on the connection's own address unless the direct
list: all three rate limiters key on the connection's own address peer is a configured proxy, in which case `X-Forwarded-For` is walked right to
unless the direct peer is a configured proxy, in which case left for the first non-proxy hop. Default trusts nothing, and a
`X-Forwarded-For` is walked right to left for the first non-proxy hop. set-but-unparseable value aborts startup. Before this, any client could mint a
Default trusts nothing, and a set-but-unparseable value aborts fresh bucket or drain another's by rotating a spoofed header (#88)
startup. Before this, any client could mint a fresh bucket or drain - 2026-08-11 Web UI cleanup: nav terminology unified on Webhooks, the Profile
another's by rotating a spoofed header (#88) settings placeholder removed, a progressive-enhancement copy button for the
- 2026-08-11 Web UI cleanup: nav terminology unified on Webhooks, the entrypoint URL, and retention form copy that states the actual policy
Profile settings placeholder removed, a progressive-enhancement copy (deletion by the reaper, 0 retains forever) (#57)
button for the entrypoint URL, and retention form copy that states the - 2026-08-11 Mask the webhook credential in delivery errors and logs: Go embeds
actual policy (deletion by the reaper, 0 retains forever) (#57) the request URL in `*url.Error`, so every transport failure persisted the full
- 2026-08-11 Mask the webhook credential in delivery errors and logs: Slack webhook URL into the per-webhook event database via
Go embeds the request URL in `*url.Error`, so every transport failure `DeliveryResult.Error`, a field a future REST API would have served.
persisted the full Slack webhook URL into the per-webhook event `maskURLError` drops path, query and userinfo while preserving the wrapped
database via `DeliveryResult.Error`, a field a future REST API would cause, so `errors.Is`/`As` and `Timeout()` still work and DNS, TLS and timeout
have served. `maskURLError` drops path, query and userinfo while failures still read differently (#118)
preserving the wrapped cause, so `errors.Is`/`As` and `Timeout()`
still work and DNS, TLS and timeout failures still read differently
(#118)
- 2026-08-11 Rate-limit the public webhook receiver endpoint - 2026-08-11 Rate-limit the public webhook receiver endpoint
(`RECEIVER_RATE_LIMIT`, default 120/min), keyed on client IP plus (`RECEIVER_RATE_LIMIT`, default 120/min), keyed on client IP plus entrypoint
entrypoint path so one entrypoint cannot exhaust another's budget; path so one entrypoint cannot exhaust another's budget; over-limit requests
over-limit requests get 429 with `Retry-After`. It was the one get 429 with `Retry-After`. It was the one unauthenticated, internet-facing
unauthenticated, internet-facing endpoint with no limit at all (#64) endpoint with no limit at all (#64)
- 2026-08-11 Enforce the body size limit before CSRF parses the form: - 2026-08-11 Enforce the body size limit before CSRF parses the form:
`MaxBodySize` is now first in all four form-parsing route groups, so `MaxBodySize` is now first in all four form-parsing route groups, so an
an oversized request is rejected with 413 instead of being read in oversized request is rejected with 413 instead of being read in full by the
full by the CSRF middleware before any cap applied (#90) CSRF middleware before any cap applied (#90)
- 2026-08-11 Mask target config on the source detail page, which - 2026-08-11 Mask target config on the source detail page, which rendered the
rendered the stored blob verbatim and so exposed the Slack stored blob verbatim and so exposed the Slack incoming-webhook URL — a bearer
incoming-webhook URL — a bearer credential that cannot be revoked credential that cannot be revoked per-holder. Config reaches the template only
per-holder. Config reaches the template only as a `TargetView` of as a `TargetView` of labelled fields, and header values are rendered as a
labelled fields, and header values are rendered as a count (#113) count (#113)
- 2026-08-11 Allow `retention_days` of 0 to mean retain forever, via a - 2026-08-11 Allow `retention_days` of 0 to mean retain forever, via a sentinel
sentinel written in `BeforeSave` so the GORM column default cannot written in `BeforeSave` so the GORM column default cannot win the race. Also
win the race. Also bounds the reaper's cutoff arithmetic: day counts bounds the reaper's cutoff arithmetic: day counts above 106751 overflowed
above 106751 overflowed `time.Duration` and wrapped the cutoff into `time.Duration` and wrapped the cutoff into the future, where every row
the future, where every row matched and the sweep deleted everything matched and the sweep deleted everything (#79)
(#79)
- 2026-08-09 Inactivity-based session timeout: sliding idle expiry - 2026-08-09 Inactivity-based session timeout: sliding idle expiry
(`SESSION_IDLE_TIMEOUT`, default `24h`) refreshed on authenticated (`SESSION_IDLE_TIMEOUT`, default `24h`) refreshed on authenticated requests,
requests, with the 7-day absolute cap kept as an independent with the 7-day absolute cap kept as an independent backstop that activity
backstop that activity never extends (#66) never extends (#66)
- 2026-08-09 Restart recovery and the 60s retry sweep terminally fail an - 2026-08-09 Restart recovery and the 60s retry sweep terminally fail an
orphaned `retrying` delivery whose target type no longer supports orphaned `retrying` delivery whose target type no longer supports retries,
retries, recording a `DeliveryResult` with the reason instead of recording a `DeliveryResult` with the reason instead of leaving the delivery
leaving the delivery stuck forever (#82) stuck forever (#82)
- 2026-08-09 Root the delivery engine's worker pool and the retention - 2026-08-09 Root the delivery engine's worker pool and the retention reaper's
reaper's sweep loop at `context.Background()` rather than the fx sweep loop at `context.Background()` rather than the fx `OnStart` hook context
`OnStart` hook context (#97), which carries fx's 15s start timeout and (#97), which carries fx's 15s start timeout and killed both roughly fifteen
killed both roughly fifteen seconds after boot: the proxy silently seconds after boot: the proxy silently stopped delivering webhooks entirely,
stopped delivering webhooks entirely, and the reaper never ran a and the reaper never ran a single sweep under its default one-hour interval
single sweep under its default one-hour interval - 2026-08-09 Archive writer lifecycle (#89): deleting a webhook (or its last
- 2026-08-09 Archive writer lifecycle (#89): deleting a webhook (or its `database` target) evicts the cached archive writer and closes its handle
last `database` target) evicts the cached archive writer and closes while deliberately leaving `archive-{webhookID}.db` on disk, and a new
its handle while deliberately leaving `archive-{webhookID}.db` on `ArchiveSweeper` prunes idle archives on the existing
disk, and a new `ArchiveSweeper` prunes idle archives on the existing
`RETENTION_SWEEP_INTERVAL` without ever creating an archive file `RETENTION_SWEEP_INTERVAL` without ever creating an archive file
- 2026-08-09 Configuration parsing fails loudly on set-but-unparseable - 2026-08-09 Configuration parsing fails loudly on set-but-unparseable
environment values: `envInt` removed in favour of `envPositiveInt` environment values: `envInt` removed in favour of `envPositiveInt` plus a
plus a `PORT` range check, `envBool` now parses with `PORT` range check, `envBool` now parses with `strconv.ParseBool`, and
`strconv.ParseBool`, and defaults apply only to unset variables (#80) defaults apply only to unset variables (#80)
- 2026-08-07 Automatic event retention cleanup based on - 2026-08-07 Automatic event retention cleanup based on `retention_days`,
`retention_days`, deleting expired events, deliveries, and delivery deleting expired events, deliveries, and delivery results from each
results from each per-webhook event database (#63) per-webhook event database (#63)
- 2026-08-07 Update golangci-lint to v2.12.2 (Docker image digest in - 2026-08-07 Update golangci-lint to v2.12.2 (Docker image digest in
`Dockerfile`, release-archive sha256 pins in `script/bootstrap`), `Dockerfile`, release-archive sha256 pins in `script/bootstrap`), adopt the
adopt the canonical `.golangci.yml` (v2 `linters.settings` layout so canonical `.golangci.yml` (v2 `linters.settings` layout so
`lll`/`funlen`/`cyclop`/`dupl` thresholds actually apply), and fix `lll`/`funlen`/`cyclop`/`dupl` thresholds actually apply), and fix all newly
all newly surfaced lint findings surfaced lint findings
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, - 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
Makefile shims, README Entrypoints section shims, README Entrypoints section
- 2026-03-25 pin golangci-lint Docker image for linting (#55) - 2026-03-25 pin golangci-lint Docker image for linting (#55)
- 2026-03-18 CSRF middleware detects TLS per-request, fixing login over - 2026-03-18 CSRF middleware detects TLS per-request, fixing login over plain
plain HTTP and behind reverse proxies (#54) HTTP and behind reverse proxies (#54)
- 2026-03-17 root path redirects based on auth state (#52) - 2026-03-17 root path redirects based on auth state (#52)
- 2026-03-17 CSRF protection, SSRF prevention for HTTP delivery targets - 2026-03-17 CSRF protection, SSRF prevention for HTTP delivery targets with DNS
with DNS rebinding defense, and per-IP login rate limiting (#42) rebinding defense, and per-IP login rate limiting (#42)
- 2026-03-17 Slack target type for incoming webhook notifications (#47) - 2026-03-17 Slack target type for incoming webhook notifications (#47)
- 2026-03-17 Dockerfile absolute paths and static linking (#49); - 2026-03-17 Dockerfile absolute paths and static linking (#49); absolute dev
absolute dev DATA_DIR default and clarified env docs (#46) DATA_DIR default and clarified env docs (#46)
- 2026-03-05 security headers middleware, session regeneration on - 2026-03-05 security headers middleware, session regeneration on login, request
login, request body size limits (#41) body size limits (#41)
- 2026-03-04 tests for delivery, middleware, and session packages - 2026-03-04 tests for delivery, middleware, and session packages (#32); removed
(#32); removed the build-architecture global (#31) the build-architecture global (#31)
- 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core - 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core delivery
delivery engine with bounded worker pool and circuit breaker, engine with bounded worker pool and circuit breaker, parallel fan-out,
parallel fan-out, per-webhook event databases, management UI (#16) per-webhook event databases, management UI (#16)
- 2026-03-01 repo brought to REPO_POLICIES standards; TODO.md folded - 2026-03-01 repo brought to REPO_POLICIES standards; TODO.md folded into README
into README (#6) (#6)
# Future Steps # Future Steps
- Delivery status and retry management UI. Replay of a terminally - Delivery status and retry management UI. Replay of a terminally failed
failed delivery and per-attempt detail already landed delivery and per-attempt detail already landed
(https://git.eeqj.de/sneak/webhooker/issues/203, (https://git.eeqj.de/sneak/webhooker/issues/203,
https://git.eeqj.de/sneak/webhooker/issues/202) https://git.eeqj.de/sneak/webhooker/issues/202)
- Per-webhook rate limiting in the receiver handler (per-webhook config - Per-webhook rate limiting in the receiver handler (per-webhook config plus
plus handler enforcement; global limits must not apply to receiver handler enforcement; global limits must not apply to receiver endpoints)
endpoints) - API key authentication for programmatic access (APIKey model exists; Bearer
- API key authentication for programmatic access (APIKey model exists; token middleware does not)
Bearer token middleware does not)
- REST API v1 - REST API v1
- CRUD for webhooks, entrypoints, targets - CRUD for webhooks, entrypoints, targets
- event viewing and filtering endpoints - event viewing and filtering endpoints
@@ -406,9 +370,9 @@ point of the branch.
- OpenAPI specification - OpenAPI specification
- Analytics dashboard: success rates, response times, volume - Analytics dashboard: success rates, response times, volume
- A remember-me option at login - A remember-me option at login
- Password reset flow for a forgotten password over the web. The - Password reset flow for a forgotten password over the web. The authenticated
authenticated password *change* flow already landed, and a lost password _change_ flow already landed, and a lost password is recoverable from
password is recoverable from the console with `webhooker resetpw` the console with `webhooker resetpw`
(https://git.eeqj.de/sneak/webhooker/issues/208) (https://git.eeqj.de/sneak/webhooker/issues/208)
- Later, nice to have - Later, nice to have
- email delivery target type - email delivery target type
+4
View File
@@ -212,6 +212,10 @@ func newApp() *fx.App {
// or renaming a webhook or target reaches its archive // or renaming a webhook or target reaches its archive
// files. // files.
func(e *delivery.Engine) delivery.Archives { return e }, func(e *delivery.Engine) delivery.Archives { return e },
// Wire *delivery.Engine as delivery.CircuitBreakers so
// the pages can show a target whose deliveries are
// paused.
func(e *delivery.Engine) delivery.CircuitBreakers { return e },
server.New, server.New,
), ),
fx.Invoke( fx.Invoke(
+18
View File
@@ -0,0 +1,18 @@
// ESLint configuration for static/js/. script/lint and the image build run
// ESLint in the Dockerfile's js-lint stage, never on the host.
//
// The rules are the ones the JavaScript styleguide linked from
// REPO_POLICIES.md states that a linter can check: const for everything,
// let only for a variable that is reassigned, never var.
export default [
// Alpine.js, extracted from 3p/ by make assets; not ours to lint.
{ ignores: ["static/js/alpine.min.js"] },
{
// The pages load static/js/app.js as a classic script, not a module.
languageOptions: { sourceType: "script" },
rules: {
"no-var": "error",
"prefer-const": "error",
},
},
];
@@ -149,6 +149,62 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
Delete(&database.Event{}), "sqlite_autoindex_events_1 (id=?)") Delete(&database.Event{}), "sqlite_autoindex_events_1 (id=?)")
} }
// TestEventLogFiltersUseTheStatusIndex does the same for the event log's
// Failed and Pending lists, of the newest events with a delivery in
// given statuses, and for their counts (eventsWithStatus and
// countEventsWithStatus in the handlers). The lists must also reach
// the events table only by ID: from the matching deliveries, then from
// the newest of those events.
func TestEventLogFiltersUseTheStatusIndex(t *testing.T) {
t.Parallel()
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
webhookID := uuid.New().String()
db, err := mgr.GetDB(webhookID)
require.NoError(t, err)
dry := db.Session(&gorm.Session{DryRun: true})
byStatus := "idx_deliveries_status (status=? AND deleted_at=?)"
pending := []database.DeliveryStatus{
database.DeliveryStatusPending,
database.DeliveryStatusRetrying,
}
var (
rows []struct{ ID string }
count int64
)
matching := dry.Model(&database.Delivery{}).
Distinct("event_id").Where("status IN ?", pending)
newest := dry.Table("(?) AS matching", matching).
Joins("CROSS JOIN events ON events.id = matching.event_id").
Where(
"events.webhook_id = ? AND events.deleted_at IS NULL",
webhookID,
).
Order("events.created_at DESC").Limit(50).
Select("events.id AS event_id")
// Each step of the plan is printed in braces, so these name the
// lookup that follows each scan.
byID := "{SEARCH events USING INDEX sqlite_autoindex_events_1 (id=?)}"
assertPlanUses(t, db, dry.Table("(?) AS newest", newest).
Joins("CROSS JOIN events ON events.id = newest.event_id").
Select("id").Order("created_at DESC").Limit(50).Find(&rows),
byStatus, "{SCAN matching} "+byID, "{SCAN newest} "+byID)
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
Distinct("event_id").Where("status IN ?", pending).Count(&count),
byStatus)
}
// TestStatisticsQueriesUseTheirIndexes does the same for the webhook // TestStatisticsQueriesUseTheirIndexes does the same for the webhook
// page's statistics (readEventStats in the handlers): deliveries in // page's statistics (readEventStats in the handlers): deliveries in
// progress, each target's deliveries finished since a time, which must // progress, each target's deliveries finished since a time, which must
@@ -233,6 +289,43 @@ func TestResubmitCountUsesItsIndex(t *testing.T) {
"(resubmitted_from_id=? AND deleted_at=?)") "(resubmitted_from_id=? AND deleted_at=?)")
} }
// TestEntrypointEventsUseTheirIndex does the same for the webhook
// page's count, for each entrypoint, of the events that arrived on its
// URL since the retention cutoff (addEntrypointEvents in the
// handlers), which must come from the index alone. It passes 25
// entrypoints, as TestResubmitCountUsesItsIndex passes 25 events.
func TestEntrypointEventsUseTheirIndex(t *testing.T) {
t.Parallel()
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
db, err := mgr.GetDB(uuid.New().String())
require.NoError(t, err)
dry := db.Session(&gorm.Session{DryRun: true})
entrypoints := make([]string, 25)
for i := range entrypoints {
entrypoints[i] = uuid.New().String()
}
var rows []struct{ Events int }
assertPlanUses(t, db, dry.Model(&database.Event{}).
Select("entrypoint_id, count(*) AS events").
Where("entrypoint_id IN ? AND resubmitted_from_id IS NULL",
entrypoints).
Where("created_at >= ?", time.Now()).
Group("entrypoint_id").Find(&rows),
"COVERING INDEX idx_events_entrypoint_id "+
"(entrypoint_id=? AND deleted_at=? AND "+
"resubmitted_from_id=? AND created_at>?)")
}
// assertPlanUses asserts that SQLite's plan for a statement GORM built // assertPlanUses asserts that SQLite's plan for a statement GORM built
// in a dry run, run with the same SQL and arguments GORM would send, // in a dry run, run with the same SQL and arguments GORM would send,
// names each of the given indexes. // names each of the given indexes.
+4
View File
@@ -56,6 +56,10 @@ type Delivery struct {
// the index. // the index.
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"` FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
// Replay is set on a delivery created by the event log's Replay
// action, so the pages can tell it from the delivery it repeats.
Replay bool `gorm:"not null;default:false" json:"replay"`
// Relations. No model marshals the record it belongs to: // Relations. No model marshals the record it belongs to:
// Event.Deliveries and Target.Deliveries lead back here, and the // Event.Deliveries and Target.Deliveries lead back here, and the
// JSON could loop. // JSON could loop.
+8 -5
View File
@@ -20,12 +20,15 @@ type Event struct {
// has no deleted_at condition and uses the index on created_at // has no deleted_at condition and uses the index on created_at
// alone. The other tables keep the unindexed BaseModel created_at. // alone. The other tables keep the unindexed BaseModel created_at.
// DeletedAt is also the second column of the resubmitted_from_id // DeletedAt is also the second column of the resubmitted_from_id
// index, for the reason DeliveryResult gives. // index, for the reason DeliveryResult gives. The entrypoint_id
CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2" json:"createdAt"` // index, for the webhook page's entrypoint list, has it second too,
DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1;index:idx_events_resubmitted_from_id,priority:2" json:"deletedAt,omitzero"` // resubmitted_from_id third, and created_at last, which the list
// compares with a range.
CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2;index:idx_events_entrypoint_id,priority:4" json:"createdAt"`
DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1;index:idx_events_resubmitted_from_id,priority:2;index:idx_events_entrypoint_id,priority:2" json:"deletedAt,omitzero"`
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"` WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
EntrypointID string `gorm:"type:uuid;not null" json:"entrypointId"` EntrypointID string `gorm:"type:uuid;not null;index:idx_events_entrypoint_id,priority:1" json:"entrypointId"`
// Request data // Request data
Method string `gorm:"not null" json:"method"` Method string `gorm:"not null" json:"method"`
@@ -44,7 +47,7 @@ type Event struct {
// existed. It is not a foreign key: the source event can be // existed. It is not a foreign key: the source event can be
// reaped by retention while its copies remain, and the id is // reaped by retention while its copies remain, and the id is
// kept as the record of where the copy came from either way. // kept as the record of where the copy came from either way.
ResubmittedFromID *string `gorm:"type:uuid;index:idx_events_resubmitted_from_id,priority:1" json:"resubmittedFromId,omitempty"` ResubmittedFromID *string `gorm:"type:uuid;index:idx_events_resubmitted_from_id,priority:1;index:idx_events_entrypoint_id,priority:3" json:"resubmittedFromId,omitempty"`
// Relations. No model marshals the record it belongs to, so // Relations. No model marshals the record it belongs to, so
// Webhook and Entrypoint are left out of the JSON. // Webhook and Entrypoint are left out of the JSON.
-1
View File
@@ -32,7 +32,6 @@ type Target struct {
// For HTTP targets (max_retries=0 means fire-and-forget, // For HTTP targets (max_retries=0 means fire-and-forget,
// >0 enables retries with backoff) // >0 enables retries with backoff)
MaxRetries int `json:"maxRetries,omitempty"` MaxRetries int `json:"maxRetries,omitempty"`
MaxQueueSize int `json:"maxQueueSize,omitempty"`
// Relations. No model marshals the record it belongs to: // Relations. No model marshals the record it belongs to:
// Webhook.Targets leads back here, and the JSON could loop. // Webhook.Targets leads back here, and the JSON could loop.
+37
View File
@@ -52,6 +52,21 @@ func (TargetTotals) TableName() string {
return "target_totals" return "target_totals"
} }
// EntrypointTotals is one row per entrypoint, created by the first
// event that arrives on its URL: when the newest such event arrived,
// which retention leaves as it is. A resubmitted copy did not arrive
// on the URL and does not change it.
type EntrypointTotals struct {
EntrypointID string `gorm:"type:uuid;primaryKey"`
LastEventAt time.Time `gorm:"not null"`
}
// TableName names the table AddEntrypointTotals updates.
func (EntrypointTotals) TableName() string {
return "entrypoint_totals"
}
// AddEventTotals adds each count in add to the webhook's event totals, // AddEventTotals adds each count in add to the webhook's event totals,
// and records add.LastEventAt as when the newest event arrived if it is // and records add.LastEventAt as when the newest event arrived if it is
// set. Call it on the transaction that writes or deletes the events it // set. Call it on the transaction that writes or deletes the events it
@@ -97,3 +112,25 @@ func AddTargetTotals(tx *gorm.DB, add TargetTotals) error {
return nil return nil
} }
// AddEntrypointTotals records add.LastEventAt as when the newest event
// arrived on the URL of the entrypoint add.EntrypointID names, creating
// its row the first time. Call it on the transaction that stores the
// event.
func AddEntrypointTotals(tx *gorm.DB, add EntrypointTotals) error {
err := tx.Exec(
`INSERT INTO entrypoint_totals (entrypoint_id, last_event_at)
VALUES (?, ?)
ON CONFLICT (entrypoint_id) DO UPDATE SET
last_event_at = excluded.last_event_at`,
add.EntrypointID, add.LastEventAt,
).Error
if err != nil {
return fmt.Errorf(
"adding to totals of entrypoint %s: %w",
add.EntrypointID, err,
)
}
return nil
}
+7
View File
@@ -111,6 +111,13 @@ func (w *Webhook) RetainsForever() bool {
return retainsForever(w.RetentionDays) return retainsForever(w.RetentionDays)
} }
// RetentionCutoff returns the time before which this webhook's events
// have expired, as the reaper computes it, and false when the webhook
// retains them forever.
func (w *Webhook) RetentionCutoff(now time.Time) (time.Time, bool) {
return retentionCutoff(now, w.RetentionDays)
}
// RetentionLabel returns the webhook's retention policy as display // RetentionLabel returns the webhook's retention policy as display
// text, so that no template has to know about the sentinel value. // text, so that no template has to know about the sentinel value.
func (w *Webhook) RetentionLabel() string { func (w *Webhook) RetentionLabel() string {
+1 -1
View File
@@ -3,7 +3,7 @@ package database
// Migrate runs database migrations for the main application database. // Migrate runs database migrations for the main application database.
// Only configuration-tier models are stored in the main database. // Only configuration-tier models are stored in the main database.
// Event-tier models (Event, Delivery, DeliveryResult, EventTotals, // Event-tier models (Event, Delivery, DeliveryResult, EventTotals,
// TargetTotals) live in // TargetTotals, EntrypointTotals) live in
// per-webhook dedicated databases managed by WebhookDBManager. // per-webhook dedicated databases managed by WebhookDBManager.
func (d *Database) Migrate() error { func (d *Database) Migrate() error {
return d.db.AutoMigrate( return d.db.AutoMigrate(
+2 -2
View File
@@ -49,7 +49,7 @@ var ErrSidecarNotRemoved = errors.New(
// WebhookDBManager manages per-webhook SQLite database files // WebhookDBManager manages per-webhook SQLite database files
// for event storage. Each webhook gets its own dedicated // for event storage. Each webhook gets its own dedicated
// database containing Events, Deliveries, DeliveryResults and the // database containing Events, Deliveries, DeliveryResults and the
// running totals of them (EventTotals, TargetTotals). // running totals of them (EventTotals, TargetTotals, EntrypointTotals).
// Database connections are opened lazily and cached. // Database connections are opened lazily and cached.
type WebhookDBManager struct { type WebhookDBManager struct {
dataDir string dataDir string
@@ -381,7 +381,7 @@ func (m *WebhookDBManager) openDB(
// Run migrations for event-tier models only // Run migrations for event-tier models only
err = db.AutoMigrate( err = db.AutoMigrate(
&Event{}, &Delivery{}, &DeliveryResult{}, &Event{}, &Delivery{}, &DeliveryResult{},
&EventTotals{}, &TargetTotals{}, &EventTotals{}, &TargetTotals{}, &EntrypointTotals{},
) )
if err != nil { if err != nil {
_ = sqlDB.Close() _ = sqlDB.Close()
+12 -3
View File
@@ -163,6 +163,17 @@ func (env *archiveEnv) seedArchiveRows(
t.Helper() t.Helper()
path := env.archivePath(tgt) path := env.archivePath(tgt)
seedArchiveFile(t, path, tgt.WebhookID, archivedAt...)
return path
}
// seedArchiveFile creates the archive file at path and inserts one row
// per supplied archived-at timestamp, as seedArchiveRows does.
func seedArchiveFile(
t *testing.T, path, webhookID string, archivedAt ...time.Time,
) {
t.Helper()
sqlDB, err := sql.Open( sqlDB, err := sql.Open(
"sqlite", fmt.Sprintf("file:%s?mode=rwc", path), "sqlite", fmt.Sprintf("file:%s?mode=rwc", path),
@@ -182,7 +193,7 @@ func (env *archiveEnv) 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: tgt.WebhookID, WebhookID: webhookID,
Method: http.MethodPost, Method: http.MethodPost,
Body: `{"seeded":true}`, Body: `{"seeded":true}`,
ArchivedAt: at, ArchivedAt: at,
@@ -191,8 +202,6 @@ func (env *archiveEnv) seedArchiveRows(
} }
require.NoError(t, sqlDB.Close()) require.NoError(t, sqlDB.Close())
return path
} }
// archivedEventIDs returns the event ids currently stored in an // archivedEventIDs returns the event ids currently stored in an
+14
View File
@@ -102,6 +102,20 @@ func (cb *CircuitBreaker) CooldownRemaining() time.Duration {
return remaining return remaining
} }
// StateAndCooldown returns the circuit state and, while the circuit is
// open, what is left of the cooldown, or zero once that has passed.
// Both are read under one lock, so they always agree.
func (cb *CircuitBreaker) StateAndCooldown() (CircuitState, time.Duration) {
cb.mu.Lock()
defer cb.mu.Unlock()
if cb.state != CircuitOpen {
return cb.state, 0
}
return cb.state, max(cb.cooldown-time.Since(cb.lastFailure), 0)
}
// RecordSuccess records a successful delivery and resets // RecordSuccess records a successful delivery and resets
// the circuit breaker to closed state. // the circuit breaker to closed state.
func (cb *CircuitBreaker) RecordSuccess() { func (cb *CircuitBreaker) RecordSuccess() {
+42 -8
View File
@@ -143,6 +143,15 @@ type Archives interface {
Rename(targetID, webhookName, targetName string) error Rename(targetID, webhookName, targetName string) error
} }
// CircuitBreakers is how the handlers read a target's circuit
// breaker, so the webhook page and the event log can say that
// deliveries to the target are paused and until when. Like Archives,
// it keeps the handlers free of the engine's internals and is
// trivially faked in tests.
type CircuitBreakers interface {
StateAndCooldown(targetID string) (CircuitState, time.Duration)
}
// EngineParams are the fx dependencies for the delivery // EngineParams are the fx dependencies for the delivery
// engine. // engine.
type EngineParams struct { type EngineParams struct {
@@ -186,9 +195,11 @@ type Engine struct {
// targets maps each target type to its implementation. // targets maps each target type to its implementation.
targets map[database.TargetType]Target targets map[database.TargetType]Target
// httpTarget is retained so tests can reach the HTTP // httpTarget and slackTarget are retained so StateAndCooldown
// target's shared client and circuit breakers. // can read their circuit breakers, and so tests can reach the
// HTTP target's shared client.
httpTarget *httpTarget httpTarget *httpTarget
slackTarget *slackTarget
// dbTarget is retained so the engine can reach the archive // dbTarget is retained so the engine can reach the archive
// writer registry for eviction, renames and the idle sweep. // writer registry for eviction, renames and the idle sweep.
@@ -284,12 +295,13 @@ func (e *Engine) EvictTarget(targetID string) {
e.dbTarget.evict(targetID) e.dbTarget.evict(targetID)
} }
// Rename implements Archives. It renames a database target's // Rename implements Archives. It renames every one of a database
// archive file to ArchiveFileName(webhookName, targetName, // target's archive files to ArchiveFileName(webhookName, targetName,
// targetID), under the lock the target's archive writes and the // targetID), each keeping the period in its name, under the lock the
// idle sweep take. It never replaces a file: if one already has the // target's archive writes and the idle sweep take. It never replaces
// new name, the error is ErrArchiveNameTaken. The caller renames // a file: if one already has a new name, the error is
// before it saves the new name: see databaseTarget.rename. // ErrArchiveNameTaken. The caller renames before it saves the new
// name: see databaseTarget.rename.
func (e *Engine) Rename( func (e *Engine) Rename(
targetID, webhookName, targetName string, targetID, webhookName, targetName string,
) error { ) error {
@@ -300,6 +312,28 @@ func (e *Engine) Rename(
return e.dbTarget.rename(targetID, webhookName, targetName) return e.dbTarget.rename(targetID, webhookName, targetName)
} }
// StateAndCooldown implements CircuitBreakers. It returns the state of
// the target's circuit breaker and, while the breaker is open, what is
// left of its cooldown; the cooldown is zero once that has passed and
// in any other state. A target with no breaker reads as closed with no
// cooldown, and reading never creates one.
func (e *Engine) StateAndCooldown(
targetID string,
) (CircuitState, time.Duration) {
for _, core := range []*httpCore{
e.httpTarget.httpCore, e.slackTarget.httpCore,
} {
val, ok := core.circuitBreakers.Load(targetID)
if ok {
cb, _ := val.(*CircuitBreaker)
return cb.StateAndCooldown()
}
}
return CircuitClosed, 0
}
// ScheduleRetry schedules a task to be re-enqueued onto the // ScheduleRetry schedules a task to be re-enqueued onto the
// retry channel after delay. It implements the Scheduler // retry channel after delay. It implements the Scheduler
// interface the targets use to own their durable retries. // interface the targets use to own their durable retries.
+56
View File
@@ -1018,6 +1018,62 @@ func TestGetCircuitBreaker_CreatesOnDemand(t *testing.T) {
) )
} }
// TestStateAndCooldown_ReadsHTTPAndSlackBreakers proves the engine
// reads the state of an http or a slack target's circuit breaker, with
// what is left of its cooldown while it is open, and no cooldown while
// it is half-open, once it closes, or for a target with no breaker.
func TestStateAndCooldown_ReadsHTTPAndSlackBreakers(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
httpID := uuid.New().String()
slackID := uuid.New().String()
state, cooldown := e.StateAndCooldown(httpID)
assert.Equal(t, delivery.CircuitClosed, state, "no breaker")
assert.Zero(t, cooldown, "no breaker")
httpCB := delivery.NewTestCircuitBreaker(1, time.Hour)
e.ExportSetCircuitBreaker(httpID, httpCB)
slackCB := delivery.NewTestCircuitBreaker(1, time.Hour)
e.ExportSetSlackCircuitBreaker(slackID, slackCB)
httpCB.RecordFailure()
slackCB.RecordFailure()
for _, id := range []string{httpID, slackID} {
state, cooldown := e.StateAndCooldown(id)
assert.Equal(t, delivery.CircuitOpen, state)
assert.Greater(t, cooldown, 59*time.Minute)
assert.LessOrEqual(t, cooldown, time.Hour)
}
httpCB.RecordSuccess()
slackCB.RecordSuccess()
for _, id := range []string{httpID, slackID} {
state, cooldown := e.StateAndCooldown(id)
assert.Equal(t, delivery.CircuitClosed, state, "closed")
assert.Zero(t, cooldown, "closed")
}
// A breaker with no cooldown goes half-open on the first Allow
// after it trips, letting that one delivery through to test the
// target.
halfOpenID := uuid.New().String()
halfOpenCB := delivery.NewTestCircuitBreaker(1, 0)
e.ExportSetCircuitBreaker(halfOpenID, halfOpenCB)
halfOpenCB.RecordFailure()
require.True(t, halfOpenCB.Allow())
state, cooldown = e.StateAndCooldown(halfOpenID)
assert.Equal(t, delivery.CircuitHalfOpen, state)
assert.Zero(t, cooldown, "half-open")
}
func TestParseHTTPConfig_Valid(t *testing.T) { func TestParseHTTPConfig_Valid(t *testing.T) {
t.Parallel() t.Parallel()
+22 -5
View File
@@ -212,6 +212,14 @@ func (e *Engine) ExportSetCircuitBreaker(
e.httpTarget.circuitBreakers.Store(targetID, cb) e.httpTarget.circuitBreakers.Store(targetID, cb)
} }
// ExportSetSlackCircuitBreaker is ExportSetCircuitBreaker for the
// slack target.
func (e *Engine) ExportSetSlackCircuitBreaker(
targetID string, cb *CircuitBreaker,
) {
e.slackTarget.circuitBreakers.Store(targetID, cb)
}
// ExportParseHTTPConfig exposes parseHTTPConfig. // ExportParseHTTPConfig exposes parseHTTPConfig.
func (e *Engine) ExportParseHTTPConfig( func (e *Engine) ExportParseHTTPConfig(
configJSON string, configJSON string,
@@ -493,23 +501,32 @@ func NewExportArchiveWriter(
return &ExportArchiveWriter{w: w} return &ExportArchiveWriter{w: w}
} }
// Write archives a row through the writer. // Write archives a row through the writer, into the file named
// without a period.
func (e *ExportArchiveWriter) Write( func (e *ExportArchiveWriter) Write(
row ExportArchivedEvent, expiry time.Duration, row ExportArchivedEvent, expiry time.Duration,
) error { ) error {
return e.w.write(row, expiry) return e.w.write(row, expiry, "")
}
// WritePeriod archives a row through the writer, into the file for
// period.
func (e *ExportArchiveWriter) WritePeriod(
row ExportArchivedEvent, expiry time.Duration, period string,
) error {
return e.w.write(row, expiry, period)
} }
// Open opens the archive file, pruning when expiry is positive. // Open opens the archive file, pruning when expiry is positive.
func (e *ExportArchiveWriter) Open(expiry time.Duration) error { func (e *ExportArchiveWriter) Open(expiry time.Duration) error {
return e.w.open(expiry) return e.w.open(e.w.path, expiry)
} }
// Reopen closes and reopens the archive file. // Reopen closes and reopens the archive file.
func (e *ExportArchiveWriter) Reopen( func (e *ExportArchiveWriter) Reopen(
expiry time.Duration, expiry time.Duration,
) error { ) error {
return e.w.reopen(expiry) return e.w.reopen(e.w.path, expiry)
} }
// SetNow replaces the clock the writer measures its reopen // SetNow replaces the clock the writer measures its reopen
@@ -539,7 +556,7 @@ func (e *ExportArchiveWriter) Path() string {
func (e *ExportArchiveWriter) OpenExisting( func (e *ExportArchiveWriter) OpenExisting(
expiry time.Duration, expiry time.Duration,
) error { ) error {
return e.w.openMode(archiveModeExisting, expiry) return e.w.openMode(e.w.path, archiveModeExisting, expiry)
} }
// SweepExpired runs an idle sweep of the archive. // SweepExpired runs an idle sweep of the archive.
+1
View File
@@ -105,6 +105,7 @@ func (e *Engine) initTargets(client *http.Client) {
dbT := &databaseTarget{eng: e} dbT := &databaseTarget{eng: e}
e.httpTarget = httpT e.httpTarget = httpT
e.slackTarget = slackT
e.dbTarget = dbT e.dbTarget = dbT
e.targets = map[database.TargetType]Target{ e.targets = map[database.TargetType]Target{
+20 -7
View File
@@ -41,6 +41,8 @@ type TargetConfigForm struct {
Timeout string Timeout string
// Expiry is the database (archive) target's row expiry. // Expiry is the database (archive) target's row expiry.
Expiry string Expiry string
// Rotation is the database (archive) target's rotation.
Rotation string
} }
// NewTargetConfigForm parses a target's stored configuration into // NewTargetConfigForm parses a target's stored configuration into
@@ -85,11 +87,13 @@ func NewTargetConfigForm(
} }
} }
// databaseConfigForm parses an archive target's optional expiry. // databaseConfigForm parses an archive target's optional expiry and
// An absent or empty configuration is the keep-forever default and // rotation. An absent, empty or never expiry yields an empty expiry,
// yields an empty field, so re-saving the form unchanged stores the // on which the edit form starts at never; saving it unchanged stores
// same empty configuration it started with. An expiry that is set // never, which means the same as an empty expiry. An absent rotation
// but not a valid duration is an error, not a blank field. // is empty too, and the form starts at none. An expiry that is set
// but not a valid duration, or a rotation that is not one of the
// four, is an error, not a blank field.
func databaseConfigForm( func databaseConfigForm(
configJSON string, configJSON string,
) (TargetConfigForm, error) { ) (TargetConfigForm, error) {
@@ -106,8 +110,15 @@ func databaseConfigForm(
) )
} }
err = ValidateArchiveRotation(cfg.Rotation)
if err != nil {
return TargetConfigForm{}, err
}
form := TargetConfigForm{Rotation: cfg.Rotation}
if cfg.Expiry == "" || cfg.Expiry == archiveExpiryNever { if cfg.Expiry == "" || cfg.Expiry == archiveExpiryNever {
return TargetConfigForm{}, nil return form, nil
} }
err = ValidateArchiveExpiry(cfg.Expiry) err = ValidateArchiveExpiry(cfg.Expiry)
@@ -115,5 +126,7 @@ func databaseConfigForm(
return TargetConfigForm{}, err return TargetConfigForm{}, err
} }
return TargetConfigForm{Expiry: cfg.Expiry}, nil form.Expiry = cfg.Expiry
return form, nil
} }
+70 -43
View File
@@ -1,9 +1,9 @@
package delivery package delivery
import ( import (
"encoding/json"
"fmt" "fmt"
"strconv" "strconv"
"time"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
@@ -120,7 +120,7 @@ func unavailableConfigFields() []ConfigField {
} }
// slackConfigFields describes a Slack target: its masked // slackConfigFields describes a Slack target: its masked
// webhook URL and its retry settings. Only the masked URL is // webhook URL and its retry count. Only the masked URL is
// shown; the full URL is the credential. // shown; the full URL is the credential.
func slackConfigFields(t *database.Target) []ConfigField { func slackConfigFields(t *database.Target) []ConfigField {
cfg, err := parseSlackConfig(t.Config) cfg, err := parseSlackConfig(t.Config)
@@ -128,10 +128,10 @@ func slackConfigFields(t *database.Target) []ConfigField {
return unavailableConfigFields() return unavailableConfigFields()
} }
return append([]ConfigField{{ return []ConfigField{{
Label: "Webhook URL", Label: "Webhook URL",
Value: cfg.MaskedWebhookURL(), Value: cfg.MaskedWebhookURL(),
}}, retryFields(t)...) }, maxRetriesField(t)}
} }
// httpConfigFields describes an HTTP target: its destination // httpConfigFields describes an HTTP target: its destination
@@ -171,63 +171,90 @@ func httpConfigFields(t *database.Target) []ConfigField {
}) })
} }
return append(fields, retryFields(t)...) fields = append(fields, maxRetriesField(t))
}
// retryFields describes a target's retry settings, which live
// on the target row rather than in its configuration blob.
func retryFields(t *database.Target) []ConfigField {
retries := strconv.Itoa(t.MaxRetries)
if t.MaxRetries == 0 {
retries += " (fire-and-forget)"
}
fields := []ConfigField{{
Label: "Max Retries",
Value: retries,
}}
if t.MaxQueueSize > 0 {
fields = append(fields, ConfigField{
Label: "Max Queue Size",
Value: strconv.Itoa(t.MaxQueueSize),
})
}
return fields return fields
} }
// databaseConfigFields describes an archive target. Its // maxRetriesField describes a target's retry count, which lives
// configuration is optional, and an absent or empty expiry // on the target row rather than in its configuration blob. A
// means the archive is kept forever. An expiry that is set // stored 0 makes a single attempt, so it is shown as 1.
// but not a valid duration is reported as unavailable rather func maxRetriesField(t *database.Target) ConfigField {
// than echoed back. attempts := strconv.Itoa(t.MaxRetries)
if t.MaxRetries == 0 {
attempts = "1 (fire-and-forget: no retries, no circuit breaker)"
}
return ConfigField{
Label: "Delivery attempts",
Value: attempts,
}
}
// databaseConfigFields describes an archive target by its
// expiry in plain units, such as "30 days", or "never" when
// the archive is kept forever, and by its rotation. An expiry
// that is set but not a valid duration, or a rotation that is
// not one of the four, is reported as unavailable rather than
// echoed back.
func databaseConfigFields(configJSON string) []ConfigField { func databaseConfigFields(configJSON string) []ConfigField {
expiry := archiveExpiryNever expiry, err := parseArchiveExpiry(configJSON)
if configJSON != "" {
var cfg databaseTargetConfig
err := json.Unmarshal([]byte(configJSON), &cfg)
if err != nil { if err != nil {
return unavailableConfigFields() return unavailableConfigFields()
} }
if cfg.Expiry != "" { rotation, err := parseArchiveRotation(configJSON)
if ValidateArchiveExpiry(cfg.Expiry) != nil { if err != nil {
return unavailableConfigFields() return unavailableConfigFields()
} }
expiry = cfg.Expiry value := archiveExpiryNever
} if expiry > 0 {
value = plainDuration(expiry)
} }
return []ConfigField{{ return []ConfigField{{
Label: "Archive Expiry", Label: "Archive expiry",
Value: expiry, Value: value,
}, {
Label: "Archive rotation",
Value: rotation,
}} }}
} }
// plainDuration writes a positive duration as a count of the
// largest whole unit it divides into: "30 days", "12 hours",
// "1 minute". A duration with a fraction of a second is
// written as Go writes it.
func plainDuration(d time.Duration) string {
const day = 24 * time.Hour
units := []struct {
size time.Duration
name string
}{
{day, "day"},
{time.Hour, "hour"},
{time.Minute, "minute"},
{time.Second, "second"},
}
for _, unit := range units {
if d%unit.size != 0 {
continue
}
count := int64(d / unit.size)
if count == 1 {
return "1 " + unit.name
}
return fmt.Sprintf("%d %ss", count, unit.name)
}
return d.String()
}
// MaskedWebhookURL returns the Slack webhook URL reduced to // MaskedWebhookURL returns the Slack webhook URL reduced to
// its scheme and host, with the path, query and any userinfo // its scheme and host, with the path, query and any userinfo
// elided. The path segments are the credential, so none of // elided. The path segments are the credential, so none of
+55 -13
View File
@@ -32,7 +32,7 @@ const (
viewMaskedOrigin = viewExampleOrigin + "/..." viewMaskedOrigin = viewExampleOrigin + "/..."
viewUnavailable = "(unavailable)" viewUnavailable = "(unavailable)"
viewExpiryNever = "never" viewExpiryNever = "never"
viewMaxRetries = "Max Retries" viewMaxRetries = "Delivery attempts"
) )
func TestMaskedWebhookURL(t *testing.T) { func TestMaskedWebhookURL(t *testing.T) {
@@ -190,20 +190,19 @@ func TestNewTargetViews_Slack(t *testing.T) {
t, t,
map[string]string{ map[string]string{
"Webhook URL": slackMaskedURL, "Webhook URL": slackMaskedURL,
viewMaxRetries: "0 (fire-and-forget)", viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
}, },
fieldMap(view.Config), fieldMap(view.Config),
) )
} }
// TestNewTargetViews_SlackRetries proves a Slack target shows // TestNewTargetViews_SlackRetries proves a Slack target shows
// its retry settings the same way an HTTP target does. // its retry count the same way an HTTP target does.
func TestNewTargetViews_SlackRetries(t *testing.T) { func TestNewTargetViews_SlackRetries(t *testing.T) {
t.Parallel() t.Parallel()
target := slackTarget() target := slackTarget()
target.MaxRetries = 2 target.MaxRetries = 2
target.MaxQueueSize = 100
view := viewFor(t, target) view := viewFor(t, target)
@@ -212,7 +211,6 @@ func TestNewTargetViews_SlackRetries(t *testing.T) {
map[string]string{ map[string]string{
"Webhook URL": slackMaskedURL, "Webhook URL": slackMaskedURL,
viewMaxRetries: "2", viewMaxRetries: "2",
"Max Queue Size": "100",
}, },
fieldMap(view.Config), fieldMap(view.Config),
) )
@@ -227,7 +225,6 @@ func TestNewTargetViews_HTTP(t *testing.T) {
`"timeout":30,` + `"timeout":30,` +
`"headers":{"Authorization":"Bearer sekrit"}}`, `"headers":{"Authorization":"Bearer sekrit"}}`,
MaxRetries: 5, MaxRetries: 5,
MaxQueueSize: 100,
}) })
fields := fieldMap(view.Config) fields := fieldMap(view.Config)
@@ -239,7 +236,6 @@ func TestNewTargetViews_HTTP(t *testing.T) {
"Timeout": "30s", "Timeout": "30s",
"Headers": "1 configured", "Headers": "1 configured",
viewMaxRetries: "5", viewMaxRetries: "5",
"Max Queue Size": "100",
}, },
fields, fields,
) )
@@ -262,7 +258,7 @@ func TestNewTargetViews_HTTPFireAndForget(t *testing.T) {
t, t,
map[string]string{ map[string]string{
"Destination URL": viewMaskedOrigin, "Destination URL": viewMaskedOrigin,
viewMaxRetries: "0 (fire-and-forget)", viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
}, },
fieldMap(view.Config), fieldMap(view.Config),
) )
@@ -305,14 +301,20 @@ func TestNewTargetViews_Database(t *testing.T) {
}{ }{
"empty config": {config: "", want: viewExpiryNever}, "empty config": {config: "", want: viewExpiryNever},
"empty expiry": {config: `{}`, want: viewExpiryNever}, "empty expiry": {config: `{}`, want: viewExpiryNever},
"explicit": {
config: `{"expiry":"720h"}`,
want: "720h",
},
"never literal": { "never literal": {
config: `{"expiry":"` + viewExpiryNever + `"}`, config: `{"expiry":"` + viewExpiryNever + `"}`,
want: viewExpiryNever, want: viewExpiryNever,
}, },
"1h": {config: `{"expiry":"1h"}`, want: "1 hour"},
"12h": {config: `{"expiry":"12h"}`, want: "12 hours"},
"24h": {config: `{"expiry":"24h"}`, want: "1 day"},
"720h": {config: `{"expiry":"720h"}`, want: "30 days"},
"2160h": {config: `{"expiry":"2160h"}`, want: "90 days"},
"8760h": {config: `{"expiry":"8760h"}`, want: "365 days"},
"36h": {config: `{"expiry":"36h"}`, want: "36 hours"},
"1h30m": {config: `{"expiry":"1h30m"}`, want: "90 minutes"},
"45s": {config: `{"expiry":"45s"}`, want: "45 seconds"},
"1.5s": {config: `{"expiry":"1.5s"}`, want: "1.5s"},
} }
for name, tc := range tests { for name, tc := range tests {
@@ -326,13 +328,49 @@ func TestNewTargetViews_Database(t *testing.T) {
assert.Equal( assert.Equal(
t, t,
map[string]string{"Archive Expiry": tc.want}, map[string]string{
"Archive expiry": tc.want,
"Archive rotation": rotationNone,
},
fieldMap(view.Config), fieldMap(view.Config),
) )
}) })
} }
} }
// TestNewTargetViews_DatabaseRotation proves the target list shows a
// database target's rotation, none when it has none stored.
func TestNewTargetViews_DatabaseRotation(t *testing.T) {
t.Parallel()
// Each stored config, and the rotation the list shows for it.
tests := map[string]string{
"": rotationNone,
`{"rotation":""}`: rotationNone,
}
for _, rotation := range []string{
rotationNone, rotationMonthly, rotationDaily, rotationHourly,
} {
tests[`{"rotation":"`+rotation+`"}`] = rotation
}
for config, want := range tests {
t.Run(config, func(t *testing.T) {
t.Parallel()
view := viewFor(t, database.Target{
Type: database.TargetTypeDatabase,
Config: config,
})
assert.Equal(
t, want, fieldMap(view.Config)["Archive rotation"],
)
})
}
}
func TestNewTargetViews_Log(t *testing.T) { func TestNewTargetViews_Log(t *testing.T) {
t.Parallel() t.Parallel()
@@ -380,6 +418,10 @@ func TestNewTargetViews_Unpresentable(t *testing.T) {
Type: database.TargetTypeDatabase, Type: database.TargetTypeDatabase,
Config: `{"expiry":"a fortnight"}`, Config: `{"expiry":"a fortnight"}`,
}, },
"invalid archive rotation": {
Type: database.TargetTypeDatabase,
Config: weeklyConfig,
},
} }
for name, target := range tests { for name, target := range tests {
+26 -11
View File
@@ -20,7 +20,8 @@ const archiveNameMaxLen = 40
// from the per-webhook event database. The event is already // from the per-webhook event database. The event is already
// persisted in the per-webhook event DB by the time delivery runs; // persisted in the per-webhook event DB by the time delivery runs;
// the database target additionally writes a durable long-term copy // the database target additionally writes a durable long-term copy
// into the file ArchiveFileName names and then records a single // into the file ArchiveFileName names, with a period added when the
// target rotates (see archivePeriodPath), and then records a single
// attempt whose outcome reflects whether the archive write // attempt whose outcome reflects whether the archive write
// succeeded. See archiveWriter for the close/reopen, auto-recreate, // succeeded. See archiveWriter for the close/reopen, auto-recreate,
// and expiry semantics. // and expiry semantics.
@@ -146,8 +147,9 @@ func (t *databaseTarget) Deliver(
} }
// archive writes the full event as a row into the target's // archive writes the full event as a row into the target's
// archive database, honouring the optional per-target expiry // archive database, honouring the optional per-target expiry and
// parsed from the target config JSON. // rotation parsed from the target config JSON. With rotation, the
// event goes to the file for the period of its receive time.
func (t *databaseTarget) archive(d *database.Delivery) error { func (t *databaseTarget) archive(d *database.Delivery) error {
webhookID := d.Event.WebhookID webhookID := d.Event.WebhookID
if webhookID == "" { if webhookID == "" {
@@ -159,6 +161,19 @@ func (t *databaseTarget) archive(d *database.Delivery) error {
return err return err
} }
rotation, err := parseArchiveRotation(d.Target.Config)
if err != nil {
return err
}
// An event whose stored row was gone before its delivery ran has
// no receive time (see Engine.hydrateEvent), and goes to the
// file for now.
receivedAt := d.Event.CreatedAt
if receivedAt.IsZero() {
receivedAt = time.Now()
}
w, err := t.writerFor(d.TargetID) w, err := t.writerFor(d.TargetID)
if err != nil { if err != nil {
return err return err
@@ -174,7 +189,7 @@ func (t *databaseTarget) archive(d *database.Delivery) error {
ContentType: d.Event.ContentType, ContentType: d.Event.ContentType,
} }
return w.write(row, expiry) return w.write(row, expiry, archivePeriod(rotation, receivedAt))
} }
// writerFor returns the archive writer for a database target, // writerFor returns the archive writer for a database target,
@@ -275,10 +290,10 @@ func (t *databaseTarget) releaseSweepWriter(
delete(t.writers, targetID) delete(t.writers, targetID)
} }
// newWriter builds the writer for a database target's archive. The // newWriter builds the writer for a database target's archive. Its
// file is the one ArchivePath gives for the webhook and the target as // path is the one ArchivePath gives for the webhook and the target as
// the main database names them now; from then on only rename changes // the main database names them now; from then on only rename changes
// the name the writer uses. It does not touch the archive file. // the name the writer uses. It does not touch the archive files.
func (t *databaseTarget) newWriter( func (t *databaseTarget) newWriter(
targetID string, targetID string,
) (*archiveWriter, error) { ) (*archiveWriter, error) {
@@ -306,10 +321,10 @@ func (t *databaseTarget) newWriter(
return w, nil return w, nil
} }
// rename moves a database target's archive file to the name for // rename moves every one of a database target's archive files to the
// webhookName and targetName. It goes through the target's writer, // name for webhookName and targetName. It goes through the target's
// so the move holds the lock that writes and the idle sweep take, // writer, so the move holds the lock that writes and the idle sweep
// and later writes use the new name. // take, and later writes use the new name.
// //
// The writer is created if there is none, and it stays cached. The // The writer is created if there is none, and it stays cached. The
// handlers rename before they save the new name, so until the save // handlers rename before they save the new name, so until the save
+190 -66
View File
@@ -85,6 +85,10 @@ type databaseTargetConfig struct {
// archived rows are pruned, or "never" (the default) to // archived rows are pruned, or "never" (the default) to
// keep them forever. // keep them forever.
Expiry string `json:"expiry"` Expiry string `json:"expiry"`
// Rotation is none (the default), monthly, daily or hourly: see
// archivePeriod.
Rotation string `json:"rotation"`
} }
// archivedEvent is one fully captured webhook event stored in a // archivedEvent is one fully captured webhook event stored in a
@@ -178,7 +182,7 @@ func ValidateArchiveExpiry(expiry string) error {
return nil return nil
} }
// archiveWriter owns one database target's archive SQLite file. // archiveWriter owns one database target's archive SQLite files.
// It serialises writes, and after each write closes and reopens // It serialises writes, and after each write closes and reopens
// the file (debounced to at most once per debounce window) so // the file (debounced to at most once per debounce window) so
// an operator can move the file away for offline archiving. The // an operator can move the file away for offline archiving. The
@@ -187,7 +191,15 @@ func ValidateArchiveExpiry(expiry string) error {
// on every open. // on every open.
type archiveWriter struct { type archiveWriter struct {
mu sync.Mutex mu sync.Mutex
// path is the target's archive file as ArchivePath names it. A
// target that rotates writes to the files archivePeriodPath names
// for path and a period instead.
path string path string
// current is the file db is open on.
current string
log *slog.Logger log *slog.Logger
debounce time.Duration debounce time.Duration
db *gorm.DB db *gorm.DB
@@ -236,12 +248,14 @@ func newArchiveWriter(
} }
} }
// write appends the event as a row, then applies the debounced // write appends the event as a row to the archive file for period
// close/reopen. It recreates the archive file if it was moved // (see archivePeriodPath), then applies the debounced close/reopen.
// or removed since the last open. A positive expiry prunes rows // When period names a different file from the one open, the open one
// older than it on each (re)open. // is closed first. It recreates the archive file if it was moved or
// removed since the last open. A positive expiry prunes rows older
// than it on each (re)open.
func (w *archiveWriter) write( func (w *archiveWriter) write(
row archivedEvent, expiry time.Duration, row archivedEvent, expiry time.Duration, period string,
) error { ) error {
w.mu.Lock() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()
@@ -252,8 +266,10 @@ func (w *archiveWriter) write(
) )
} }
if w.db == nil || !fileExists(w.path) { file := archivePeriodPath(w.path, period)
err := w.reopen(expiry)
if w.db == nil || w.current != file || !fileExists(file) {
err := w.reopen(file, expiry)
if err != nil { if err != nil {
return err return err
} }
@@ -264,41 +280,41 @@ func (w *archiveWriter) write(
err := w.db.Create(&row).Error err := w.db.Create(&row).Error
if err != nil { if err != nil {
return fmt.Errorf( return fmt.Errorf(
"archiving event to %s: %w", w.path, err, "archiving event to %s: %w", file, err,
) )
} }
if w.now().Sub(w.lastReopen) >= w.debounce { if w.now().Sub(w.lastReopen) >= w.debounce {
return w.reopen(expiry) return w.reopen(file, expiry)
} }
return nil return nil
} }
// open opens (creating if missing) the archive file, migrates // open opens (creating if missing) an archive file, migrates
// its schema, records the reopen time, and prunes expired rows // its schema, records the reopen time, and prunes expired rows
// when expiry is positive. // when expiry is positive.
func (w *archiveWriter) open(expiry time.Duration) error { func (w *archiveWriter) open(file string, expiry time.Duration) error {
return w.openMode(archiveModeCreate, expiry) return w.openMode(file, archiveModeCreate, expiry)
} }
// openMode opens the archive file with the given SQLite URI // openMode opens an archive file with the given SQLite URI
// mode, migrates its schema, records the reopen time, and // mode, migrates its schema, records the reopen time, and
// prunes expired rows when expiry is positive. The write path // prunes expired rows when expiry is positive. The write path
// passes archiveModeCreate so a missing file is recreated; the // passes archiveModeCreate so a missing file is recreated; the
// idle sweep passes archiveModeExisting so a missing file is an // idle sweep passes archiveModeExisting so a missing file is an
// error rather than a newly conjured empty archive. // error rather than a newly conjured empty archive.
func (w *archiveWriter) openMode( func (w *archiveWriter) openMode(
mode string, expiry time.Duration, file, mode string, expiry time.Duration,
) error { ) error {
// Opened through database.OpenSQLite so an archive file carries // Opened through database.OpenSQLite so an archive file carries
// the same WAL journaling, busy timeout, immediate-transaction // the same WAL journaling, busy timeout, immediate-transaction
// locking, and pool bounds as every other database file. See // locking, and pool bounds as every other database file. See
// internal/database/sqlite_open.go. // internal/database/sqlite_open.go.
sqlDB, err := database.OpenSQLite(w.path, mode) sqlDB, err := database.OpenSQLite(file, mode)
if err != nil { if err != nil {
return fmt.Errorf( return fmt.Errorf(
"opening archive database %s: %w", w.path, err, "opening archive database %s: %w", file, err,
) )
} }
@@ -314,7 +330,7 @@ func (w *archiveWriter) openMode(
return fmt.Errorf( return fmt.Errorf(
"connecting to archive database %s: %w", "connecting to archive database %s: %w",
w.path, err, file, err,
) )
} }
@@ -323,11 +339,12 @@ func (w *archiveWriter) openMode(
_ = sqlDB.Close() _ = sqlDB.Close()
return fmt.Errorf( return fmt.Errorf(
"migrating archive database %s: %w", w.path, err, "migrating archive database %s: %w", file, err,
) )
} }
w.db = gdb w.db = gdb
w.current = file
w.lastReopen = w.now() w.lastReopen = w.now()
w.reopens++ w.reopens++
@@ -338,12 +355,12 @@ func (w *archiveWriter) openMode(
return nil return nil
} }
// reopen closes any open handle and opens the file afresh. The // reopen closes any open handle and opens file afresh. The
// fresh open recreates the file if it was moved away. // fresh open recreates the file if it was moved away.
func (w *archiveWriter) reopen(expiry time.Duration) error { func (w *archiveWriter) reopen(file string, expiry time.Duration) error {
w.close() w.close()
return w.open(expiry) return w.open(file, expiry)
} }
// close closes the underlying handle, if any. // close closes the underlying handle, if any.
@@ -360,22 +377,56 @@ func (w *archiveWriter) close() {
w.db = nil w.db = nil
} }
// sweepExpired prunes an archive that may have gone idle, with // sweepExpired prunes the target's archive files, which may have
// no write to trigger the usual on-reopen prune. It takes the // gone idle, with no write to trigger the usual on-reopen prune. It
// writer's own mutex for the whole operation, so a sweep is // lists the files under the writer's own mutex, then takes the mutex
// ordered against concurrent writes rather than reaching around // again for one file at a time, so a write waits for at most one
// them to the file. // file's prune, and each prune is ordered against concurrent writes
// rather than reaching around them to the file.
// //
// It never creates the archive file: a missing file is skipped, // It never creates an archive file: it prunes only the files
// and the reopen uses archiveModeExisting so SQLite itself // archiveFiles lists, skips one that is gone by the time it is
// refuses to create one if the file disappears between the // reached (moved away, or renamed since the listing), and opens each
// check and the open. // with archiveModeExisting so SQLite itself refuses to create one if
// the file disappears between the check and the open. A file named
// for a period that the prune leaves empty is deleted.
// //
// The archive is left CLOSED afterwards. An idle archive holding // The archive is left CLOSED afterwards. An idle archive holding
// no handle is what keeps the operator's move-the-file-away // no handle is what keeps the operator's move-the-file-away
// workflow working; the next write reopens (and recreates) the // workflow working; the next write reopens (and recreates) the
// file as it always has. // file as it always has.
func (w *archiveWriter) sweepExpired(expiry time.Duration) error { func (w *archiveWriter) sweepExpired(expiry time.Duration) error {
w.mu.Lock()
files, err := archiveFiles(w.path)
w.mu.Unlock()
if err != nil {
return err
}
var errs []error
for _, file := range files {
err = w.sweepFile(file, expiry)
if errors.Is(err, errArchiveWriterEvicted) {
return err
}
if err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// sweepFile prunes one of the target's archive files for sweepExpired,
// holding w.mu while it does. It skips a file that is gone, and deletes
// the file, with its -wal and -shm, when it is named for a period and
// the prune leaves it empty.
func (w *archiveWriter) sweepFile(
file archiveFile, expiry time.Duration,
) error {
w.mu.Lock() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()
@@ -385,7 +436,7 @@ func (w *archiveWriter) sweepExpired(expiry time.Duration) error {
) )
} }
if !fileExists(w.path) { if !fileExists(file.path) {
return nil return nil
} }
@@ -393,26 +444,56 @@ func (w *archiveWriter) sweepExpired(expiry time.Duration) error {
// freshly opened file, matching the write path's semantics. // freshly opened file, matching the write path's semantics.
w.close() w.close()
err := w.openMode(archiveModeExisting, expiry) err := w.openMode(file.path, archiveModeExisting, expiry)
if err != nil { if err != nil {
return err return err
} }
if file.period == "" {
w.close() w.close()
return nil return nil
} }
// rename gives the archive file a new name in the same directory, var rows int64
// 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 err = w.db.Model(&archivedEvent{}).Count(&rows).Error
// -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 w.close()
// error: the operator may have moved it away, and the next write
// creates it under the new name. if err != nil {
return fmt.Errorf(
"counting rows in archive %s: %w", file.path, err,
)
}
if rows > 0 {
return nil
}
for _, suffix := range []string{"", "-wal", "-shm"} {
err = os.Remove(file.path + suffix)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("deleting empty archive file: %w", err)
}
}
w.log.Info("deleted empty archive file", "path", file.path)
return nil
}
// rename gives every one of the target's archive files the new
// name, keeping the period in the name of each (see
// archivePeriodPath), and the writer uses the files under that name
// from now on. The handle is closed first, which folds the -wal into
// the .db; any -wal or -shm still beside a file (left by a crash) is
// moved with it, because SQLite finds them by name. A target with no
// files is not an error: the operator may have moved them away, and
// the next write creates its file under the new name.
// //
// If a file already has the new name, nothing is moved and the // If a file already has one of the new names, nothing is moved and
// error is ErrArchiveNameTaken. If one file fails to move, those // the error is ErrArchiveNameTaken. If one file fails to move, those
// already moved are moved back before the error is returned, so the // already moved are moved back before the error is returned, so the
// archive is never split across two names. // archive is never split across two names.
func (w *archiveWriter) rename(name string) error { func (w *archiveWriter) rename(name string) error {
@@ -430,38 +511,53 @@ func (w *archiveWriter) rename(name string) error {
return nil return nil
} }
suffixes := []string{"", "-wal", "-shm"} files, err := archiveFiles(w.path)
if err != nil {
return err
}
for _, suffix := range suffixes { // from[i] moves to to[i].
if fileExists(path + suffix) { var from, to []string
for _, file := range files {
renamed := archivePeriodPath(path, file.period)
for _, suffix := range []string{"", "-wal", "-shm"} {
from = append(from, file.path+suffix)
to = append(to, renamed+suffix)
}
}
for _, taken := range to {
if fileExists(taken) {
return fmt.Errorf( return fmt.Errorf(
"%w: %s", ErrArchiveNameTaken, name+suffix, "%w: %s", ErrArchiveNameTaken, filepath.Base(taken),
) )
} }
} }
w.close() w.close()
for i, suffix := range suffixes { for i := range from {
err := os.Rename(w.path+suffix, path+suffix) err = os.Rename(from[i], to[i])
if err == nil || errors.Is(err, fs.ErrNotExist) { if err == nil || errors.Is(err, fs.ErrNotExist) {
continue continue
} }
for _, moved := range suffixes[:i] { for j := range i {
backErr := os.Rename(path+moved, w.path+moved) backErr := os.Rename(to[j], from[j])
if backErr != nil && !errors.Is(backErr, fs.ErrNotExist) { if backErr != nil && !errors.Is(backErr, fs.ErrNotExist) {
w.log.Error( w.log.Error(
"failed to move archive file back", "failed to move archive file back",
"from", path+moved, "from", to[j],
"to", w.path+moved, "to", from[j],
"error", backErr, "error", backErr,
) )
} }
} }
return fmt.Errorf( return fmt.Errorf(
"renaming archive %s to %s: %w", w.path+suffix, path+suffix, err, "renaming archive %s to %s: %w", from[i], to[i], err,
) )
} }
@@ -499,7 +595,7 @@ func (w *archiveWriter) prune(expiry time.Duration) {
if res.Error != nil { if res.Error != nil {
w.log.Error( w.log.Error(
"failed to prune expired archive rows", "failed to prune expired archive rows",
"path", w.path, "path", w.current,
"error", res.Error, "error", res.Error,
) )
@@ -509,38 +605,59 @@ func (w *archiveWriter) prune(expiry time.Duration) {
if res.RowsAffected > 0 { if res.RowsAffected > 0 {
w.log.Info( w.log.Info(
"pruned expired archive rows", "pruned expired archive rows",
"path", w.path, "path", w.current,
"rows_deleted", res.RowsAffected, "rows_deleted", res.RowsAffected,
) )
} }
} }
// ArchiveFileInfo is what the metadata of a database target's archive // ArchiveFileInfo is what the metadata of a database target's archive
// file says about it. // files says about them.
type ArchiveFileInfo struct { type ArchiveFileInfo struct {
// Size is the bytes on disk of the file and its -wal together. // Files counts the files.
Files int
// Size is the bytes on disk of the files and their -wal together.
Size int64 Size int64
// Written is when the file or its -wal was last modified, whichever // Written is when a file or a -wal was last modified, whichever is
// is later: a write lands in the -wal first. // latest: a write lands in the -wal first.
Written time.Time Written time.Time
} }
// StatArchive reads the metadata of the archive file at path and of // StatArchive reads the metadata of a database target's archive
// its -wal, without opening the archive. With no file at path, which is // files, given the path ArchivePath gives it (see archiveFiles), and
// so before the first write and after the operator moved it away, the // of their -wal, without opening them. With no files, which is so
// before the first write and after the operator moved them away, the
// error wraps fs.ErrNotExist. // error wraps fs.ErrNotExist.
func StatArchive(path string) (ArchiveFileInfo, error) { func StatArchive(path string) (ArchiveFileInfo, error) {
file, err := os.Stat(path) files, err := archiveFiles(path)
if err != nil { if err != nil {
return ArchiveFileInfo{}, err return ArchiveFileInfo{}, err
} }
info := ArchiveFileInfo{Size: file.Size(), Written: file.ModTime()} var info ArchiveFileInfo
wal, err := os.Stat(path + "-wal") for _, file := range files {
db, err := os.Stat(file.path)
if errors.Is(err, fs.ErrNotExist) { if errors.Is(err, fs.ErrNotExist) {
return info, nil continue
}
if err != nil {
return ArchiveFileInfo{}, err
}
info.Files++
info.Size += db.Size()
if db.ModTime().After(info.Written) {
info.Written = db.ModTime()
}
wal, err := os.Stat(file.path + "-wal")
if errors.Is(err, fs.ErrNotExist) {
continue
} }
if err != nil { if err != nil {
@@ -552,6 +669,13 @@ func StatArchive(path string) (ArchiveFileInfo, error) {
if wal.ModTime().After(info.Written) { if wal.ModTime().After(info.Written) {
info.Written = wal.ModTime() info.Written = wal.ModTime()
} }
}
if info.Files == 0 {
return ArchiveFileInfo{}, fmt.Errorf(
"no archive file for %s: %w", path, fs.ErrNotExist,
)
}
return info, nil return info, nil
} }
+166 -55
View File
@@ -6,10 +6,14 @@ import (
"database/sql" "database/sql"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"io/fs"
"log/slog" "log/slog"
"os"
"path/filepath" "path/filepath"
"sync"
"time" "time"
"unicode/utf8" "unicode/utf8"
@@ -50,21 +54,46 @@ func ArchiveExportFileName(
at.UTC().Format("20060102T150405Z") + ".json.gz" at.UTC().Format("20060102T150405Z") + ".json.gz"
} }
// ArchiveExport is a database target's archive opened for download. // ArchiveExport is a database target's archive listed for download. It
// It reads the file on its own connection, inside one read-only // opens one of the target's files at a time, only when its rows are
// transaction, so it writes out the archive as it stood when // about to be written out, and closes it before it opens the next, so
// OpenArchiveExport returned. // an export holds at most one file open however many the target has.
// //
// Archives are in WAL mode, where a reader works from a snapshot and // Each file is read on its own connection inside one read-only
// never blocks a writer: archive writes go on while an export is open, // transaction, so its rows are written out as the file stood when it
// and the export does not see them. SQLite cannot checkpoint the -wal // was opened. Archives are in WAL mode, where a reader works from a
// past an open snapshot, so the -wal grows until the export is closed. // snapshot and never blocks a writer: archive writes go on while a file
// is open, and the export does not see them. SQLite cannot checkpoint
// a -wal past an open snapshot, so the open file's -wal grows until the
// export has written that file out.
type ArchiveExport struct { type ArchiveExport struct {
// periods are the periods of the target's files when the export
// was listed, "" for the file without one, in the order
// archiveFiles lists them.
periods []string
// lock is held while currentPath is called and a file is opened,
// so that a rename, which holds it too, cannot move the file in
// between.
lock sync.Locker
// currentPath returns the path ArchivePath gives the target under
// the names stored for it now, which a rename may have changed since
// the export was listed.
currentPath func() (string, error)
log *slog.Logger
}
// exportFile is one archive file opened for an export.
type exportFile struct {
db *sql.DB db *sql.DB
tx *gorm.DB tx *gorm.DB
// empty is true when there is nothing to read: no file, or a file // period is the period in the file's name, "" for none.
// without the archive's table yet. period string
// empty is true for a file without the archive's table yet.
empty bool empty bool
} }
@@ -74,26 +103,46 @@ type exportedName struct {
Name string `json:"name"` Name string `json:"name"`
} }
// OpenArchiveExport opens the archive file at path for export and // NewArchiveExport lists a database target's archive files for export,
// takes the snapshot the export reads. It never creates the file: with // given the path ArchivePath gives it (see archiveFiles). It opens none
// no file at path, the export has no rows. // of them. Its caller holds lock, which every rename of the target's
// files runs under, from reading the names path is made of until it
// returns, so the files it lists are the ones those names give.
// //
// Once it has returned, the file is open, so a rename or a move of it // WriteGzipJSON, called without lock held, finds each file again by its
// does not affect the export, which reads the same file under its new // period under the path currentPath gives, holding lock while it does
// name. // and while it opens the file, so a rename during the export loses no
// // file. A file that is gone by then, emptied by the sweep or moved
// The transaction lasts as long as ctx does, so ctx must last for the // away, is skipped. The export never creates a file: with no files, it
// whole export. // has no rows.
func OpenArchiveExport( func NewArchiveExport(
ctx context.Context, path string, log *slog.Logger, path string,
lock sync.Locker,
currentPath func() (string, error),
log *slog.Logger,
) (*ArchiveExport, error) { ) (*ArchiveExport, error) {
if !fileExists(path) { files, err := archiveFiles(path)
return &ArchiveExport{empty: true}, nil if err != nil {
return nil, err
} }
db, err := database.OpenSQLite(path, archiveModeExisting) x := &ArchiveExport{lock: lock, currentPath: currentPath, log: log}
for _, file := range files {
x.periods = append(x.periods, file.period)
}
return x, nil
}
// openExportFile opens one archive file for an export and takes its
// snapshot. The transaction lasts as long as ctx does.
func openExportFile(
ctx context.Context, file archiveFile, log *slog.Logger,
) (*exportFile, error) {
db, err := database.OpenSQLite(file.path, archiveModeExisting)
if err != nil { if err != nil {
return nil, fmt.Errorf("opening archive %s: %w", path, err) return nil, fmt.Errorf("opening archive %s: %w", file.path, err)
} }
gdb, err := gorm.Open( gdb, err := gorm.Open(
@@ -106,7 +155,7 @@ func OpenArchiveExport(
if err != nil { if err != nil {
_ = db.Close() _ = db.Close()
return nil, fmt.Errorf("opening archive %s: %w", path, err) return nil, fmt.Errorf("opening archive %s: %w", file.path, err)
} }
// ReadOnly makes the driver begin a deferred transaction in place // ReadOnly makes the driver begin a deferred transaction in place
@@ -116,7 +165,9 @@ func OpenArchiveExport(
if tx.Error != nil { if tx.Error != nil {
_ = db.Close() _ = db.Close()
return nil, fmt.Errorf("reading archive %s: %w", path, tx.Error) return nil, fmt.Errorf(
"reading archive %s: %w", file.path, tx.Error,
)
} }
// The transaction's first read is what takes the snapshot. // The transaction's first read is what takes the snapshot.
@@ -127,22 +178,27 @@ func OpenArchiveExport(
_ = tx.Rollback() _ = tx.Rollback()
_ = db.Close() _ = db.Close()
return nil, fmt.Errorf("reading archive %s: %w", path, err) return nil, fmt.Errorf("reading archive %s: %w", file.path, err)
} }
return &ArchiveExport{db: db, tx: tx, empty: tables == 0}, nil return &exportFile{
db: db, tx: tx, period: file.period, empty: tables == 0,
}, nil
} }
// WriteGzipJSON writes the export to w as one gzipped JSON object: // WriteGzipJSON writes the export to w as one gzipped JSON object:
// webhook and target, each an id and a name; exported_at; and // webhook and target, each an id and a name; exported_at; and
// archived_events, one object per archived row, keyed by column name. // archived_events, one object per archived row, keyed by column name,
// A body that is not valid UTF-8 cannot be a JSON string, so it is // the files in the order archiveFiles lists them. A row from a file
// written in base64, with "body_encoding": "base64" beside it. // named for a period has "period" beside its columns. A body that is
// not valid UTF-8 cannot be a JSON string, so it is written in base64,
// with "body_encoding": "base64" beside it.
// //
// Each row is written out before the next is read, so neither the // Each row is written out before the next is read, so neither the
// archive nor its JSON is ever held in memory whole. After an error // archive nor its JSON is ever held in memory whole, and each file is
// the gzip stream is left unfinished, so what was written does not // closed once its rows are written, before the next is opened. When it
// decompress as a whole file. // returns, no file is open. After an error the gzip stream is left
// unfinished, so what was written does not decompress as a whole file.
func (x *ArchiveExport) WriteGzipJSON( func (x *ArchiveExport) WriteGzipJSON(
ctx context.Context, ctx context.Context,
w io.Writer, w io.Writer,
@@ -169,15 +225,35 @@ func (x *ArchiveExport) WriteGzipJSON(
return zw.Close() return zw.Close()
} }
// Close ends the export's transaction and closes its connection. // openFile finds the target's archive file for period under the path
func (x *ArchiveExport) Close() error { // currentPath gives now, and opens it for the export, holding x.lock
if x.db == nil { // for both. For a file that is gone, the error wraps fs.ErrNotExist.
return nil func (x *ArchiveExport) openFile(
ctx context.Context, period string,
) (*exportFile, error) {
x.lock.Lock()
defer x.lock.Unlock()
path, err := x.currentPath()
if err != nil {
return nil, fmt.Errorf("finding archive file: %w", err)
} }
_ = x.tx.Rollback() file := archiveFile{path: archivePeriodPath(path, period), period: period}
return x.db.Close() _, err = os.Stat(file.path)
if err != nil {
return nil, err
}
return openExportFile(ctx, file, x.log)
}
// close ends the file's transaction and closes its connection.
func (f *exportFile) close() error {
_ = f.tx.Rollback()
return f.db.Close()
} }
// writeJSON writes head with archived_events added as its last key, // writeJSON writes head with archived_events added as its last key,
@@ -207,46 +283,77 @@ func (x *ArchiveExport) writeJSON(
return err return err
} }
// writeRows writes each archived row to w, oldest first, one per line, // writeRows writes the archived rows of each file to w, one per line,
// separated by commas. // separated by commas, opening each file in turn and closing it once
// its rows are written.
func (x *ArchiveExport) writeRows(ctx context.Context, w io.Writer) error { func (x *ArchiveExport) writeRows(ctx context.Context, w io.Writer) error {
if x.empty { sep := "\n"
for _, period := range x.periods {
f, err := x.openFile(ctx, period)
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return err
}
sep, err = f.writeRows(ctx, w, sep)
err = errors.Join(err, f.close())
if err != nil {
return err
}
}
return nil return nil
} }
rows, err := x.tx.WithContext(ctx). // writeRows writes the file's archived rows to w, oldest first, the
// first after sep and each other after ",\n". It returns what goes
// before the next row: sep again when the file had no rows.
func (f *exportFile) writeRows(
ctx context.Context, w io.Writer, sep string,
) (string, error) {
if f.empty {
return sep, nil
}
rows, err := f.tx.WithContext(ctx).
Model(&archivedEvent{}).Order("id").Rows() Model(&archivedEvent{}).Order("id").Rows()
if err != nil { if err != nil {
return err return "", err
} }
defer func() { _ = rows.Close() }() defer func() { _ = rows.Close() }()
for sep := "\n"; rows.Next(); sep = ",\n" { for ; rows.Next(); sep = ",\n" {
var ev archivedEvent var ev archivedEvent
err = x.tx.ScanRows(rows, &ev) err = f.tx.ScanRows(rows, &ev)
if err != nil { if err != nil {
return err return "", err
} }
_, err = io.WriteString(w, sep) _, err = io.WriteString(w, sep)
if err != nil { if err != nil {
return err return "", err
} }
err = writeRow(w, &ev) err = writeRow(w, &ev, f.period)
if err != nil { if err != nil {
return err return "", err
} }
} }
return rows.Err() return sep, rows.Err()
} }
// writeRow writes an archived row to w as a JSON object keyed by // writeRow writes an archived row to w as a JSON object keyed by
// column name, its body in base64 when it is not valid UTF-8. // column name, its body in base64 when it is not valid UTF-8, with
func writeRow(w io.Writer, ev *archivedEvent) error { // the period of its file beside them unless that is "".
func writeRow(w io.Writer, ev *archivedEvent, period string) error {
row := map[string]any{ row := map[string]any{
"id": ev.ID, "id": ev.ID,
"event_id": ev.EventID, "event_id": ev.EventID,
@@ -264,6 +371,10 @@ func writeRow(w io.Writer, ev *archivedEvent) error {
row["body_encoding"] = "base64" row["body_encoding"] = "base64"
} }
if period != "" {
row["period"] = period
}
line, err := json.Marshal(row) line, err := json.Marshal(row)
if err != nil { if err != nil {
return err return err
+202 -51
View File
@@ -12,6 +12,8 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings"
"sync"
"testing" "testing"
"time" "time"
@@ -29,14 +31,8 @@ const (
exportTargetName = "Long-term archive" exportTargetName = "Long-term archive"
) )
const (
// binaryBody is a body that is not valid UTF-8. // binaryBody is a body that is not valid UTF-8.
binaryBody = "\xff\xfe\x00\x01binary\x80" const binaryBody = "\xff\xfe\x00\x01binary\x80"
// openedEventID is the event the snapshot tests archive before
// they open the export.
openedEventID = "opened"
)
// writeExportTo writes export to w as the archive of the export tests' // writeExportTo writes export to w as the archive of the export tests'
// webhook and target, exported at 2026-10-02T12:03:04Z. // webhook and target, exported at 2026-10-02T12:03:04Z.
@@ -59,19 +55,41 @@ func writeExportTo(
) )
} }
// listExport lists the archive at path for export, as the archive of a
// target whose names do not change.
func listExport(t *testing.T, path string) *delivery.ArchiveExport {
t.Helper()
return newExport(t, path, &sync.Mutex{}, func() (string, error) {
return path, nil
})
}
// newExport lists the archive at path for export, to find each file
// again under the path currentPath gives, holding lock while it does.
// Nothing else takes lock while it lists, so it does not hold lock.
func newExport(
t *testing.T,
path string,
lock sync.Locker,
currentPath func() (string, error),
) *delivery.ArchiveExport {
t.Helper()
export, err := delivery.NewArchiveExport(
path, lock, currentPath, archiveTestLogger(),
)
require.NoError(t, err)
return export
}
// exportArchive runs a whole export of the archive at path and returns // exportArchive runs a whole export of the archive at path and returns
// its JSON, decompressed and parsed. // its JSON, decompressed and parsed.
func exportArchive(t *testing.T, path string) map[string]any { func exportArchive(t *testing.T, path string) map[string]any {
t.Helper() t.Helper()
export, err := delivery.OpenArchiveExport( return writeExport(t, listExport(t, path))
t.Context(), path, archiveTestLogger(),
)
require.NoError(t, err)
defer func() { require.NoError(t, export.Close()) }()
return writeExport(t, export)
} }
// writeExport writes an opened export and returns its JSON, // writeExport writes an opened export and returns its JSON,
@@ -241,66 +259,204 @@ func TestArchiveExport_Empty(t *testing.T) {
} }
} }
// TestArchiveExport_ReadsOneSnapshot proves an export writes the // unlockHook is a sync.Locker that runs fn each time it is unlocked. An
// archive as it was when it was opened, and holds up no archive // export unlocks its lock right after it opens a file.
// write: a row written while the export is open is stored, and is not type unlockHook struct {
// in the export. A write held up for the whole busy timeout would sync.Mutex
// fail.
fn func()
}
func (u *unlockHook) Unlock() {
u.Mutex.Unlock()
u.fn()
}
// TestArchiveExport_ReadsOneSnapshot proves an export writes a file
// out as it was when the export opened it, and holds up no archive
// write: a row written after the export was listed but before the file
// was opened is in the export, and one written while the file is open
// is stored, and is not. A write held up for the whole busy timeout
// would fail.
func TestArchiveExport_ReadsOneSnapshot(t *testing.T) { func TestArchiveExport_ReadsOneSnapshot(t *testing.T) {
t.Parallel() t.Parallel()
path := filepath.Join(t.TempDir(), "archive.db") path := filepath.Join(t.TempDir(), "archive.db")
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0) w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: openedEventID}, 0)) require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "listed"}, 0))
export, err := delivery.OpenArchiveExport(
t.Context(), path, archiveTestLogger(),
)
require.NoError(t, err)
defer func() { require.NoError(t, export.Close()) }()
opened := &unlockHook{fn: func() {
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "during"}, 0)) require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "during"}, 0))
}}
export := newExport(t, path, opened, func() (string, error) {
return path, nil
})
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "before-open"}, 0))
assert.Equal(t, assert.Equal(t,
[]string{openedEventID}, exportedEventIDs(t, writeExport(t, export)), []string{"listed", "before-open"},
exportedEventIDs(t, writeExport(t, export)),
) )
var stored int64 var stored int64
require.NoError(t, openArchiveDBForRead(t, path). require.NoError(t, openArchiveDBForRead(t, path).
Model(&delivery.ExportArchivedEvent{}).Count(&stored).Error) Model(&delivery.ExportArchivedEvent{}).Count(&stored).Error)
assert.Equal(t, int64(2), stored) assert.Equal(t, int64(3), stored)
} }
// TestArchiveExport_SurvivesRename proves that renaming the archive // TestArchiveExport_FindsFilesAfterRename proves that renaming the
// while an export of it is open, as renaming its webhook or target // archive after an export has listed it, as renaming its webhook or
// does, leaves the export reading the same file. // target does, loses no file: the export finds each file again by its
func TestArchiveExport_SurvivesRename(t *testing.T) { // period under the new name. A file moved away by then is skipped.
func TestArchiveExport_FindsFilesAfterRename(t *testing.T) {
t.Parallel() t.Parallel()
path := filepath.Join(t.TempDir(), "archive-old.db") dir := t.TempDir()
path := filepath.Join(dir, "archive-old.db")
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0) w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: openedEventID}, 0)) for _, period := range []string{"", dayPeriod, hourPeriod} {
require.NoError(t, w.WritePeriod(
delivery.ExportArchivedEvent{EventID: "in-" + period}, 0, period,
))
}
export, err := delivery.OpenArchiveExport( current := path
t.Context(), path, archiveTestLogger(), export := newExport(t, path, &sync.Mutex{}, func() (string, error) {
) return current, nil
require.NoError(t, err) })
defer func() { require.NoError(t, export.Close()) }()
require.NoError(t, w.Rename("archive-new.db")) require.NoError(t, w.Rename("archive-new.db"))
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "after"}, 0))
require.NoFileExists(t, path) current = filepath.Join(dir, "archive-new.db")
removeArchiveFiles(t, periodPath(current, dayPeriod))
assert.Equal(t, assert.Equal(t,
[]string{openedEventID}, exportedEventIDs(t, writeExport(t, export)), []string{"in-", "in-" + hourPeriod},
exportedEventIDs(t, writeExport(t, export)),
) )
} }
// TestArchiveExport_EveryFileOldestFirst writes a row to a target's
// file without a period and to its files for a month, an hour and a
// day, and proves the export holds every row: the file without a
// period first, then the others oldest period first, each row from a
// file named for a period carrying that period. A file made after the
// export was listed is not in it.
func TestArchiveExport_EveryFileOldestFirst(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive-wh.db")
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
// Written in an order that is not the export's.
for _, period := range []string{nextDayPeriod, "", hourPeriod, "2026-03"} {
require.NoError(t, w.WritePeriod(
delivery.ExportArchivedEvent{EventID: "in-" + period}, 0, period,
))
}
export := listExport(t, path)
require.NoError(t, w.WritePeriod(
delivery.ExportArchivedEvent{EventID: "later"}, 0, "2026-03-06",
))
events := exportedEvents(t, writeExport(t, export))
ids := make([]string, 0, len(events))
periods := make([]any, 0, len(events))
for _, ev := range events {
ids = append(ids, fmt.Sprint(ev["event_id"]))
periods = append(periods, ev["period"])
}
assert.Equal(t,
[]string{"in-", "in-2026-03", "in-" + hourPeriod, "in-" + nextDayPeriod},
ids,
)
assert.Equal(t,
[]any{nil, "2026-03", hourPeriod, nextDayPeriod}, periods,
)
assert.NotContains(t, events[0], "period",
"a row from the file without a period has no period")
}
// openFilesPeak is an io.Writer that discards what it is given and
// records the most archive files in dir the process had open at any
// write, as /proc/self/fd lists the files a process has open.
type openFilesPeak struct {
dir string
max int
}
func (p *openFilesPeak) Write(b []byte) (int, error) {
fds, err := os.ReadDir("/proc/self/fd")
if err != nil {
return 0, err
}
open := map[string]bool{}
for _, fd := range fds {
file, err := os.Readlink(filepath.Join("/proc/self/fd", fd.Name()))
if err == nil && filepath.Dir(file) == p.dir &&
strings.HasSuffix(file, ".db") {
open[file] = true
}
}
p.max = max(p.max, len(open))
return len(b), nil
}
// TestArchiveExport_OneFileOpenAtATime exports a target with a file for
// each of 24 hours and proves the export never had more than one of
// them open, and had one open while it wrote. Each file holds a row of
// 48 KiB of random base64, which gzip shrinks little, so the export
// writes output while it reads each file.
func TestArchiveExport_OneFileOpenAtATime(t *testing.T) {
t.Parallel()
if runtime.GOOS != "linux" {
t.Skip("only Linux lists a process's open files in /proc/self/fd")
}
// Readlink gives each open file's path with no symbolic link in it.
dir, err := filepath.EvalSymlinks(t.TempDir())
require.NoError(t, err)
path := filepath.Join(dir, "archive-wh.db")
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
random := make([]byte, 36<<10)
for hour := range 24 {
_, _ = rand.Read(random)
require.NoError(t, w.WritePeriod(delivery.ExportArchivedEvent{
Body: base64.StdEncoding.EncodeToString(random),
}, 0, fmt.Sprintf("2026-10-01-%02d", hour)))
}
// The writer's own handle on the last file is not the export's.
w.Evict()
// Through a buffer, the open files are listed once per 8 KiB of
// output, a few times for each file, rather than at each of gzip's
// small writes, which takes far longer.
peak := &openFilesPeak{dir: dir}
buffered := bufio.NewWriterSize(peak, 8<<10)
require.NoError(t, writeExportTo(t, listExport(t, path), buffered))
require.NoError(t, buffered.Flush())
assert.Equal(t, 1, peak.max)
}
// heapPeak is an io.Writer that discards what it is given and records // heapPeak is an io.Writer that discards what it is given and records
// the largest heap it saw at a write. It collects garbage before each // the largest heap it saw at a write. It collects garbage before each
// reading, so the heap it reads is what is still held. // reading, so the heap it reads is what is still held.
@@ -338,12 +494,7 @@ func exportHeapGrowth(t *testing.T, rows, bodySize int) uint64 {
}, 0)) }, 0))
} }
export, err := delivery.OpenArchiveExport( export := listExport(t, path)
t.Context(), path, archiveTestLogger(),
)
require.NoError(t, err)
defer func() { require.NoError(t, export.Close()) }()
runtime.GC() runtime.GC()
@@ -0,0 +1,198 @@
package delivery
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"time"
"sneak.berlin/go/webhooker/internal/database"
)
// The archive rotations: how often a database target starts a new
// archive file. Every rotation but none puts the period of an event's
// receive time, in UTC, in the name of the file the event goes to,
// written in the layout beside it.
const (
archiveRotationNone = "none"
archiveRotationMonthly = "monthly"
archiveRotationDaily = "daily"
archiveRotationHourly = "hourly"
archiveMonthLayout = "2006-01"
archiveDayLayout = "2006-01-02"
archiveHourLayout = "2006-01-02-15"
)
// errArchiveRotationUnknown is returned for a rotation that is not one
// of the four.
var errArchiveRotationUnknown = errors.New(
"rotation must be none, monthly, daily or hourly",
)
// archiveFile is one of a database target's archive files, and the
// period in its name: "" for the file named without one.
type archiveFile struct {
path string
period string
}
// ValidateArchiveRotation checks a user-supplied archive rotation for
// a database target: empty or none (both meaning one file), monthly,
// daily or hourly.
func ValidateArchiveRotation(rotation string) error {
switch rotation {
case "", archiveRotationNone, archiveRotationMonthly,
archiveRotationDaily, archiveRotationHourly:
return nil
default:
return fmt.Errorf("%w: %q", errArchiveRotationUnknown, rotation)
}
}
// parseArchiveRotation reads the rotation from a database target's
// config JSON. An empty config or an empty rotation is none.
func parseArchiveRotation(configJSON string) (string, error) {
if configJSON == "" {
return archiveRotationNone, nil
}
var cfg databaseTargetConfig
err := json.Unmarshal([]byte(configJSON), &cfg)
if err != nil {
return "", fmt.Errorf("parsing database target config: %w", err)
}
err = ValidateArchiveRotation(cfg.Rotation)
if err != nil {
return "", err
}
if cfg.Rotation == "" {
return archiveRotationNone, nil
}
return cfg.Rotation, nil
}
// archivePeriod returns the period, in UTC, that a rotation puts an
// event received at receivedAt in: "2026-10" for monthly,
// "2026-10-01" for daily, "2026-10-01-19" for hourly, and "" for none.
func archivePeriod(rotation string, receivedAt time.Time) string {
switch rotation {
case archiveRotationMonthly:
return receivedAt.UTC().Format(archiveMonthLayout)
case archiveRotationDaily:
return receivedAt.UTC().Format(archiveDayLayout)
case archiveRotationHourly:
return receivedAt.UTC().Format(archiveHourLayout)
default:
return ""
}
}
// archivePeriodPath returns the path of a database target's archive
// file for a period: path, as ArchivePath gives it, with "-" and the
// period put before its ".db". The period "" gives path itself.
func archivePeriodPath(path, period string) string {
if period == "" {
return path
}
return strings.TrimSuffix(path, ".db") + "-" + period + ".db"
}
// ArchivePathAt returns the archive file a database target writes an
// event received at receivedAt to: ArchivePath's file, with the
// period in its name when the target rotates.
func ArchivePathAt(
dbMgr *database.WebhookDBManager,
webhook *database.Webhook,
target *database.Target,
receivedAt time.Time,
) (string, error) {
rotation, err := parseArchiveRotation(target.Config)
if err != nil {
return "", err
}
return archivePeriodPath(
ArchivePath(dbMgr, webhook, target),
archivePeriod(rotation, receivedAt),
), nil
}
// archiveFiles lists the database target's archive files that exist,
// given the path ArchivePath gives it: the file at path, then each
// file archivePeriodPath names for path and a period, oldest period
// first. Which rotation wrote a file does not matter, so the files of
// an earlier rotation setting are listed too.
func archiveFiles(path string) ([]archiveFile, error) {
dir := filepath.Dir(path)
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("listing archive files: %w", err)
}
stem := strings.TrimSuffix(filepath.Base(path), ".db")
var files []archiveFile
for _, entry := range entries {
period, ok := archiveFilePeriod(stem, entry.Name())
if ok {
files = append(files, archiveFile{
path: filepath.Join(dir, entry.Name()),
period: period,
})
}
}
// A month sorts before the days and hours in it.
slices.SortFunc(files, func(a, b archiveFile) int {
return strings.Compare(a.period, b.period)
})
return files, nil
}
// archiveFilePeriod reports whether name is the name of an archive
// file of the target whose file name without a period is stem+".db",
// and the period in it.
func archiveFilePeriod(stem, name string) (string, bool) {
rest, ok := strings.CutPrefix(name, stem)
if !ok {
return "", false
}
rest, ok = strings.CutSuffix(rest, ".db")
if !ok {
return "", false
}
if rest == "" {
return "", true
}
period, ok := strings.CutPrefix(rest, "-")
if !ok {
return "", false
}
for _, layout := range []string{
archiveMonthLayout, archiveDayLayout, archiveHourLayout,
} {
_, err := time.Parse(layout, period)
if err == nil {
return period, true
}
}
return "", false
}
@@ -0,0 +1,389 @@
package delivery_test
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
// The archive rotations, and the configs of a daily target and of one
// whose rotation is not one of the four.
const (
rotationNone = "none"
rotationMonthly = "monthly"
rotationDaily = "daily"
rotationHourly = "hourly"
dailyConfig = `{"rotation":"daily"}`
weeklyConfig = `{"rotation":"weekly"}`
)
// The periods the tests archive into most: two days, and an hour of
// the first.
const (
dayPeriod = "2026-03-04"
nextDayPeriod = "2026-03-05"
hourPeriod = "2026-03-04-05"
)
// periodPath returns the archive file for a period of the target whose
// file without a period is path.
func periodPath(path, period string) string {
return strings.TrimSuffix(path, ".db") + "-" + period + ".db"
}
// deliverReceivedAt delivers to a database target an event whose
// receive time is receivedAt, and returns the event's id. The receive
// time is what decides a rotated archive's file, so setting it is how
// these tests move the clock across a period boundary.
func (env *archiveEnv) deliverReceivedAt(
t *testing.T, tgt *database.Target, receivedAt time.Time,
) string {
t.Helper()
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`)
event.CreatedAt = receivedAt
env.eng.ExportDeliverDatabase(
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
)
return event.ID
}
// TestDeliverDatabase_RotatesAtEachPeriodBoundary delivers, for each
// rotation, an event received in the last second of a period and one
// received in the first second of the next, and checks each lands in
// the file named for its own period, in UTC. Rotation none keeps both
// in the one file.
func TestDeliverDatabase_RotatesAtEachPeriodBoundary(t *testing.T) {
t.Parallel()
berlin := time.FixedZone("CEST", 2*60*60)
cases := []struct {
name string
rotation string
before, after time.Time
// periods are the periods of before and after.
periods [2]string
}{
{
rotationMonthly, rotationMonthly,
time.Date(2026, 1, 31, 23, 59, 59, 0, time.UTC),
time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC),
[2]string{"2026-01", "2026-02"},
},
{
rotationDaily, rotationDaily,
time.Date(2026, 3, 4, 23, 59, 59, 0, time.UTC),
time.Date(2026, 3, 5, 0, 0, 0, 0, time.UTC),
[2]string{dayPeriod, nextDayPeriod},
},
{
// The same instants, received in a zone two hours ahead
// of UTC, where they fall on 5 March: the period is UTC's.
"daily in another zone", rotationDaily,
time.Date(2026, 3, 5, 1, 59, 59, 0, berlin),
time.Date(2026, 3, 5, 2, 0, 0, 0, berlin),
[2]string{dayPeriod, nextDayPeriod},
},
{
rotationHourly, rotationHourly,
time.Date(2026, 3, 4, 5, 59, 59, 0, time.UTC),
time.Date(2026, 3, 4, 6, 0, 0, 0, time.UTC),
[2]string{hourPeriod, "2026-03-04-06"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, `{"rotation":"`+tc.rotation+`"}`)
path := env.archivePath(tgt)
first := env.deliverReceivedAt(t, tgt, tc.before)
second := env.deliverReceivedAt(t, tgt, tc.after)
assert.Equal(t, []string{first},
archivedEventIDs(t, periodPath(path, tc.periods[0])))
assert.Equal(t, []string{second},
archivedEventIDs(t, periodPath(path, tc.periods[1])))
assert.NoFileExists(t, path,
"a rotated target never writes the file without a period")
})
}
t.Run(rotationNone, func(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, `{"rotation":"`+rotationNone+`"}`)
first := env.deliverReceivedAt(t, tgt, cases[0].before)
second := env.deliverReceivedAt(t, tgt, cases[0].after)
assert.ElementsMatch(t, []string{first, second},
archivedEventIDs(t, env.archivePath(tgt)))
})
}
// TestDeliverDatabase_EventWithoutReceiveTime proves an event whose
// receive time is not known goes to the file for the time it is
// archived, rather than to one for the year 1.
func TestDeliverDatabase_EventWithoutReceiveTime(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, `{"rotation":"`+rotationMonthly+`"}`)
path := env.archivePath(tgt)
before := time.Now().UTC().Format("2006-01")
id := env.deliverReceivedAt(t, tgt, time.Time{})
file := periodPath(path, time.Now().UTC().Format("2006-01"))
_, err := os.Stat(file)
if err != nil {
// The month turned during the delivery.
file = periodPath(path, before)
}
assert.Equal(t, []string{id}, archivedEventIDs(t, file))
assert.NoFileExists(t, periodPath(path, "0001-01"))
}
// TestDeliverDatabase_RotationChangeKeepsOldFiles changes a target's
// rotation from none to daily between two events, and checks the
// second goes to the daily file while the first stays where it was.
func TestDeliverDatabase_RotationChangeKeepsOldFiles(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
path := env.archivePath(tgt)
at := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
first := env.deliverReceivedAt(t, tgt, at)
tgt.Config = dailyConfig
second := env.deliverReceivedAt(t, tgt, at)
assert.Equal(t, []string{first}, archivedEventIDs(t, path))
assert.Equal(t, []string{second},
archivedEventIDs(t, periodPath(path, dayPeriod)))
}
// TestArchiveSweep_PrunesEveryFile gives a daily target three files:
// the file without a period, left from before it rotated, and two
// daily files. Each holds a row older than the expiry, and one daily
// file also a newer row. The sweep prunes the old row from every file,
// deletes the daily file it leaves empty, and keeps the file without a
// period although it is empty too.
func TestArchiveSweep_PrunesEveryFile(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(
t, `{"expiry":"1h","rotation":"`+rotationDaily+`"}`,
)
path := env.archivePath(tgt)
emptied := periodPath(path, dayPeriod)
kept := periodPath(path, nextDayPeriod)
now := time.Now()
old := now.Add(-48 * time.Hour)
seedArchiveFile(t, path, tgt.WebhookID, old)
seedArchiveFile(t, emptied, tgt.WebhookID, old)
seedArchiveFile(t, kept, tgt.WebhookID, old, now.Add(-time.Minute))
env.sweeper.ExportSweep(t.Context())
assert.Empty(t, archivedEventIDs(t, path))
assert.Equal(t, []string{sweepRowNew}, archivedEventIDs(t, kept))
for _, suffix := range archiveFileSuffixes() {
assert.NoFileExists(t, emptied+suffix)
}
}
// TestRename_MovesEveryFile renames a daily target that also has a
// file without a period, and checks every file moves to the new name
// with its period, rows and all, and that a later write uses the new
// name.
func TestRename_MovesEveryFile(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
oldPath := env.archivePath(tgt)
day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
unrotated := env.deliverReceivedAt(t, tgt, day)
tgt.Config = dailyConfig
first := env.deliverReceivedAt(t, tgt, day)
second := env.deliverReceivedAt(t, tgt, day.Add(24*time.Hour))
require.NoError(t, env.eng.Rename(tgt.ID, "Orders", "Long Term"))
newPath := filepath.Join(
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
)
for _, old := range []string{
oldPath,
periodPath(oldPath, dayPeriod),
periodPath(oldPath, nextDayPeriod),
} {
assert.NoFileExists(t, old)
}
assert.Equal(t, []string{unrotated}, archivedEventIDs(t, newPath))
assert.Equal(t, []string{first},
archivedEventIDs(t, periodPath(newPath, dayPeriod)))
assert.Equal(t, []string{second},
archivedEventIDs(t, periodPath(newPath, nextDayPeriod)))
third := env.deliverReceivedAt(t, tgt, day.Add(48*time.Hour))
assert.Equal(t, []string{third},
archivedEventIDs(t, periodPath(newPath, "2026-03-06")))
}
// TestRename_NeverReplacesARotatedFile plants a file at the new name
// of a target's daily file, and proves the rename is refused and moves
// none of the target's files.
func TestRename_NeverReplacesARotatedFile(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, dailyConfig)
oldPath := env.archivePath(tgt)
day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
env.deliverReceivedAt(t, tgt, day)
env.deliverReceivedAt(t, tgt, day.Add(24*time.Hour))
newPath := filepath.Join(
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
)
planted := periodPath(newPath, nextDayPeriod)
require.NoError(t, os.WriteFile(planted, []byte("planted"), 0o600))
require.ErrorIs(
t, env.eng.Rename(tgt.ID, "Orders", "Long Term"),
delivery.ErrArchiveNameTaken,
)
assert.FileExists(t, periodPath(oldPath, dayPeriod))
assert.FileExists(t, periodPath(oldPath, nextDayPeriod))
assert.NoFileExists(t, periodPath(newPath, dayPeriod))
}
// TestStatArchive_EveryFile proves StatArchive counts and adds up every
// one of a target's files, takes the latest write of any of them, and
// leaves out files whose names only look like the target's.
func TestStatArchive_EveryFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "archive-wh.db")
files := []string{
path, periodPath(path, "2026-03"), periodPath(path, hourPeriod),
}
written := time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC)
var size int64
for i, file := range files {
require.NoError(t, os.WriteFile(file, make([]byte, 100*(i+1)), 0o600))
size += int64(100 * (i + 1))
at := written.Add(-time.Duration(i) * time.Hour)
require.NoError(t, os.Chtimes(file, at, at))
}
for _, other := range []string{
"archive-wh-2026-13.db", "archive-wh-2026-3.db",
"archive-wh-other.db", "archive-wh-2026-03.json",
"archive-whx.db",
} {
require.NoError(t,
os.WriteFile(filepath.Join(dir, other), []byte("x"), 0o600))
}
got, err := delivery.StatArchive(path)
require.NoError(t, err)
assert.Equal(t, len(files), got.Files)
assert.Equal(t, size, got.Size)
assert.True(t, written.Equal(got.Written), got.Written)
}
// TestArchivePathAt names the file each rotation writes an event to.
func TestArchivePathAt(t *testing.T) {
t.Parallel()
dataDir := t.TempDir()
dbMgr := database.NewTestWebhookDBManager(dataDir)
webhook := &database.Webhook{
BaseModel: database.BaseModel{ID: "wh-id"}, Name: "Orders",
}
at := time.Date(2026, 10, 1, 19, 30, 0, 0, time.UTC)
cases := map[string]string{
"": "",
`{"rotation":"` + rotationNone + `"}`: "",
`{"rotation":"` + rotationMonthly + `"}`: "-2026-10",
dailyConfig: "-2026-10-01",
`{"rotation":"` + rotationHourly + `"}`: "-2026-10-01-19",
}
for config, period := range cases {
target := &database.Target{
BaseModel: database.BaseModel{ID: "tgt-id"},
Name: "Archive",
Config: config,
}
got, err := delivery.ArchivePathAt(dbMgr, webhook, target, at)
require.NoError(t, err, config)
assert.Equal(t,
filepath.Join(
dataDir, "archive-orders-archive-tgt-id"+period+".db",
),
got, config,
)
}
_, err := delivery.ArchivePathAt(dbMgr, webhook, &database.Target{
Config: weeklyConfig,
}, at)
require.Error(t, err)
}
// TestValidateArchiveRotation accepts the four rotations, and empty,
// and refuses anything else.
func TestValidateArchiveRotation(t *testing.T) {
t.Parallel()
for _, ok := range []string{
"", rotationNone, rotationMonthly, rotationDaily, rotationHourly,
} {
require.NoError(t, delivery.ValidateArchiveRotation(ok), ok)
}
for _, bad := range []string{"weekly", "Daily", "hourly "} {
require.Error(t, delivery.ValidateArchiveRotation(bad), bad)
}
}
+39
View File
@@ -218,6 +218,7 @@ func TestStatArchive(t *testing.T) {
got, err := delivery.StatArchive(path) got, err := delivery.StatArchive(path)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, 1, got.Files)
assert.Equal(t, file.Size()+wal.Size(), got.Size) assert.Equal(t, file.Size()+wal.Size(), got.Size)
assert.True(t, written.Equal(got.Written), got.Written) assert.True(t, written.Equal(got.Written), got.Written)
@@ -720,3 +721,41 @@ func TestArchiveWriter_RenameMovesBackOnFailure(t *testing.T) {
assert.NoFileExists(t, filepath.Join(dir, newName)) assert.NoFileExists(t, filepath.Join(dir, newName))
assert.Equal(t, oldPath, w.Path()) assert.Equal(t, oldPath, w.Path())
} }
// TestArchiveWriter_RenameMovesBackEveryFile renames a target with a
// file without a period and a file for a month, and proves that when
// the month's file fails to move, the file already moved is moved back:
// both files are under the old name with their rows, and nothing is
// under the new name. The new name is 251 bytes, so the file without a
// period, with its -wal and -shm, can take it, but the month's file,
// eight bytes longer, cannot.
func TestArchiveWriter_RenameMovesBackEveryFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
oldPath := filepath.Join(dir, "archive-old.db")
monthPath := filepath.Join(dir, "archive-old-2026-03.db")
w := delivery.NewExportArchiveWriter(oldPath, archiveTestLogger(), 0)
require.NoError(t, w.WritePeriod(
delivery.ExportArchivedEvent{EventID: "in-none"}, 0, "",
))
require.NoError(t, w.WritePeriod(
delivery.ExportArchivedEvent{EventID: "in-month"}, 0, "2026-03",
))
require.Error(t, w.Rename(strings.Repeat("a", 248)+".db"))
assert.Equal(t, []string{"in-none"}, archivedEventIDs(t, oldPath))
assert.Equal(t, []string{"in-month"}, archivedEventIDs(t, monthPath))
entries, err := os.ReadDir(dir)
require.NoError(t, err)
for _, entry := range entries {
assert.True(t, strings.HasPrefix(entry.Name(), "archive-old"),
"%s is not under the old name", entry.Name())
}
assert.Equal(t, oldPath, w.Path())
}
+9 -3
View File
@@ -204,10 +204,11 @@ func TestNewTargetConfigForm(t *testing.T) {
form, err = delivery.NewTargetConfigForm(&database.Target{ form, err = delivery.NewTargetConfigForm(&database.Target{
Type: database.TargetTypeDatabase, Type: database.TargetTypeDatabase,
Config: `{"expiry":"720h"}`, Config: `{"expiry":"720h","rotation":"daily"}`,
}) })
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "720h", form.Expiry) assert.Equal(t, "720h", form.Expiry)
assert.Equal(t, rotationDaily, form.Rotation)
form, err = delivery.NewTargetConfigForm(&database.Target{ form, err = delivery.NewTargetConfigForm(&database.Target{
Type: database.TargetTypeLog, Type: database.TargetTypeLog,
@@ -216,8 +217,9 @@ func TestNewTargetConfigForm(t *testing.T) {
assert.Empty(t, form.URL) assert.Empty(t, form.URL)
} }
// A keep-forever archive target must pre-fill as an empty field, so // A keep-forever archive target yields an empty expiry, so the edit
// saving the form back unchanged stores the same empty config. // form starts on never; saving it unchanged stores never, which means
// the same as an empty expiry.
func TestNewTargetConfigForm_DatabaseNeverIsBlank(t *testing.T) { func TestNewTargetConfigForm_DatabaseNeverIsBlank(t *testing.T) {
t.Parallel() t.Parallel()
@@ -247,6 +249,10 @@ func TestNewTargetConfigForm_UnreadableConfigErrors(t *testing.T) {
Type: database.TargetTypeDatabase, Type: database.TargetTypeDatabase,
Config: `{"expiry":"soon"}`, Config: `{"expiry":"soon"}`,
}, },
{
Type: database.TargetTypeDatabase,
Config: weeklyConfig,
},
{Type: database.TargetType("nope")}, {Type: database.TargetType("nope")},
} }
+6 -4
View File
@@ -234,7 +234,7 @@ func (c *httpCore) handleRetry(
database.DeliveryStatusRetrying, database.DeliveryStatusRetrying,
) )
backoff := calcBackoff(attemptNum) backoff := Backoff(attemptNum)
retryTask := *task retryTask := *task
retryTask.AttemptNum = attemptNum + 1 retryTask.AttemptNum = attemptNum + 1
@@ -301,7 +301,7 @@ func (c *httpCore) remainingBackoff(
return 0 return 0
} }
backoff := calcBackoff(attemptNum) backoff := Backoff(attemptNum)
elapsed := time.Since(lastResult.CreatedAt) elapsed := time.Since(lastResult.CreatedAt)
remaining := backoff - elapsed remaining := backoff - elapsed
@@ -326,12 +326,14 @@ func (c *httpCore) backoffElapsed(
return true return true
} }
backoff := calcBackoff(attemptNum) backoff := Backoff(attemptNum)
return time.Since(lastResult.CreatedAt) >= backoff return time.Since(lastResult.CreatedAt) >= backoff
} }
func calcBackoff(attemptNum int) time.Duration { // Backoff is how long an http or slack target with retries waits after
// a delivery's failed attempt attemptNum before trying it again.
func Backoff(attemptNum int) time.Duration {
shift := max(attemptNum-1, 0) shift := max(attemptNum-1, 0)
shift = min(shift, maxBackoffShift) shift = min(shift, maxBackoffShift)
+58
View File
@@ -0,0 +1,58 @@
package handlers
const (
// archiveExpiryNever is the archive expiry that keeps archived
// events forever. A stored empty expiry means the same.
archiveExpiryNever = "never"
// tmplKeyArchiveExpiryChoices is the template data key for the
// entries of a page's archive expiry select.
tmplKeyArchiveExpiryChoices = "ArchiveExpiryChoices"
)
// archiveChoice is one entry of a database target's archive expiry
// or archive rotation select: the value stored, the label shown, and
// whether the select starts on it.
type archiveChoice struct {
Value string
Label string
Selected bool
}
// archiveExpiryChoices lists the archive expiries offered by the new
// webhook page, the add target form and the target edit form.
func archiveExpiryChoices() []archiveChoice {
return []archiveChoice{
{Value: archiveExpiryNever, Label: archiveExpiryNever},
{Value: "1h", Label: "1h"},
{Value: "12h", Label: "12h"},
{Value: "24h", Label: "24h"},
{Value: "720h", Label: "30d"},
{Value: "2160h", Label: "90d"},
{Value: "8760h", Label: "365d"},
}
}
// archiveExpiryOptions returns the choices with expiry selected; an
// empty expiry selects never. An expiry that is not one of the
// choices comes first as its own selected entry, so saving the form
// unchanged keeps it.
func archiveExpiryOptions(expiry string) []archiveChoice {
if expiry == "" {
expiry = archiveExpiryNever
}
options := archiveExpiryChoices()
for i := range options {
if options[i].Value == expiry {
options[i].Selected = true
return options
}
}
own := archiveChoice{Value: expiry, Label: expiry, Selected: true}
return append([]archiveChoice{own}, options...)
}
+185
View File
@@ -0,0 +1,185 @@
package handlers_test
import (
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
)
// expiryNever is the archive expiry that keeps archived events
// forever.
const expiryNever = "never"
// matched returns what the one group of pattern matched in page, at
// each match.
func matched(pattern, page string) []string {
matches := regexp.MustCompile(pattern).FindAllStringSubmatch(page, -1)
groups := make([]string, 0, len(matches))
for _, m := range matches {
groups = append(groups, m[1])
}
return groups
}
// expiryShown returns the archive expiries the webhook page's target
// list shows.
func expiryShown(
t *testing.T, env *sourceTestEnv, webhookID string,
) []string {
t.Helper()
w := httptest.NewRecorder()
env.handlers.HandleSourceDetail().ServeHTTP(w, getRequest(
t, "/hook/"+webhookID, env.cookies,
map[string]string{sourceIDParam: webhookID},
))
require.Equal(t, http.StatusOK, w.Code)
return matched(
`Archive expiry:</span>\s*<span>([^<]*)</span>`, w.Body.String(),
)
}
// expirySelected returns the target edit page and the expiries its
// expiry select starts on.
func expirySelected(
t *testing.T, env *sourceTestEnv, webhookID, targetID string,
) (string, []string) {
t.Helper()
page := targetEditPage(t, env, webhookID, targetID)
return page, selectedIn(page, "expiry")
}
// targetEditPage returns a target's edit page.
func targetEditPage(
t *testing.T, env *sourceTestEnv, webhookID, targetID string,
) string {
t.Helper()
w := serveTarget(
env, http.MethodGet,
"/hook/"+webhookID+"/targets/"+targetID+"/edit", nil,
)
require.Equal(t, http.StatusOK, w.Code)
return w.Body.String()
}
// selectedIn returns the values the select named name on page starts
// on.
func selectedIn(page, name string) []string {
_, rest, _ := strings.Cut(page, `<select id="`+name+`" name="`+name+`"`)
options, _, _ := strings.Cut(rest, "</select>")
return matched(`<option value="([^"]*)" selected>`, options)
}
// TestArchiveExpiryChoices adds a database target with each archive
// expiry the forms offer, and checks that it is stored as chosen,
// shown in plain units in the target list, and that the target edit
// form starts on it.
func TestArchiveExpiryChoices(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
choices := []struct{ value, shown string }{
{expiryNever, expiryNever},
{"1h", "1 hour"},
{"12h", "12 hours"},
{"24h", "1 day"},
{"720h", "30 days"},
{"2160h", "90 days"},
{"8760h", "365 days"},
}
for _, choice := range choices {
t.Run(choice.value, func(t *testing.T) {
t.Parallel()
webhook := seedWebhookWithRetention(t, env.db, 30)
form := url.Values{}
form.Set("name", "archive")
form.Set("type", string(database.TargetTypeDatabase))
form.Set("expiry", choice.value)
w := serveTarget(
env, http.MethodPost, "/hook/"+webhook.ID+"/targets", form,
)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.JSONEq(
t, `{"expiry":"`+choice.value+`"}`, targets[0].Config,
)
assert.Equal(
t, []string{choice.shown},
expiryShown(t, env, webhook.ID),
)
_, selected := expirySelected(t, env, webhook.ID, targets[0].ID)
assert.Equal(t, []string{choice.value}, selected)
})
}
}
// TestArchiveExpiryEditStartsOnStoredValue checks the edit form of a
// database target whose stored expiry is empty, which selects never,
// and of one whose expiry is not one of the choices, which is listed
// first as its own selected entry and saved unchanged.
func TestArchiveExpiryEditStartsOnStoredValue(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30)
empty := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase, "",
)
_, selected := expirySelected(t, env, webhook.ID, empty.ID)
assert.Equal(t, []string{expiryNever}, selected)
webhook = seedWebhookWithRetention(t, env.db, 30)
unlisted := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase,
`{"expiry":"36h"}`,
)
assert.Equal(t, []string{"36 hours"}, expiryShown(t, env, webhook.ID))
page, selected := expirySelected(t, env, webhook.ID, unlisted.ID)
assert.Equal(t, []string{"36h"}, selected)
assert.Regexp(
t,
`<select id="expiry" name="expiry" class="input">\s*`+
`<option value="36h" selected>36h</option>\s*`+
`<option value="never">never</option>`,
page,
)
assert.Contains(t, page, `<option value="8760h">365d</option>`)
form := url.Values{}
form.Set("name", unlisted.Name)
form.Set("expiry", "36h")
w := submitTargetEdit(env, webhook.ID, unlisted.ID, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.JSONEq(
t, `{"expiry":"36h"}`, storedTarget(t, env, unlisted.ID).Config,
)
}
+42
View File
@@ -0,0 +1,42 @@
package handlers
const (
// archiveRotationNone is the archive rotation that keeps a
// database target's archive in one file. A stored empty rotation
// means the same.
archiveRotationNone = "none"
// tmplKeyArchiveRotationChoices is the template data key for the
// entries of a page's archive rotation select.
tmplKeyArchiveRotationChoices = "ArchiveRotationChoices"
)
// archiveRotationChoices lists the archive rotations offered by the
// new webhook page, the add target form and the target edit form.
func archiveRotationChoices() []archiveChoice {
return []archiveChoice{
{Value: archiveRotationNone, Label: archiveRotationNone},
{Value: "monthly", Label: "monthly"},
{Value: "daily", Label: "daily"},
{Value: "hourly", Label: "hourly"},
}
}
// archiveRotationOptions returns the choices with rotation selected;
// an empty rotation, or one that is not a choice, selects none. A
// stored rotation is always a choice: the forms refuse any other.
func archiveRotationOptions(rotation string) []archiveChoice {
options := archiveRotationChoices()
for i := range options {
if options[i].Value == rotation {
options[i].Selected = true
return options
}
}
options[0].Selected = true
return options
}
+229
View File
@@ -0,0 +1,229 @@
package handlers_test
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
// rotationNone is the archive rotation that keeps one file.
const rotationNone = "none"
// rotationShown returns the archive rotations the webhook page's
// target list shows.
func rotationShown(
t *testing.T, env *sourceTestEnv, webhookID string,
) []string {
t.Helper()
return matched(
`Archive rotation:</span>\s*<span>([^<]*)</span>`,
renderedPage(t, env, webhookID),
)
}
// TestArchiveRotationChoices adds a database target with each archive
// rotation the forms offer, and checks that it is stored as chosen,
// shown in the target list, and that the target edit form starts on
// it. It then edits the target to hourly, keeping its expiry.
func TestArchiveRotationChoices(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
for _, rotation := range []string{rotationNone, "monthly", "daily", "hourly"} {
t.Run(rotation, func(t *testing.T) {
t.Parallel()
webhook := seedWebhookWithRetention(t, env.db, 30)
form := url.Values{}
form.Set("name", "archive")
form.Set("type", string(database.TargetTypeDatabase))
form.Set("expiry", "720h")
form.Set("rotation", rotation)
w := serveTarget(
env, http.MethodPost, "/hook/"+webhook.ID+"/targets", form,
)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.JSONEq(t,
`{"expiry":"720h","rotation":"`+rotation+`"}`,
targets[0].Config,
)
assert.Equal(t,
[]string{rotation}, rotationShown(t, env, webhook.ID))
page := targetEditPage(t, env, webhook.ID, targets[0].ID)
assert.Equal(t, []string{rotation}, selectedIn(page, "rotation"))
form = url.Values{}
form.Set("name", "archive")
form.Set("expiry", "720h")
form.Set("rotation", "hourly")
w = submitTargetEdit(env, webhook.ID, targets[0].ID, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.JSONEq(t,
`{"expiry":"720h","rotation":"hourly"}`,
storedTarget(t, env, targets[0].ID).Config,
)
})
}
}
// TestArchiveRotationEditStartsOnNone checks the edit form of a
// database target with no rotation stored starts on none.
func TestArchiveRotationEditStartsOnNone(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30)
target := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase, "",
)
page := targetEditPage(t, env, webhook.ID, target.ID)
assert.Equal(t, []string{rotationNone}, selectedIn(page, "rotation"))
assert.Equal(t, []string{rotationNone}, rotationShown(t, env, webhook.ID))
}
// TestArchiveRotationRefused proves a rotation that is not one of the
// four is refused on the add target form and the target edit form, and
// that nothing is stored.
func TestArchiveRotationRefused(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30)
form := url.Values{}
form.Set("name", "archive")
form.Set("type", string(database.TargetTypeDatabase))
form.Set("rotation", "weekly")
w := serveTarget(
env, http.MethodPost, "/hook/"+webhook.ID+"/targets", form,
)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "Invalid archive rotation")
assert.Empty(t, targetsForWebhook(t, env.db, webhook.ID))
target := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase,
`{"rotation":"daily"}`,
)
form.Del("type")
w = submitTargetEdit(env, webhook.ID, target.ID, form)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "Invalid archive rotation")
assert.JSONEq(t,
`{"rotation":"daily"}`, storedTarget(t, env, target.ID).Config,
)
}
// TestHandleSourceCreateSubmit_ArchiveRotation proves the new webhook
// page's archive rotation is stored on the archive target it creates.
func TestHandleSourceCreateSubmit_ArchiveRotation(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
form := url.Values{}
form.Set("name", "rotated")
form.Set("archive", "on")
form.Set("archive_expiry", "720h")
form.Set("archive_rotation", "daily")
w := submitCreateForm(env, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
var webhook database.Webhook
require.NoError(t, env.db.DB().
Where("name = ?", "rotated").First(&webhook).Error)
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.JSONEq(t,
`{"expiry":"720h","rotation":"daily"}`, targets[0].Config,
)
}
// TestArchiveFileView_Rotated describes a daily target's archive files
// at two times. On a day that has a file, the view names that file;
// on the next, before any event, it names the file the next event
// will go to, not created yet. Both times the size is of every file
// together and the last write the latest of them.
func TestArchiveFileView_Rotated(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30)
target := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase,
`{"rotation":"daily"}`,
)
path := delivery.ArchivePath(env.dbMgr, &webhook, target)
stem := strings.TrimSuffix(path, ".db")
written := time.Date(2026, 10, 2, 9, 0, 0, 0, time.UTC)
for i, day := range []string{"2026-10-01", "2026-10-02"} {
file := stem + "-" + day + ".db"
require.NoError(t, os.WriteFile(file, make([]byte, 1000), 0o600))
at := written.Add(time.Duration(i-1) * 24 * time.Hour)
require.NoError(t, os.Chtimes(file, at, at))
}
view := env.handlers.ArchiveFileViewForTest(
&webhook, target, time.Date(2026, 10, 2, 23, 0, 0, 0, time.UTC),
)
assert.Equal(t, filepath.Base(stem)+"-2026-10-02.db", view.Name)
assert.Empty(t, view.Note)
assert.Equal(t, 2, view.Files)
assert.Equal(t, "2.0 kB", view.Size)
assert.Equal(t, "2026-10-02 09:00:00 UTC", view.WrittenUTC)
view = env.handlers.ArchiveFileViewForTest(
&webhook, target, time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC),
)
assert.Equal(t, filepath.Base(stem)+"-2026-10-03.db", view.Name)
assert.Equal(t, "not created yet", view.Note)
assert.Equal(t, 2, view.Files)
assert.Equal(t, "2.0 kB", view.Size)
page := targetList(t, renderedPage(t, env, webhook.ID))
assert.Contains(t, page, "Archive size: 2.0 kB in 2 files")
}
// renderedPage returns the webhook page.
func renderedPage(t *testing.T, env *sourceTestEnv, webhookID string) string {
t.Helper()
w := httptest.NewRecorder()
env.handlers.HandleSourceDetail().ServeHTTP(w, getRequest(
t, "/hook/"+webhookID, env.cookies,
map[string]string{sourceIDParam: webhookID},
))
require.Equal(t, http.StatusOK, w.Code)
return w.Body.String()
}
+1 -1
View File
@@ -268,7 +268,7 @@ func (h *Handlers) rejectLogin(
))) )))
h.renderLoginError( h.renderLoginError(
w, r, w, r,
"Too many failed login attempts. Please try again later.", "Too many failed sign-in attempts. Please try again later.",
http.StatusTooManyRequests, http.StatusTooManyRequests,
) )
} }
@@ -0,0 +1,89 @@
package handlers_test
import (
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
)
// TestDeliveryAttempts_ReadInTheTargetTypesOwnTerms proves, on the
// event's page and in the event log, that an http or slack attempt
// shows its status as before, while a database or log attempt, which
// sends no HTTP request, says what it did and shows no status.
func TestDeliveryAttempts_ReadInTheTargetTypesOwnTerms(t *testing.T) {
t.Parallel()
cases := []struct {
targetType database.TargetType
success bool
statusCode int
errText string
outcome string
status string // "" when the attempt must show no status
}{
{
database.TargetTypeHTTP, false, 0, "",
"failure", "Status: &mdash; (no response)",
},
{
database.TargetTypeSlack, true, http.StatusOK, "",
"success", "Status: 200",
},
{database.TargetTypeDatabase, true, 0, "", "archived", ""},
{
database.TargetTypeDatabase, false, 0,
"opening archive database: disk full", "failure", "",
},
{database.TargetTypeLog, true, 0, "", "written to the log", ""},
}
for _, tc := range cases {
t.Run(string(tc.targetType), func(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
target := seedTarget(t, f.db, f.webhook.ID, tc.targetType)
event := f.event(t, contentTypeJSON, "{}", time.Now())
dlv := f.delivery(
t, event, target.ID, database.DeliveryStatusDelivered,
)
require.NoError(t, f.webhookDB.Omit(clause.Associations).Create(
&database.DeliveryResult{
DeliveryID: dlv.ID,
AttemptNum: 1,
Success: tc.success,
StatusCode: tc.statusCode,
Error: tc.errText,
},
).Error)
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
require.Equal(t, http.StatusOK, w.Code)
pages := []string{
w.Body.String(),
renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID),
}
for _, page := range pages {
assert.Contains(t, page, ">"+tc.outcome+"</span>")
if tc.errText != "" {
assert.Contains(t, page, "Error: "+tc.errText)
}
if tc.status == "" {
assert.NotContains(t, page, "Status:")
} else {
assert.Contains(t, page, tc.status)
}
}
})
}
}
+16 -13
View File
@@ -2,7 +2,6 @@ package handlers
import ( import (
"net/http" "net/http"
"strconv"
"github.com/go-chi/chi" "github.com/go-chi/chi"
"gorm.io/gorm" "gorm.io/gorm"
@@ -21,7 +20,9 @@ const (
// replayTargetDeleted reports a target that once existed and has // replayTargetDeleted reports a target that once existed and has
// since been deleted. Deletes are soft and deliveries carry no // since been deleted. Deletes are soft and deliveries carry no
// foreign key to the target row, so the history survives its // foreign key to the target row, so the history survives its
// target and this is the ordinary case for an old event. // target and this is the ordinary case for an old event. The
// event log shows no Replay button for such a delivery, so only
// a page loaded before the delete reaches this.
replayTargetDeleted noticeCode = "replay-target-deleted" replayTargetDeleted noticeCode = "replay-target-deleted"
// replayTargetMissing reports a target id that names no row at // replayTargetMissing reports a target id that names no row at
@@ -281,6 +282,7 @@ func createReplayDelivery(
EventID: event.ID, EventID: event.ID,
TargetID: target.ID, TargetID: target.ID,
Status: database.DeliveryStatusPending, Status: database.DeliveryStatusPending,
Replay: true,
} }
err := webhookDB.Transaction(func(tx *gorm.DB) error { err := webhookDB.Transaction(func(tx *gorm.DB) error {
@@ -327,24 +329,25 @@ func replayBody(body string) *string {
} }
// redirectToEventLog redirects a replay or resubmit back to the event // redirectToEventLog redirects a replay or resubmit back to the event
// log it was triggered from, carrying the outcome as its notice and // log it was triggered from, carrying the outcome as its notice. A
// the page number the form submitted. // Replay form carries the list it was pressed in as show, so a replay
// returns to the Failed or Pending list; a Resubmit form carries none,
// so a resubmit returns to the full log, where its new event is the
// newest.
func redirectToEventLog( func redirectToEventLog(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
webhook database.Webhook, webhook database.Webhook,
code noticeCode, code noticeCode,
) { ) {
dest := withNotice("/hook/"+webhook.ID+"/events", code) location := withNotice("/hook/"+webhook.ID+"/events", code)
// The page is read from the form rather than the query string: show := r.PostFormValue(showParam)
// this is a POST, and its query string is what logs and Referer if eventLogStatuses(show) != nil {
// headers record. location += "&" + showParam + "=" + show
if page := pageOrFirst(
r.PostFormValue("page"),
); page > 1 {
dest += "&page=" + strconv.Itoa(page)
} }
http.Redirect(w, r, dest, http.StatusSeeOther) http.Redirect( //nolint:gosec // show is checked by eventLogStatuses
w, r, location, http.StatusSeeOther,
)
} }
+122 -1
View File
@@ -1,8 +1,10 @@
package handlers_test package handlers_test
import ( import (
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -470,6 +472,66 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
) )
} }
// TestHandleDeliveryReplay_ReturnsToTheListItWasPressedIn proves a
// Replay pressed in the Failed list carries that list in its form and
// returns to it, and that a show value the event log does not know
// returns to the full log.
func TestHandleDeliveryReplay_ReturnsToTheListItWasPressedIn(
t *testing.T,
) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &dbMgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP,
`{"url":"`+replayTargetURL+`"}`,
)
_, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
assert.Contains(t, renderSourceLogsPageWithQuery(
t, h, sess, wh.ID, "?show=failed",
), `name="show" value="failed"`)
// The second replay is refused, as the first is still queued.
for _, tc := range []struct{ show, location string }{
{"failed", "/hook/" + wh.ID +
"/events?notice=replay-queued&show=failed"},
{"made-up", "/hook/" + wh.ID + "/events?notice=replay-in-flight"},
} {
req := postRequest(
"/hook/"+wh.ID+"/deliveries/"+original.ID+"/replay",
authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
),
map[string]string{
paramSourceID: wh.ID,
paramDeliveryID: original.ID,
},
)
req.Body = io.NopCloser(strings.NewReader("show=" + tc.show))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.HandleDeliveryReplay().ServeHTTP(w, req)
require.Equal(t, http.StatusSeeOther, w.Code, tc.show)
assert.Equal(t, tc.location, w.Header().Get("Location"), tc.show)
}
}
// TestHandleSourceLogs_RendersReplayControlAndBanner proves the action // TestHandleSourceLogs_RendersReplayControlAndBanner proves the action
// reaches the page it belongs on: a finished delivery renders a POST // reaches the page it belongs on: a finished delivery renders a POST
// form carrying a CSRF token, and the outcome code a refusal redirects // form carrying a CSRF token, and the outcome code a refusal redirects
@@ -513,7 +575,11 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
) )
assert.Contains(t, refused, "alert-error") assert.Contains(t, refused, "alert-error")
assert.Contains(t, refused, "has been deleted") assert.Contains(
t, refused,
"has been deleted. Use Resubmit to send the event "+
"to the webhook",
)
// An outcome code nobody issued renders no banner at all. // An outcome code nobody issued renders no banner at all.
unknown := renderSourceLogsPageWithQuery( unknown := renderSourceLogsPageWithQuery(
@@ -524,3 +590,58 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
assert.NotContains(t, unknown, "alert-success") assert.NotContains(t, unknown, "alert-success")
assert.NotContains(t, unknown, "made-up") assert.NotContains(t, unknown, "made-up")
} }
// TestHandleDeliveryReplay_LabelsTheReplay proves a delivery created
// by Replay is labelled as a replay in the event's summary line in the
// event log, and in the list of the event's deliveries there and on
// the event's page, while the delivery it repeats is not.
func TestHandleDeliveryReplay_LabelsTheReplay(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &dbMgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP,
`{"url":"`+replayTargetURL+`"}`,
)
event, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
w := postReplay(t, h, sess, wh.ID, original.ID)
require.Equal(t, http.StatusSeeOther, w.Code)
eventLog := renderSourceLogsPage(t, h, sess, wh.ID)
assert.Contains(t, eventLog, tgt.Name+": failed")
assert.Contains(t, eventLog, tgt.Name+" (replay): pending")
w = serveEventPage(t, h, sess, wh.ID, event.ID)
require.Equal(t, http.StatusOK, w.Code)
// In each delivery list a row names the target, then the label if
// it is a replay, then its status: the replay is still pending, the
// original failed.
replayRow := tgt.Name + `</span> ` +
`<span class="text-xs text-gray-500">replay</span> ` +
`<span class="text-xs text-gray-400">pending</span>`
originalRow := tgt.Name + `</span> ` +
`<span class="text-xs text-red-600">failed</span>`
for _, page := range []string{eventLog, w.Body.String()} {
page = strings.Join(strings.Fields(page), " ")
assert.Contains(t, page, replayRow)
assert.Contains(t, page, originalRow)
}
}
+13 -2
View File
@@ -1,6 +1,9 @@
package handlers package handlers
import ( import (
"time"
"github.com/dustin/go-humanize"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
) )
@@ -24,8 +27,8 @@ const maxRenderedResponseBytes = 4096
// bytes rather than characters, and they make SQLite do the // bytes rather than characters, and they make SQLite do the
// cut, so an oversized stored response never becomes a Go // cut, so an oversized stored response never becomes a Go
// string at all. // string at all.
const deliveryResultColumns = "delivery_id, attempt_num, success, " + const deliveryResultColumns = "delivery_id, attempt_num, created_at, " +
"status_code, error, duration, " + "success, status_code, error, duration, " +
"substr(cast(response_body as blob), 1, ?) AS response_body, " + "substr(cast(response_body as blob), 1, ?) AS response_body, " +
"length(cast(response_body as blob)) AS response_bytes" "length(cast(response_body as blob)) AS response_bytes"
@@ -45,6 +48,11 @@ type DeliveryResultView struct {
AttemptNum int AttemptNum int
Success bool Success bool
// Ran is how long ago the attempt was recorded, and RanUTC the
// full timestamp the page shows on hover.
Ran string
RanUTC string
// StatusCode is 0 when the attempt never got a response, // StatusCode is 0 when the attempt never got a response,
// which is why the page asks HasStatusCode rather than // which is why the page asks HasStatusCode rather than
// printing the number. // printing the number.
@@ -100,6 +108,7 @@ func (v DeliveryResultView) HasStatusCode() bool {
type deliveryResultRow struct { type deliveryResultRow struct {
DeliveryID string DeliveryID string
AttemptNum int AttemptNum int
CreatedAt time.Time
Success bool Success bool
StatusCode int StatusCode int
Error string Error string
@@ -157,6 +166,8 @@ func (r *deliveryResultRow) view(
return DeliveryResultView{ return DeliveryResultView{
AttemptNum: r.AttemptNum, AttemptNum: r.AttemptNum,
Success: r.Success, Success: r.Success,
Ran: humanize.Time(r.CreatedAt),
RanUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
StatusCode: r.StatusCode, StatusCode: r.StatusCode,
Error: redactor.Redact(r.Error), Error: redactor.Redact(r.Error),
DurationMS: r.Duration, DurationMS: r.Duration,
@@ -435,9 +435,7 @@ func TestHandleSourceLogs_BoundsRenderedAttempts(t *testing.T) {
}).Error) }).Error)
} }
views := h.LoadEventLogViewsForTest( views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh)
httptest.NewRecorder(), *wh, 1,
)
require.Len(t, views, 1) require.Len(t, views, 1)
require.Len(t, views[0].Deliveries, 1) require.Len(t, views[0].Deliveries, 1)
@@ -489,9 +487,7 @@ func TestHandleSourceLogs_BoundsOversizeResponse(t *testing.T) {
stored := strings.Repeat("A", responseCap*4) + tail stored := strings.Repeat("A", responseCap*4) + tail
seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, stored) seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, stored)
views := h.LoadEventLogViewsForTest( views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh)
httptest.NewRecorder(), *wh, 1,
)
require.Len(t, views, 1) require.Len(t, views, 1)
require.Len(t, views[0].Deliveries, 1) require.Len(t, views[0].Deliveries, 1)
require.Len(t, views[0].Deliveries[0].Results, 1) require.Len(t, views[0].Deliveries[0].Results, 1)
+169
View File
@@ -0,0 +1,169 @@
package handlers
import (
"net/http"
"github.com/go-chi/chi"
"github.com/google/uuid"
"sneak.berlin/go/webhooker/internal/database"
)
// HandleEntrypointCreate handles adding a new entrypoint.
func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
sourceID := chi.URLParam(r, "sourceID")
var webhook database.Webhook
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
return
}
// The body size cap is enforced by the MaxBodySize
// middleware, which runs before CSRF parses the form.
err = r.ParseForm()
if err != nil {
h.renderError(w, r, http.StatusBadRequest)
return
}
description := r.PostFormValue("description")
entrypoint := &database.Entrypoint{
WebhookID: webhook.ID,
Path: uuid.New().String(),
Description: description,
Active: true,
}
err = h.db.DB().Create(entrypoint).Error
if err != nil {
h.serverError(w, r, "failed to create entrypoint", err)
return
}
http.Redirect(
w, r, withNotice("/hook/"+webhook.ID, entrypointAdded),
http.StatusSeeOther,
)
}
}
// HandleEntrypointEdit handles changing an entrypoint's description.
// It writes only the description column, so the entrypoint keeps its
// URL, and an activate or deactivate saved since the page was shown
// is not undone.
func (h *Handlers) HandleEntrypointEdit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
sourceID := chi.URLParam(r, "sourceID")
entrypointID := chi.URLParam(r, "entrypointID")
var webhook database.Webhook
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
return
}
// The body size cap is enforced by the MaxBodySize
// middleware, which runs before CSRF parses the form.
err = r.ParseForm()
if err != nil {
h.renderError(w, r, http.StatusBadRequest)
return
}
result := h.db.DB().Model(&database.Entrypoint{}).Where(
"id = ? AND webhook_id = ?", entrypointID, webhook.ID,
).Update("description", r.PostFormValue("description"))
if result.Error != nil {
h.serverError(
w, r, "failed to edit entrypoint", result.Error,
)
return
}
// The id came from the URL and may name another webhook's
// entrypoint, which this webhook does not have.
if result.RowsAffected == 0 {
h.renderError(w, r, http.StatusNotFound)
return
}
http.Redirect(
w, r, withNotice("/hook/"+webhook.ID, entrypointSaved),
http.StatusSeeOther,
)
}
}
// HandleEntrypointDelete handles deleting an entrypoint.
func (h *Handlers) HandleEntrypointDelete() http.HandlerFunc {
return h.deleteChildResource(
"entrypointID", &database.Entrypoint{},
"failed to delete entrypoint",
nil,
entrypointDeleted,
)
}
// HandleEntrypointToggle handles toggling an entrypoint's
// active state.
func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
return h.toggleChildResource(
"entrypointID",
func(webhookID, childID string) (bool, error) {
var ep database.Entrypoint
err := h.db.DB().Where(
"id = ? AND webhook_id = ?",
childID, webhookID,
).First(&ep).Error
if err != nil {
return false, err
}
// Only the active column: saving the whole row would
// write back the description read above over an edit
// saved since.
active := !ep.Active
return active, h.db.DB().Model(&ep).
Update("active", active).Error
},
"failed to toggle entrypoint",
entrypointActivated, entrypointDeactivated,
)
}
+77
View File
@@ -1,6 +1,11 @@
package handlers package handlers
import ( import (
"fmt"
"time"
"github.com/dustin/go-humanize"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
@@ -11,6 +16,21 @@ type EntrypointView struct {
Path string Path string
Description string Description string
Active bool Active bool
// Events is how many events arrived on the entrypoint's URL within
// the webhook's retention period. LastEvent is when the newest
// event ever to arrive on it did, relative, and LastEventUTC the
// full time; both are empty when none ever did.
Events int64
LastEvent string
LastEventUTC string
}
// entrypointEvents is one entrypoint's count read by
// addEntrypointEvents.
type entrypointEvents struct {
EntrypointID string
Events int64
} }
// NewEntrypointViews projects entrypoints for rendering. // NewEntrypointViews projects entrypoints for rendering.
@@ -32,3 +52,60 @@ func NewEntrypointViews(
return views return views
} }
// addEntrypointEvents fills in each view's event figures from the
// webhook's event database: when the last event arrived on its URL,
// from its EntrypointTotals row, and how many events arrived on it
// since the webhook's retention cutoff, counted in one query over the
// events' entrypoint_id index. Resubmitted copies did not arrive on
// the URL and are left out of both.
func addEntrypointEvents(
webhookDB *gorm.DB,
webhook *database.Webhook,
views []EntrypointView,
now time.Time,
) error {
ids := make([]string, len(views))
byID := make(map[string]*EntrypointView, len(views))
for i := range views {
ids[i] = views[i].ID
byID[views[i].ID] = &views[i]
}
var totals []database.EntrypointTotals
err := webhookDB.Where("entrypoint_id IN ?", ids).Find(&totals).Error
if err != nil {
return fmt.Errorf("reading entrypoint totals: %w", err)
}
query := webhookDB.Model(&database.Event{}).
Select("entrypoint_id, count(*) AS events").
Where("entrypoint_id IN ? AND resubmitted_from_id IS NULL", ids)
cutoff, finite := webhook.RetentionCutoff(now)
if finite {
query = query.Where("created_at >= ?", cutoff)
}
var counts []entrypointEvents
err = query.Group("entrypoint_id").Find(&counts).Error
if err != nil {
return fmt.Errorf("counting events by entrypoint: %w", err)
}
for _, row := range totals {
view := byID[row.EntrypointID]
view.LastEvent = humanize.Time(row.LastEventAt)
view.LastEventUTC =
row.LastEventAt.UTC().Format(time.DateTime) + " UTC"
}
for _, row := range counts {
byID[row.EntrypointID].Events = row.Events
}
return nil
}
+197
View File
@@ -0,0 +1,197 @@
package handlers_test
import (
"net/http"
"strconv"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/session"
)
// entrypointRow returns the part of a rendered webhook page from an
// entrypoint's URL to the next entrypoint's, which holds its figures.
func entrypointRow(t *testing.T, page, entrypointID string) string {
t.Helper()
_, row, found := strings.Cut(page, `id="entrypoint-url-`+entrypointID+`"`)
require.True(t, found)
row, _, _ = strings.Cut(row, `id="entrypoint-url-`)
return row
}
// lastEventShown matches an entrypoint row's last event arriving at at.
func lastEventShown(at time.Time) string {
return `Last Event:</span>\s*<span title="` +
at.UTC().Format(time.DateTime) + ` UTC">[^<]+</span>`
}
// eventsShown matches an entrypoint row's count of n events.
func eventsShown(n int) string {
return `Events Within Retention:</span>\s*<span>` +
strconv.Itoa(n) + `</span>`
}
// TestHandleSourceDetail_ShowsEntrypointEvents proves each entrypoint
// on the webhook page shows its own figures: how many events arrived
// through it within the webhook's retention period, leaving out one
// older than that, and when the newest arrived, or "never" for an
// entrypoint with none.
func TestHandleSourceDetail_ShowsEntrypointEvents(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &dbMgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := &database.Webhook{
UserID: deleteTestUserID, Name: "figures", RetentionDays: 7,
}
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
webhookDB, err := dbMgr.GetDB(wh.ID)
require.NoError(t, err)
entrypoint := func() *database.Entrypoint {
ep := &database.Entrypoint{
WebhookID: wh.ID, Path: uuid.New().String(), Active: true,
}
require.NoError(t,
db.DB().Omit(clause.Associations).Create(ep).Error)
return ep
}
// event stores an event that arrived on ep's URL age ago and
// records it as ep's last event, as the receiver does.
event := func(ep *database.Entrypoint, age time.Duration) time.Time {
e := &database.Event{
WebhookID: wh.ID,
EntrypointID: ep.ID,
Method: http.MethodPost,
}
e.CreatedAt = time.Now().Add(-age)
require.NoError(t,
webhookDB.Omit(clause.Associations).Create(e).Error)
require.NoError(t, database.AddEntrypointTotals(webhookDB,
database.EntrypointTotals{
EntrypointID: ep.ID, LastEventAt: e.CreatedAt,
}))
return e.CreatedAt
}
busy, quiet, unused := entrypoint(), entrypoint(), entrypoint()
event(busy, 8*24*time.Hour) // older than the 7 days kept
event(busy, 3*time.Hour)
busyLast := event(busy, time.Hour)
quietLast := event(quiet, 2*24*time.Hour)
body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.Regexp(t, lastEventShown(busyLast), entrypointRow(t, body, busy.ID))
assert.Regexp(t, eventsShown(2), entrypointRow(t, body, busy.ID))
assert.Regexp(t, lastEventShown(quietLast), entrypointRow(t, body, quiet.ID))
assert.Regexp(t, eventsShown(1), entrypointRow(t, body, quiet.ID))
assert.Regexp(t, `Last Event:</span>\s*<span>never</span>`,
entrypointRow(t, body, unused.ID))
assert.Regexp(t, eventsShown(0), entrypointRow(t, body, unused.ID))
}
// TestHandleSourceDetail_EntrypointLastEventSurvivesRetention checks
// that once retention has removed every event that arrived on an
// entrypoint's URL, the entrypoint still shows when the last one
// arrived rather than "never".
func TestHandleSourceDetail_EntrypointLastEventSurvivesRetention(
t *testing.T,
) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
log *logger.Logger
)
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := &database.Webhook{
UserID: deleteTestUserID, Name: "swept", RetentionDays: 1,
}
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
ep := seedEntrypoint(t, db, wh.ID)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, ep.Path, 1)
arrived := events[0].CreatedAt
statsAge(t, webhookDB, events[0].ID, time.Now().Add(-50*time.Hour))
statsPrune(t, db, dbMgr, log, webhookDB)
require.Empty(t, listEvents(t, webhookDB))
row := entrypointRow(t, renderSourceDetailPage(t, h, sess, wh.ID), ep.ID)
assert.Regexp(t, lastEventShown(arrived), row)
assert.Regexp(t, eventsShown(0), row)
}
// TestHandleSourceDetail_ResubmitLeavesEntrypointFigures checks that a
// resubmitted copy, which did not arrive on the entrypoint's URL,
// changes neither the entrypoint's last event nor its count.
func TestHandleSourceDetail_ResubmitLeavesEntrypointFigures(
t *testing.T,
) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &dbMgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
ep := seedEntrypoint(t, db, wh.ID)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, ep.Path, 1)
arrived := events[0].CreatedAt
require.Equal(t, http.StatusSeeOther,
postResubmit(t, h, sess, wh.ID, events[0].ID).Code)
require.Len(t, listEvents(t, webhookDB), 2)
var totals database.EntrypointTotals
require.NoError(t, webhookDB.Take(&totals).Error)
assert.True(t, arrived.Equal(totals.LastEventAt))
row := entrypointRow(t, renderSourceDetailPage(t, h, sess, wh.ID), ep.ID)
assert.Regexp(t, lastEventShown(arrived), row)
assert.Regexp(t, eventsShown(1), row)
}
+3 -1
View File
@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"math"
"net/http" "net/http"
"github.com/go-chi/chi" "github.com/go-chi/chi"
@@ -59,8 +60,9 @@ func (h *Handlers) HandleEventDetail() http.HandlerFunc {
return return
} }
// The page shows every request header.
views, ok := h.eventLogViews( views, ok := h.eventLogViews(
w, r, webhookDB, webhook.ID, rows, targets, w, r, webhookDB, webhook.ID, rows, targets, math.MaxInt,
) )
if !ok { if !ok {
return return
+620
View File
@@ -0,0 +1,620 @@
package handlers
import (
"net/http"
"slices"
"time"
"github.com/dustin/go-humanize"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
// DeliveryView is the display-safe projection of a delivery
// for the event log page. Its target is a TargetView, so the
// stored configuration blob — which holds the target's
// credential — has no path to the template.
type DeliveryView struct {
ID string
Status database.DeliveryStatus
Target delivery.TargetView
// Replay is set on a delivery the Replay action created.
Replay bool
// Created is how long ago the delivery was created, and
// CreatedUTC the full timestamp the page shows on hover.
Created string
CreatedUTC string
// Results is this delivery's attempts in attempt order,
// bounded by maxRenderedAttempts. Without them a failure
// renders as the status word alone and says nothing about
// why.
Results []DeliveryResultView
// AttemptCount is how many attempts were recorded, which
// is more than len(Results) once the middle was dropped.
AttemptCount int
// AttemptsOmitted is how many attempts were dropped from
// the middle of Results. The page must show it, or the
// bound would hide history rather than fold it.
AttemptsOmitted int
// Paused is set while the delivery is retrying and its
// target's circuit breaker is open, and nil otherwise.
Paused *PausedView
}
// eventLogTarget is what the event log needs to know about
// one target: the display-safe view its template renders, and
// the redactor that keeps that target's own credential out of
// the text its remote peer chose. The two are kept together
// so a caller cannot pick up one without the other, and apart
// from TargetView so the secrets never reach a template.
type eventLogTarget struct {
View delivery.TargetView
Redactor delivery.Redactor
}
// The event log's show query parameter and its two values: the events
// with a failed delivery, and those with a delivery still pending or
// retrying.
const (
showParam = "show"
showFailed = "failed"
showPending = "pending"
)
// HandleSourceLogs shows the request/response logs for a
// webhook.
func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
webhook, ok := h.ownedWebhook(w, r)
if !ok {
return
}
targets, err := h.loadTargetMap(webhook.ID)
if err != nil {
// Without the map every delivery renders through a
// zero redactor, so failing the page is the only
// safe answer.
h.serverError(w, r, "failed to load targets", err)
return
}
// Any other value of show lists every event, as no value
// does.
show := r.URL.Query().Get(showParam)
statuses := eventLogStatuses(show)
if statuses == nil {
show = ""
}
evts, total, ok := h.loadEventsWithDeliveries(
w, r, webhook, targets, statuses,
)
if !ok {
return
}
failed, pending, err := h.countFailedAndPendingEvents(webhook.ID)
if err != nil {
h.serverError(w, r, "failed to count events", err)
return
}
data := map[string]any{
tmplKeyWebhook: &webhook,
"Events": evts,
"TotalEvents": total,
"Show": show,
"FailedEvents": failed,
"PendingEvents": pending,
}
h.renderTemplate(w, r, "source_logs.html", data)
}
}
// loadTargetMap loads targets into a map of display-safe
// views keyed by target ID, each paired with its redactor.
// The projection happens here so that no caller can hand a
// raw target, configuration blob and all, to a template: the
// raw rows do not leave this function.
//
// The load is Unscoped because deleting a target only soft
// deletes the row while its deliveries survive in the
// per-webhook database. Both halves of the map need those rows:
// a scoped load leaves an old delivery with a zero redactor,
// which renders its response bodies unredacted, and with a zero
// view, which renders its target as a blank name.
//
// This map is historical display only. It is built for the event
// log and an event's own page, and reaches nothing but
// DeliveryView.Target: the target list on the source detail page,
// the edit form and the replay path each resolve targets
// themselves, and a deleted row is refused there as before.
func (h *Handlers) loadTargetMap(
webhookID string,
) (map[string]eventLogTarget, error) {
var targets []database.Target
err := h.db.DB().Unscoped().Where(
"webhook_id = ?", webhookID,
).Find(&targets).Error
if err != nil {
return nil, err
}
targetMap := make(
map[string]eventLogTarget, len(targets),
)
for i := range targets {
targetMap[targets[i].ID] = eventLogTarget{
Redactor: delivery.NewRedactor(&targets[i]),
}
}
// The views come from NewTargetViews rather than being
// rebuilt here, so the masking rules stay in one place and a
// deleted target's configuration is masked by the same code
// that masks a live one's.
for _, v := range delivery.NewTargetViews(targets) {
entry := targetMap[v.ID]
entry.View = v
targetMap[v.ID] = entry
}
return targetMap, nil
}
// loadEventsWithDeliveries loads the recentEventLimit newest events
// and their deliveries from the per-webhook database, and the total
// number of events stored. Given delivery statuses, both cover only
// the events with a delivery in one of them. Events come back as
// capped projections rather than database.Event rows: see
// eventLogColumns for why the cut happens in SQL.
//
// The bool reports whether the load succeeded. It is false
// once this has answered the request with an error, and the
// caller must then render nothing further.
func (h *Handlers) loadEventsWithDeliveries(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
targetMap map[string]eventLogTarget,
statuses []database.DeliveryStatus,
) ([]EventLogView, int64, bool) {
if !h.dbMgr.DBExists(webhook.ID) {
return nil, 0, true
}
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.serverError(
w, r, "failed to get webhook database", err,
)
return nil, 0, false
}
rows, totalEvents, err := loadEventLogRows(
webhookDB, webhook.ID, statuses,
)
if err != nil {
h.serverError(w, r, "failed to load events", err)
return nil, 0, false
}
result, ok := h.eventLogViews(
w, r, webhookDB, webhook.ID, rows, targetMap,
maxRenderedBodyBytes,
)
return result, totalEvents, ok
}
// eventLogViews projects loaded events for rendering, each with
// its deliveries, how many times it has been resubmitted and the
// entrypoint it arrived at (for a resubmitted copy, the one the
// request it copies arrived at), and with its request headers only
// when their text holds at most maxHeaderBytes. Like
// loadEventsWithDeliveries, it reports false once it has answered
// the request with an error.
func (h *Handlers) eventLogViews(
w http.ResponseWriter,
r *http.Request,
webhookDB *gorm.DB,
webhookID string,
rows []eventLogRow,
targetMap map[string]eventLogTarget,
maxHeaderBytes int,
) ([]EventLogView, bool) {
result := make([]EventLogView, len(rows))
eventDeliveries := make([][]database.Delivery, len(rows))
var deliveryIDs []string
eventIDs := make([]string, len(rows))
for i := range rows {
result[i] = rows[i].view(webhookID, maxHeaderBytes)
eventIDs[i] = rows[i].ID
webhookDB.Where(
"event_id = ?", rows[i].ID,
).Find(&eventDeliveries[i])
for j := range eventDeliveries[i] {
deliveryIDs = append(
deliveryIDs, eventDeliveries[i][j].ID,
)
}
}
attempts, err := h.loadDeliveryResults(
webhookDB, deliveryIDs,
)
if err != nil {
h.serverError(
w, r, "failed to load delivery attempts", err,
)
return nil, false
}
resubmits, err := resubmitCounts(webhookDB, eventIDs)
if err != nil {
h.serverError(
w, r, "failed to count event resubmissions", err,
)
return nil, false
}
entrypoints, err := h.entrypointNames(webhookID)
if err != nil {
h.serverError(w, r, "failed to load entrypoints", err)
return nil, false
}
for i := range rows {
result[i].Deliveries = h.newDeliveryViews(
eventDeliveries[i], targetMap, attempts,
)
result[i].ResubmitCount = resubmits[rows[i].ID]
name, ok := entrypoints[rows[i].EntrypointID]
if !ok {
name = "deleted entrypoint"
}
result[i].Entrypoint = name
}
return result, true
}
// loadEventLogRows reads the event log projection of the
// recentEventLimit newest events, newest first, and the total number
// of events stored, both narrowed by statuses as eventsWithStatus
// narrows them.
func loadEventLogRows(
webhookDB *gorm.DB,
webhookID string,
statuses []database.DeliveryStatus,
) ([]eventLogRow, int64, error) {
totalEvents, err := countEventsWithStatus(
webhookDB, webhookID, statuses,
)
if err != nil {
return nil, 0, err
}
var rows []eventLogRow
err = eventsWithStatus(webhookDB, webhookID, statuses).Select(
eventLogColumns, maxRenderedBodyBytes, maxRenderedBodyBytes,
).Order("created_at DESC").Limit(recentEventLimit).Find(&rows).Error
return rows, totalEvents, err
}
// eventLogStatuses returns the delivery statuses the event log's show
// value lists events by, or nil for one that lists every event.
func eventLogStatuses(show string) []database.DeliveryStatus {
switch show {
case showFailed:
return []database.DeliveryStatus{database.DeliveryStatusFailed}
case showPending:
return []database.DeliveryStatus{
database.DeliveryStatusPending,
database.DeliveryStatusRetrying,
}
default:
return nil
}
}
// eventsWithStatus selects the webhook's events, or, given statuses,
// the recentEventLimit newest of those with at least one delivery in
// one of them.
//
// Given statuses, its cost follows the matching deliveries. SQLite
// never reorders a CROSS JOIN, so it reads each join's left side
// first: the distinct event IDs of the matching deliveries, through
// idx_deliveries_status; then each of those events by ID, sorted to
// keep the newest; then the rows of only the events kept, so no other
// event's body is read. With a plain "id IN (matching deliveries)"
// condition instead, SQLite, which keeps no statistics on these
// tables, walks every event newest first.
func eventsWithStatus(
webhookDB *gorm.DB,
webhookID string,
statuses []database.DeliveryStatus,
) *gorm.DB {
if statuses == nil {
return webhookDB.Model(&database.Event{}).Where(
"webhook_id = ?", webhookID,
)
}
matching := webhookDB.Model(&database.Delivery{}).
Distinct("event_id").Where("status IN ?", statuses)
newest := webhookDB.Table("(?) AS matching", matching).
Joins("CROSS JOIN events ON events.id = matching.event_id").
Where(
"events.webhook_id = ? AND events.deleted_at IS NULL",
webhookID,
).
Order("events.created_at DESC").Limit(recentEventLimit).
Select("events.id AS event_id")
return webhookDB.Table("(?) AS newest", newest).
Joins("CROSS JOIN events ON events.id = newest.event_id")
}
// countEventsWithStatus counts the webhook's events with at least one
// delivery in one of the statuses, or every event when statuses is
// nil. Given statuses, it counts the distinct events of the matching
// deliveries and reads nothing but those deliveries, through
// idx_deliveries_status, where counting the events would read every
// event row. That is the same number, because retention deletes an
// event's deliveries with it.
func countEventsWithStatus(
webhookDB *gorm.DB,
webhookID string,
statuses []database.DeliveryStatus,
) (int64, error) {
var count int64
if statuses == nil {
err := webhookDB.Model(&database.Event{}).Where(
"webhook_id = ?", webhookID,
).Count(&count).Error
return count, err
}
err := webhookDB.Model(&database.Delivery{}).Distinct("event_id").
Where("status IN ?", statuses).Count(&count).Error
return count, err
}
// countFailedAndPendingEvents returns how many of the webhook's events
// the event log lists when it shows only those with a failed delivery,
// and when it shows only those with a delivery pending or retrying.
func (h *Handlers) countFailedAndPendingEvents(
webhookID string,
) (int64, int64, error) {
if !h.dbMgr.DBExists(webhookID) {
return 0, 0, nil
}
webhookDB, err := h.dbMgr.GetDB(webhookID)
if err != nil {
return 0, 0, err
}
failed, err := countEventsWithStatus(
webhookDB, webhookID, eventLogStatuses(showFailed),
)
if err != nil {
return 0, 0, err
}
pending, err := countEventsWithStatus(
webhookDB, webhookID, eventLogStatuses(showPending),
)
if err != nil {
return 0, 0, err
}
return failed, pending, nil
}
// resubmitCounts reports, for each of the page's events, how many
// events have been resubmitted from it.
//
// One grouped query covers the page rather than one query per event.
// The page shows at most recentEventLimit events, far below SQLite's
// bound parameter ceiling, so it needs no chunking as the delivery
// result load does.
func resubmitCounts(
webhookDB *gorm.DB, eventIDs []string,
) (map[string]int, error) {
counts := make(map[string]int, len(eventIDs))
if len(eventIDs) == 0 {
return counts, nil
}
var rows []struct {
ResubmittedFromID string
Total int
}
err := webhookDB.Model(&database.Event{}).
Select("resubmitted_from_id, count(*) AS total").
Where("resubmitted_from_id IN ?", eventIDs).
Group("resubmitted_from_id").
Find(&rows).Error
if err != nil {
return nil, err
}
for _, row := range rows {
counts[row.ResubmittedFromID] = row.Total
}
return counts, nil
}
// deliveryIDChunkSize bounds how many delivery IDs go into one
// IN clause. SQLite refuses a statement carrying more than
// SQLITE_MAX_VARIABLE_NUMBER (32766) bound parameters, and a
// page holds one delivery per target per event, so a webhook
// with enough targets would turn the whole query into an error
// and the page into zero attempts.
const deliveryIDChunkSize = 500
// loadDeliveryResults loads the recorded attempts for the
// page's deliveries, keyed by delivery ID.
//
// Each response body is cut by SQLite rather than in Go, for
// the reason deliveryResultColumns gives. How many attempts a
// delivery has is the target's MaxRetries, which the
// authenticated operator sets; how many of them reach the page
// is bounded again by maxRenderedAttempts.
func (h *Handlers) loadDeliveryResults(
webhookDB *gorm.DB,
deliveryIDs []string,
) (map[string][]deliveryResultRow, error) {
byDelivery := make(map[string][]deliveryResultRow)
for chunk := range slices.Chunk(
deliveryIDs, deliveryIDChunkSize,
) {
var rows []deliveryResultRow
err := webhookDB.Model(
&database.DeliveryResult{},
).Select(
deliveryResultColumns, maxRenderedResponseBytes,
).Where(
"delivery_id IN ?", chunk,
).Order("attempt_num ASC").Find(&rows).Error
if err != nil {
// Returning what was loaded so far renders the
// deliveries in the failed chunk as never having run,
// which is indistinguishable from ones that really
// never ran. The page fails instead.
return nil, err
}
for i := range rows {
byDelivery[rows[i].DeliveryID] = append(
byDelivery[rows[i].DeliveryID], rows[i],
)
}
}
return byDelivery, nil
}
// newDeliveryViews projects deliveries for rendering,
// resolving each one's target to its display-safe view and
// each one's attempts through that target's redactor. A
// retrying delivery also reads its target's circuit breaker.
func (h *Handlers) newDeliveryViews(
deliveries []database.Delivery,
targetMap map[string]eventLogTarget,
attempts map[string][]deliveryResultRow,
) []DeliveryView {
views := make([]DeliveryView, len(deliveries))
for i := range deliveries {
target := targetMap[deliveries[i].TargetID]
rows := attempts[deliveries[i].ID]
created := deliveries[i].CreatedAt
results, omitted := renderedAttempts(
rows, target.Redactor,
)
views[i] = DeliveryView{
ID: deliveries[i].ID,
Status: deliveries[i].Status,
Target: target.View,
Replay: deliveries[i].Replay,
Created: humanize.Time(created),
CreatedUTC: created.UTC().Format(time.DateTime) + " UTC",
Results: results,
AttemptCount: len(rows),
AttemptsOmitted: omitted,
}
if deliveries[i].Status == database.DeliveryStatusRetrying {
views[i].Paused = h.deliveryPausedView(
deliveries[i].TargetID, rows,
)
}
}
return views
}
// maxRenderedAttempts bounds how many of one delivery's
// attempts the page renders. Past it the middle is dropped and
// counted, keeping the first attempts and the last ones: how
// the delivery started failing and how it ended are what a
// reader needs, and the count says plainly that the rest was
// dropped rather than never recorded.
const (
renderedAttemptsHead = 10
renderedAttemptsTail = 10
maxRenderedAttempts = renderedAttemptsHead +
renderedAttemptsTail
)
// renderedAttempts projects a delivery's attempts through the
// target's redactor, at most maxRenderedAttempts of them, and
// reports how many it dropped.
func renderedAttempts(
rows []deliveryResultRow,
redactor delivery.Redactor,
) ([]DeliveryResultView, int) {
omitted := 0
if len(rows) > maxRenderedAttempts {
omitted = len(rows) - maxRenderedAttempts
kept := make(
[]deliveryResultRow, 0, maxRenderedAttempts,
)
kept = append(kept, rows[:renderedAttemptsHead]...)
kept = append(
kept, rows[len(rows)-renderedAttemptsTail:]...,
)
rows = kept
}
views := make([]DeliveryResultView, len(rows))
for i := range rows {
views[i] = rows[i].view(redactor)
}
return views, omitted
}
+52
View File
@@ -0,0 +1,52 @@
package handlers_test
import (
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
)
// TestEventLog_TimesCarryTheirZone proves that the event log shows
// when an event arrived, and that it and the event's page show when
// each delivery was created and each attempt recorded: each as how
// long ago, with the full UTC time on hover.
func TestEventLog_TimesCarryTheirZone(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
now := time.Now().UTC().Truncate(time.Second)
receivedAt := now.Add(-3 * time.Hour)
createdAt := now.Add(-90 * time.Minute)
ranAt := now.Add(-30 * time.Minute)
event := f.event(t, contentTypeJSON, "{}", receivedAt)
dlv := f.deliveryQueuedAt(
t, event, target.ID, database.DeliveryStatusDelivered, createdAt,
)
f.attempt(t, dlv, http.StatusOK, ranAt.Sub(createdAt))
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
require.Equal(t, http.StatusOK, w.Code)
eventPage := w.Body.String()
eventLog := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
received := receivedAt.Format(time.DateTime)
assert.Contains(t, eventLog, `title="`+received+` UTC">3 hours ago</span>`)
assert.NotContains(t, eventLog, received+"</span>",
"an event's time must not be written without its zone")
assert.Contains(t, eventPage, received+" UTC")
for _, page := range []string{eventLog, eventPage} {
assert.Contains(t, page, `title="`+createdAt.Format(time.DateTime)+
` UTC">created 1 hour ago</span>`)
assert.Contains(t, page, `title="`+ranAt.Format(time.DateTime)+
` UTC">30 minutes ago</span>`)
}
}
+124 -11
View File
@@ -1,8 +1,15 @@
package handlers package handlers
import ( import (
"encoding/json"
"net/http"
"slices"
"strings"
"time" "time"
"unicode/utf8" "unicode/utf8"
"github.com/dustin/go-humanize"
"sneak.berlin/go/webhooker/internal/database"
) )
// eventLogColumns is the event log's projection. The casts to // eventLogColumns is the event log's projection. The casts to
@@ -10,16 +17,20 @@ import (
// bytes rather than characters, so the cap bounds the page in // bytes rather than characters, so the cap bounds the page in
// bytes whatever the payload's encoding. Cutting in SQLite // bytes whatever the payload's encoding. Cutting in SQLite
// rather than in Go is the point of the projection — an // rather than in Go is the point of the projection — an
// oversized body never becomes a Go string at all. // oversized body or set of request headers never becomes a Go
// string at all.
const eventLogColumns = "id, created_at, method, content_type, " + const eventLogColumns = "id, created_at, method, content_type, " +
"resubmitted_from_id, " + "resubmitted_from_id, entrypoint_id, " +
"substr(cast(headers as blob), 1, ?) AS headers, " +
"length(cast(headers as blob)) AS headers_bytes, " +
"substr(cast(body as blob), 1, ?) AS body, " + "substr(cast(body as blob), 1, ?) AS body, " +
"length(cast(body as blob)) AS body_bytes" "length(cast(body as blob)) AS body_bytes"
// eventColumns is eventLogColumns for the event's own page, which // eventColumns is eventLogColumns for the event's own page, which
// shows the whole body. // shows the whole body and every request header.
const eventColumns = "id, created_at, method, content_type, " + const eventColumns = "id, created_at, method, content_type, " +
"resubmitted_from_id, " + "resubmitted_from_id, entrypoint_id, headers, " +
"length(cast(headers as blob)) AS headers_bytes, " +
"cast(body as blob) AS body, " + "cast(body as blob) AS body, " +
"length(cast(body as blob)) AS body_bytes" "length(cast(body as blob)) AS body_bytes"
@@ -28,12 +39,32 @@ const eventColumns = "id, created_at, method, content_type, " +
// DeliveryView and TargetView. // DeliveryView and TargetView.
type EventLogView struct { type EventLogView struct {
ID string ID string
CreatedAt time.Time
Method string Method string
ContentType string ContentType string
// Received is how long ago the event arrived, and ReceivedUTC
// the full timestamp.
Received string
ReceivedUTC string
Body BodyView Body BodyView
// Entrypoint names the entrypoint the event arrived at. A
// resubmitted copy, even a copy of a copy, did not arrive; it
// names the one the request it copies arrived at. The name is
// the entrypoint's description, "Entrypoint" when it has none,
// or "deleted entrypoint", never its URL, which is the
// entrypoint's secret.
Entrypoint string
// Headers is the event's request headers as text, one
// "Name: value" line per value, sorted by name. HeadersCut
// reports headers left out because they hold more than
// maxRenderedBodyBytes, stored or as text; only the event log
// leaves them out.
Headers string
HeadersCut bool
// ResubmittedFromID names the event this one was copied // ResubmittedFromID names the event this one was copied
// from, empty for an event that arrived on the receiver. // from, empty for an event that arrived on the receiver.
ResubmittedFromID string ResubmittedFromID string
@@ -54,39 +85,121 @@ func (v EventLogView) ResubmittedFrom() bool {
} }
// eventLogRow is one row of the event log projection, or of // eventLogRow is one row of the event log projection, or of
// eventColumns. In the event log its body column arrives // eventColumns. In the event log its headers and body columns
// already cut to the cap by SQLite, with the true size beside // arrive already cut to the cap by SQLite, each with its true
// it. // size beside it.
type eventLogRow struct { type eventLogRow struct {
ID string ID string
CreatedAt time.Time CreatedAt time.Time
Method string Method string
ContentType string ContentType string
ResubmittedFromID *string ResubmittedFromID *string
EntrypointID string
Headers string
HeadersBytes int64
Body []byte Body []byte
BodyBytes int64 BodyBytes int64
} }
// view projects a loaded row of the webhook's events for // view projects a loaded row of the webhook's events for
// rendering. // rendering. It shows the request headers when the row holds them
func (r *eventLogRow) view(webhookID string) EventLogView { // whole and their text holds at most maxHeaderBytes.
func (r *eventLogRow) view(
webhookID string, maxHeaderBytes int,
) EventLogView {
var from string var from string
if r.ResubmittedFromID != nil { if r.ResubmittedFromID != nil {
from = *r.ResubmittedFromID from = *r.ResubmittedFromID
} }
headers, fit := requestHeaderLines(r.Headers, maxHeaderBytes)
return EventLogView{ return EventLogView{
ID: r.ID, ID: r.ID,
CreatedAt: r.CreatedAt,
Method: r.Method, Method: r.Method,
ContentType: r.ContentType, ContentType: r.ContentType,
Received: humanize.Time(r.CreatedAt),
ReceivedUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
Body: newBodyView( Body: newBodyView(
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes, "/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
), ),
Headers: strings.Join(headers, "\n"),
HeadersCut: !fit || r.HeadersBytes > int64(len(r.Headers)),
ResubmittedFromID: from, ResubmittedFromID: from,
} }
} }
// requestHeaderLines turns an event's stored request headers, the
// JSON the receiver writes, into one "Name: value" line per value,
// sorted by name. Headers that do not parse, as when the event log
// has cut them, show as none. It reports false, with no lines, when
// the lines, each with the newline that follows it, would hold more
// than maxBytes: a header sent many times is stored with its name
// once but shown with it on every line.
func requestHeaderLines(headersJSON string, maxBytes int) ([]string, bool) {
var headers http.Header
if json.Unmarshal([]byte(headersJSON), &headers) != nil {
return nil, true
}
names := make([]string, 0, len(headers))
for name := range headers {
names = append(names, name)
}
slices.Sort(names)
var lines []string
size := 0
for _, name := range names {
for _, value := range headers[name] {
line := name + ": " + value
size += len(line) + len("\n")
if size > maxBytes {
return nil, false
}
lines = append(lines, line)
}
}
return lines, true
}
// entrypointNames maps each of the webhook's entrypoints to the name
// an event that arrived at it shows: its description, or "Entrypoint"
// when it has none, as the webhook page names it. A deleted
// entrypoint is left out.
func (h *Handlers) entrypointNames(
webhookID string,
) (map[string]string, error) {
var entrypoints []database.Entrypoint
err := h.db.DB().Where(
"webhook_id = ?", webhookID,
).Find(&entrypoints).Error
if err != nil {
return nil, err
}
names := make(map[string]string, len(entrypoints))
for i := range entrypoints {
name := entrypoints[i].Description
if name == "" {
name = "Entrypoint"
}
names[entrypoints[i].ID] = name
}
return names, nil
}
// trimPartialRune drops a trailing UTF-8 sequence that the // trimPartialRune drops a trailing UTF-8 sequence that the
// byte-wise cut left incomplete, so a multi-byte rune severed // byte-wise cut left incomplete, so a multi-byte rune severed
// at the cap does not surface as a mojibake tail. // at the cap does not surface as a mojibake tail.
+1 -3
View File
@@ -75,9 +75,7 @@ func seedAndProject(
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
seedEventWithBody(t, dbMgr, wh.ID, body) seedEventWithBody(t, dbMgr, wh.ID, body)
views := h.LoadEventLogViewsForTest( views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh)
httptest.NewRecorder(), *wh, 1,
)
require.Len(t, views, 1) require.Len(t, views, 1)
return views[0] return views[0]
+333
View File
@@ -0,0 +1,333 @@
package handlers_test
import (
"encoding/json"
"net/http"
"slices"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
)
// arrivedAt is how a page names the entrypoint an event arrived at.
func arrivedAt(name string) string {
return `Arrived at <span class="text-gray-900 wrap-anywhere">` + name +
`</span>`
}
// copiedRequestArrivedAt is how a page names, for a resubmitted copy,
// the entrypoint the request it copies arrived at.
func copiedRequestArrivedAt(name string) string {
return `The request it copies arrived at ` +
`<span class="text-gray-900 wrap-anywhere">` + name + `</span>`
}
// headerBox is how a page shows an event's request header lines: as
// one block of text in a single box.
func headerBox(lines ...string) string {
return `<pre class="rounded-md border border-gray-200 bg-white p-2 ` +
`text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap ` +
`break-all">` + strings.Join(lines, "\n") + `</pre>`
}
// showHeadersLink is the event log's link to an event's own page for
// request headers it leaves out.
func showHeadersLink(webhookID, eventID string) string {
return `<a href="/hook/` + webhookID + `/events/` + eventID +
`" class="btn-small">Show the request headers</a>`
}
// entrypoint records one of the fixture webhook's entrypoints.
func (f *recentEventsFixture) entrypoint(
t *testing.T, description string,
) *database.Entrypoint {
t.Helper()
ep := &database.Entrypoint{
WebhookID: f.webhook.ID,
Path: uuid.NewString(),
Description: description,
Active: true,
}
require.NoError(t, f.db.DB().Omit(clause.Associations).Create(ep).Error)
return ep
}
// eventAt records an event that arrived at the entrypoint with the
// given request headers, stored as JSON as the receiver stores them.
func (f *recentEventsFixture) eventAt(
t *testing.T,
ep *database.Entrypoint,
headersJSON string,
receivedAt time.Time,
) *database.Event {
t.Helper()
event := &database.Event{
WebhookID: f.webhook.ID,
EntrypointID: ep.ID,
Method: http.MethodPost,
Headers: headersJSON,
Body: "{}",
BodyBytes: 2,
ContentType: contentTypeJSON,
}
event.CreatedAt = receivedAt
require.NoError(t, f.webhookDB.Omit(
clause.Associations,
).Create(event).Error)
return event
}
// TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders proves two
// events that arrived at two entrypoints each show their own
// entrypoint and request headers, in the event log and on their own
// pages, with the headers sorted by name, escaped and keeping their
// whitespace, and never the entrypoint's URL.
func TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders(
t *testing.T,
) {
t.Parallel()
f := newRecentEventsFixture(t)
billing := f.entrypoint(t, "Billing sender")
unnamed := f.entrypoint(t, "")
// Stored in reverse name order.
older := f.eventAt(t, billing,
`{"X-Shop-Event":["order.created"],`+
`"User-Agent":["shop/1 build\t7"],"Accept":["*/*"]}`,
time.Now().Add(-time.Minute))
newer := f.eventAt(t, unnamed,
`{"X-Shop-Event":["order.paid"],"X-Note":["<b>hi</b>"]}`,
time.Now())
olderShows := func(t *testing.T, page string) {
t.Helper()
assert.Contains(t, page, arrivedAt("Billing sender"))
assert.Contains(t, page, headerBox(
"Accept: */*",
"User-Agent: shop/1 build\t7",
"X-Shop-Event: order.created",
), "headers are sorted by name")
assert.NotContains(t, page, "order.paid")
assert.NotContains(t, page, billing.Path)
}
newerShows := func(t *testing.T, page string) {
t.Helper()
assert.Contains(t, page, arrivedAt("Entrypoint"))
assert.Contains(t, page, headerBox(
"X-Note: &lt;b&gt;hi&lt;/b&gt;",
"X-Shop-Event: order.paid",
))
assert.NotContains(t, page, "<b>hi</b>")
assert.NotContains(t, page, "order.created")
assert.NotContains(t, page, unnamed.Path)
}
// The log lists the newer event first, so everything between
// the two events' first mentions belongs to the newer one.
_, rest, found := strings.Cut(renderSourceLogsPage(
t, f.h, f.sess, f.webhook.ID,
), newer.ID)
require.True(t, found)
newerPart, olderPart, found := strings.Cut(rest, older.ID)
require.True(t, found)
newerShows(t, newerPart)
olderShows(t, olderPart)
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, newer.ID)
require.Equal(t, http.StatusOK, w.Code)
newerShows(t, w.Body.String())
w = serveEventPage(t, f.h, f.sess, f.webhook.ID, older.ID)
require.Equal(t, http.StatusOK, w.Code)
olderShows(t, w.Body.String())
}
// TestEventRequest_DeletedEntrypoint proves an event whose entrypoint
// has since been deleted says so in the event log and on its own page.
func TestEventRequest_DeletedEntrypoint(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
ep := f.entrypoint(t, "Retired sender")
event := f.eventAt(t, ep, `{}`, time.Now())
require.NoError(t, f.db.DB().Delete(ep).Error)
page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
assert.Contains(t, page, arrivedAt("deleted entrypoint"))
assert.NotContains(t, page, "Retired sender")
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), arrivedAt("deleted entrypoint"))
assert.NotContains(t, w.Body.String(), "Retired sender")
}
// TestEventRequest_ResubmittedCopy proves a resubmitted copy and a copy
// of that copy each say the request they copy arrived at the
// entrypoint, in the event log and on their own pages, and never that
// they did.
func TestEventRequest_ResubmittedCopy(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
ep := f.entrypoint(t, "Billing sender")
original := f.eventAt(t, ep, `{}`, time.Now().Add(-2*time.Minute))
copied := f.eventAt(t, ep, `{}`, time.Now().Add(-time.Minute))
copyOfCopy := f.eventAt(t, ep, `{}`, time.Now())
require.NoError(t, f.webhookDB.Model(copied).Update(
"resubmitted_from_id", original.ID,
).Error)
require.NoError(t, f.webhookDB.Model(copyOfCopy).Update(
"resubmitted_from_id", copied.ID,
).Error)
// The log lists the newest event first, and each event's Resubmit
// form comes before its entrypoint, so cutting the page at the
// copy's and the original's forms leaves each event's entrypoint
// in its own part.
copyOfCopyPart, rest, found := strings.Cut(
renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID),
"/events/"+copied.ID+"/resubmit",
)
require.True(t, found)
copyPart, originalPart, found := strings.Cut(
rest, "/events/"+original.ID+"/resubmit",
)
require.True(t, found)
for _, part := range []string{copyOfCopyPart, copyPart} {
assert.Contains(t, part, copiedRequestArrivedAt("Billing sender"))
assert.NotContains(t, part, arrivedAt("Billing sender"))
}
assert.Contains(t, originalPart, arrivedAt("Billing sender"))
assert.NotContains(t, originalPart,
copiedRequestArrivedAt("Billing sender"))
for _, event := range []*database.Event{copied, copyOfCopy} {
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(),
copiedRequestArrivedAt("Billing sender"))
assert.NotContains(t, w.Body.String(), arrivedAt("Billing sender"))
}
}
// TestEventRequest_HeadersOverTheLimit proves the event log leaves out
// request headers that hold more than it shows of a body, whether
// stored or as lines, and links to the event's own page, which shows
// them all.
func TestEventRequest_HeadersOverTheLimit(t *testing.T) {
t.Parallel()
// The receiver stores each "<" as six bytes of JSON, so this
// header is over the limit stored but not as a line.
const lessThans = bodyCap/6 + 1
// A header sent many times is stored with its name once, and
// shown with it on every line.
repeatedName := "X-Repeated-" + strings.Repeat("r", 1000)
tests := map[string]struct {
headers http.Header
line string
}{
"stored": {
headers: http.Header{"X-Long": {strings.Repeat("<", lessThans)}},
line: "X-Long: " + strings.Repeat("&lt;", lessThans),
},
"as lines": {
headers: http.Header{repeatedName: slices.Repeat([]string{""}, 41)},
line: repeatedName + ": ",
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()
headersJSON, err := json.Marshal(tc.headers)
require.NoError(t, err)
f := newRecentEventsFixture(t)
ep := f.entrypoint(t, "Billing sender")
event := f.eventAt(t, ep, string(headersJSON), time.Now())
page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
assert.Contains(t, page, showHeadersLink(f.webhook.ID, event.ID))
assert.NotContains(t, page, tc.line)
assert.Less(t, len(page), 4*bodyCap)
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), tc.line)
assert.NotContains(t, w.Body.String(), "Show the request headers")
})
}
}
// TestEventRequest_ManyShortHeaderLines proves that for many short
// request header lines the event log writes no more than its limit,
// apart from escaping: lines that fill the limit show as one block of
// text, and one line more is left out with a link to the event's own
// page.
func TestEventRequest_ManyShortHeaderLines(t *testing.T) {
t.Parallel()
// Each "A: " line and the newline after it hold four bytes, so
// this many lines fill the limit exactly. Each line in its own
// element would make the page many times the limit.
const fill = bodyCap / len("A: \n")
tests := map[string]struct {
lines int
shown bool
}{
"filling the limit": {lines: fill, shown: true},
"one over the limit": {lines: fill + 1, shown: false},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()
headersJSON, err := json.Marshal(http.Header{
"A": slices.Repeat([]string{""}, tc.lines),
})
require.NoError(t, err)
f := newRecentEventsFixture(t)
ep := f.entrypoint(t, "Billing sender")
event := f.eventAt(t, ep, string(headersJSON), time.Now())
page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
box := headerBox(slices.Repeat([]string{"A: "}, tc.lines)...)
link := showHeadersLink(f.webhook.ID, event.ID)
assert.Equal(t, tc.shown, strings.Contains(page, box))
assert.Equal(t, !tc.shown, strings.Contains(page, link))
assert.Less(t, len(page), 4*bodyCap)
})
}
}
+12 -10
View File
@@ -51,12 +51,6 @@ const (
SidecarLeftMsgForTest = sidecarLeftMsg SidecarLeftMsgForTest = sidecarLeftMsg
) )
// PageOrFirstForTest exposes pageOrFirst for use in the handlers_test
// package.
func PageOrFirstForTest(s string) int {
return pageOrFirst(s)
}
// DummyVerificationsForTest reports how many equivalent-cost // DummyVerificationsForTest reports how many equivalent-cost
// verifications were charged for usernames that do not exist. It // verifications were charged for usernames that do not exist. It
// lets a test prove the anti-enumeration path ran without timing // lets a test prove the anti-enumeration path ran without timing
@@ -79,10 +73,9 @@ func TrimPartialRuneForTest(b []byte) []byte {
func (s *Handlers) LoadEventLogViewsForTest( func (s *Handlers) LoadEventLogViewsForTest(
w http.ResponseWriter, w http.ResponseWriter,
webhook database.Webhook, webhook database.Webhook,
page int,
) []EventLogView { ) []EventLogView {
views, _, _ := s.loadEventsWithDeliveries( views, _, _ := s.loadEventsWithDeliveries(
w, newRequestForTest(), webhook, nil, page, w, newRequestForTest(), webhook, nil, nil,
) )
return views return views
@@ -167,7 +160,16 @@ func (s *Handlers) BuildHTTPTargetConfigForTest(
// buildDatabaseTargetConfig for use in the handlers_test // buildDatabaseTargetConfig for use in the handlers_test
// package. // package.
func BuildDatabaseTargetConfigForTest( func BuildDatabaseTargetConfigForTest(
expiry string, expiry, rotation string,
) (string, string, error) { ) (string, string, error) {
return buildDatabaseTargetConfig(expiry) return buildDatabaseTargetConfig(expiry, rotation)
}
// ArchiveFileViewForTest exposes archiveFileView, which describes a
// database target's archive files as the target list shows them at
// now.
func (s *Handlers) ArchiveFileViewForTest(
webhook *database.Webhook, target *database.Target, now time.Time,
) *ArchiveFileView {
return s.archiveFileView(webhook, target, now)
} }
+20 -11
View File
@@ -30,10 +30,9 @@ import (
const ( const (
// maxBodyShift is the bit shift for 1 MB body limit. // maxBodyShift is the bit shift for 1 MB body limit.
maxBodyShift = 20 maxBodyShift = 20
// recentEventLimit is the number of recent events to show. // recentEventLimit is the number of most recent events that a
// webhook's page and its event log show.
recentEventLimit = 50 recentEventLimit = 50
// paginationPerPage is the number of items per page.
paginationPerPage = 25
// tmplKeyError is the template data key for an error message. // tmplKeyError is the template data key for an error message.
tmplKeyError = "Error" tmplKeyError = "Error"
@@ -67,6 +66,7 @@ type HandlersParams struct {
Middleware *middleware.Middleware Middleware *middleware.Middleware
Notifier delivery.Notifier Notifier delivery.Notifier
Archives delivery.Archives Archives delivery.Archives
CircuitBreakers delivery.CircuitBreakers
SSRFGuard *delivery.Guard SSRFGuard *delivery.Guard
Metrics *metrics.Set Metrics *metrics.Set
Registry *prometheus.Registry Registry *prometheus.Registry
@@ -84,6 +84,7 @@ type Handlers struct {
mw *middleware.Middleware mw *middleware.Middleware
notifier delivery.Notifier notifier delivery.Notifier
archives delivery.Archives archives delivery.Archives
breakers delivery.CircuitBreakers
mtr *metrics.Set mtr *metrics.Set
templates map[string]*template.Template templates map[string]*template.Template
@@ -98,7 +99,9 @@ type Handlers struct {
// Interleaved, one could rename an archive between another's // Interleaved, one could rename an archive between another's
// rename and save, leaving the file named for one edit and the // rename and save, leaving the file named for one edit and the
// stored names from the other. An archive download holds it while // stored names from the other. An archive download holds it while
// it reads the stored names and opens the file they give. // it reads the stored names and lists the files they give, and
// again for each file while it finds the file under the names
// stored then and opens it.
renameMu sync.Mutex renameMu sync.Mutex
// dummyVerifications counts the equivalent-cost verifications // dummyVerifications counts the equivalent-cost verifications
@@ -110,22 +113,25 @@ type Handlers struct {
// parsePageTemplate parses a page-specific template set from the // parsePageTemplate parses a page-specific template set from the
// embedded FS. Each page template is combined with the shared // embedded FS. Each page template is combined with the shared
// base, htmlheader, navbar and notice templates, and with any further // base, htmlheader, navbar and notice templates, and with any further
// files the page includes. The page file must be listed first so that // files the page includes. The set is named after the page file, so
// its root action ({{template "base" .}}) becomes the template set's // the page's root action ({{template "base" .}}) is its entry point.
// entry point. //
// The page file is parsed last because a later definition of a name
// replaces an earlier one: the page's {{define "title"}} must replace
// the {{block "title"}} fallback in htmlheader.html.
func parsePageTemplate( func parsePageTemplate(
pageFile string, included ...string, pageFile string, included ...string,
) *template.Template { ) *template.Template {
files := append([]string{ files := append([]string{
pageFile,
"base.html", "base.html",
"htmlheader.html", "htmlheader.html",
"navbar.html", "navbar.html",
"notice.html", "notice.html",
}, included...) }, included...)
files = append(files, pageFile)
return template.Must( return template.Must(
template.ParseFS(templates.Templates, files...), template.New(pageFile).ParseFS(templates.Templates, files...),
) )
} }
@@ -145,6 +151,7 @@ func New(
s.mw = params.Middleware s.mw = params.Middleware
s.notifier = params.Notifier s.notifier = params.Notifier
s.archives = params.Archives s.archives = params.Archives
s.breakers = params.CircuitBreakers
s.mtr = params.Metrics s.mtr = params.Metrics
s.ssrf = params.SSRFGuard s.ssrf = params.SSRFGuard
@@ -160,10 +167,12 @@ func New(
), ),
"source_edit.html": parsePageTemplate("source_edit.html"), "source_edit.html": parsePageTemplate("source_edit.html"),
"source_logs.html": parsePageTemplate( "source_logs.html": parsePageTemplate(
"source_logs.html", "event_body.html", "delivery_attempts.html", "source_logs.html", "event_request.html", "event_body.html",
"delivery_row.html", "delivery_attempts.html",
), ),
"event_detail.html": parsePageTemplate( "event_detail.html": parsePageTemplate(
"event_detail.html", "event_body.html", "delivery_attempts.html", "event_detail.html", "event_request.html", "event_body.html",
"delivery_row.html", "delivery_attempts.html",
), ),
"target_edit.html": parsePageTemplate("target_edit.html"), "target_edit.html": parsePageTemplate("target_edit.html"),
"error.html": parsePageTemplate("error.html"), "error.html": parsePageTemplate("error.html"),
+77 -5
View File
@@ -9,6 +9,7 @@ import (
"net/http/httptest" "net/http/httptest"
"sync" "sync"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -181,6 +182,40 @@ func (r *recordingArchives) Renames() []archiveRename {
return out return out
} }
// testCircuitBreakers is a delivery.CircuitBreakers that reports, for
// each target, the circuit state and cooldown a test gave it with Set,
// and a closed breaker for any other target.
type testCircuitBreakers struct {
mu sync.Mutex
states map[string]delivery.CircuitState
cooldowns map[string]time.Duration
}
// Set makes the target's breaker read as state, with cooldown left.
func (b *testCircuitBreakers) Set(
targetID string, state delivery.CircuitState, cooldown time.Duration,
) {
b.mu.Lock()
defer b.mu.Unlock()
if b.states == nil {
b.states = map[string]delivery.CircuitState{}
b.cooldowns = map[string]time.Duration{}
}
b.states[targetID] = state
b.cooldowns[targetID] = cooldown
}
func (b *testCircuitBreakers) StateAndCooldown(
targetID string,
) (delivery.CircuitState, time.Duration) {
b.mu.Lock()
defer b.mu.Unlock()
return b.states[targetID], b.cooldowns[targetID]
}
// newTestApp returns an app whose RequireStart fails the test when // newTestApp returns an app whose RequireStart fails the test when
// starting takes longer than fx's default start timeout of 15s. That // starting takes longer than fx's default start timeout of 15s. That
// limit catches a start that hangs, not a busy host: measured with make // limit catches a start that hangs, not a busy host: measured with make
@@ -231,6 +266,12 @@ func newTestAppWithConfig(
func(r *recordingArchives) delivery.Archives { func(r *recordingArchives) delivery.Archives {
return r return r
}, },
func() *testCircuitBreakers {
return &testCircuitBreakers{}
},
func(b *testCircuitBreakers) delivery.CircuitBreakers {
return b
},
metrics.NewRegistry, metrics.NewRegistry,
metrics.New, metrics.New,
middleware.New, middleware.New,
@@ -436,23 +477,37 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) { func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
t.Parallel() t.Parallel()
// Empty expiry: the keep-forever default, empty config. // Empty expiry and rotation: the keep-forever, one-file default,
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("") // empty config.
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("", "")
require.NoError(t, err) require.NoError(t, err)
assert.Empty(t, errMsg) assert.Empty(t, errMsg)
assert.Empty(t, cfg) assert.Empty(t, cfg)
// Explicit never is stored as config. // Explicit never is stored as config.
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("never") cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("never", "")
require.NoError(t, err) require.NoError(t, err)
assert.Empty(t, errMsg) assert.Empty(t, errMsg)
assert.JSONEq(t, `{"expiry":"never"}`, cfg) assert.JSONEq(t, `{"expiry":"never"}`, cfg)
// A positive duration is stored as config. // A positive duration is stored as config.
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("720h") cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("720h", "")
require.NoError(t, err) require.NoError(t, err)
assert.Empty(t, errMsg) assert.Empty(t, errMsg)
assert.JSONEq(t, `{"expiry":"720h"}`, cfg) assert.JSONEq(t, `{"expiry":"720h"}`, cfg)
// A rotation is stored as config, with or without an expiry.
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("", "daily")
require.NoError(t, err)
assert.Empty(t, errMsg)
assert.JSONEq(t, `{"rotation":"daily"}`, cfg)
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest(
"720h", "hourly",
)
require.NoError(t, err)
assert.Empty(t, errMsg)
assert.JSONEq(t, `{"expiry":"720h","rotation":"hourly"}`, cfg)
} }
func TestBuildDatabaseTargetConfig_RejectsBadExpiry( func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
@@ -461,7 +516,7 @@ func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
t.Parallel() t.Parallel()
for _, bad := range []string{"nonsense", "7d", "-5h"} { for _, bad := range []string{"nonsense", "7d", "-5h"} {
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest(bad) cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest(bad, "")
require.NoError(t, err) require.NoError(t, err)
assert.Contains( assert.Contains(
@@ -471,3 +526,20 @@ func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
assert.Empty(t, cfg) assert.Empty(t, cfg)
} }
} }
func TestBuildDatabaseTargetConfig_RejectsBadRotation(
t *testing.T,
) {
t.Parallel()
for _, bad := range []string{"weekly", "Daily", " none"} {
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("", bad)
require.NoError(t, err)
assert.Contains(
t, errMsg, "Invalid archive rotation",
"rotation %q should be refused", bad,
)
assert.Empty(t, cfg)
}
}
+3 -2
View File
@@ -66,7 +66,8 @@ func noticeFor(r *http.Request) *notice {
}, },
replayTargetDeleted: { replayTargetDeleted: {
Text: "Not replayed: the target this delivery was for " + Text: "Not replayed: the target this delivery was for " +
"has been deleted. Recreate the target, then replay.", "has been deleted. Use Resubmit to send the event " +
"to the webhook's currently active targets.",
Failed: true, Failed: true,
}, },
replayTargetMissing: { replayTargetMissing: {
@@ -95,7 +96,7 @@ func noticeFor(r *http.Request) *notice {
}, },
resubmitNoTargets: { resubmitNoTargets: {
Text: "Resubmitted: a new event was created, but this " + Text: "Resubmitted: a new event was created, but this " +
"source has no active targets, so nothing was queued.", "webhook has no active targets, so nothing was queued.",
}, },
}[noticeCode(r.URL.Query().Get(noticeParam))] }[noticeCode(r.URL.Query().Get(noticeParam))]
if !ok { if !ok {
+111
View File
@@ -0,0 +1,111 @@
package handlers_test
import (
"html/template"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/session"
"sneak.berlin/go/webhooker/templates"
)
// TestEveryPageRendersItsOwnTitle renders each page template and checks
// the browser tab title is the one the page declares, not the
// "Webhooker" fallback in htmlheader.html. A page that fails to render
// shows the error page's title instead, and fails here too.
func TestEveryPageRendersItsOwnTitle(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
var sess *session.Session
app := newTestApp(t, &h, &sess)
app.RequireStart()
t.Cleanup(app.RequireStop)
// A pointer, as in the handlers: some pages call
// Webhook.RetentionLabel, a pointer method.
webhook := &database.Webhook{Name: "orders", RetentionDays: 14}
webhook.ID = testWebhookID
pages := []struct {
page string
data map[string]any
title string
}{
{"login.html", map[string]any{}, "Sign in - Webhooker"},
{"profile.html", map[string]any{}, "Profile - Webhooker"},
{"settings.html", map[string]any{}, "Settings - Webhooker"},
{"sources_list.html", map[string]any{}, "Webhooks - Webhooker"},
{"sources_new.html", map[string]any{}, "New Webhook - Webhooker"},
{
"source_detail.html",
map[string]any{dataKeyWebhook: webhook},
"orders - Webhooker",
},
{
"source_edit.html",
map[string]any{dataKeyWebhook: webhook},
"Edit orders - Webhooker",
},
{
"source_logs.html",
map[string]any{
dataKeyWebhook: webhook,
dataKeyEvents: []handlers.EventLogView{},
"TotalEvents": int64(0),
},
"Full Event Log - orders - Webhooker",
},
{
"event_detail.html",
map[string]any{dataKeyWebhook: webhook},
"Event - orders - Webhooker",
},
{
"target_edit.html",
map[string]any{
dataKeyWebhook: webhook,
"Target": map[string]any{"Name": "alerts", "Type": "slack"},
},
"Edit alerts - Webhooker",
},
{
"error.html",
map[string]any{"StatusText": http.StatusText(http.StatusNotFound)},
"Not Found - Webhooker",
},
}
for _, p := range pages {
body := renderPage(t, h, sess, p.page, p.data)
_, afterOpen, _ := strings.Cut(body, "<title>")
title, _, _ := strings.Cut(afterOpen, "</title>")
assert.Equal(t, p.title, title, p.page)
}
}
// TestTitleFallbackIsWebhooker checks the title htmlheader.html gives a
// page that declares none. Every page declares one, so it is checked on
// htmlheader.html alone.
func TestTitleFallbackIsWebhooker(t *testing.T) {
t.Parallel()
header := template.Must(
template.ParseFS(templates.Templates, "htmlheader.html"),
)
var buf strings.Builder
require.NoError(t, header.ExecuteTemplate(&buf, "htmlheader", nil))
assert.Contains(t, buf.String(), "<title>Webhooker</title>")
}
+230
View File
@@ -0,0 +1,230 @@
package handlers
import (
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi"
"sneak.berlin/go/webhooker/internal/database"
)
// parseRetentionDays interprets a retention_days form value. It
// returns the number of days, or, for a value it refuses, the message
// the create and edit forms show; the message is empty when the value
// is accepted.
//
// An empty value yields fallback, which lets the create path apply the
// default and the edit path leave the stored value unchanged. A value
// of 0 is returned as 0 and is rewritten to the retain-forever
// sentinel by database.Webhook's BeforeSave hook. Anything unparseable
// or negative is refused rather than silently given a default.
//
// The upper bound is not cosmetic. The reaper computes its cutoff as a
// time.Duration, an int64 nanosecond count, so a day count above
// database.MaxFiniteRetentionDays overflows, puts the cutoff in the
// future, and deletes every event the webhook has. A finite value
// above that ceiling is therefore refused, and the message names the
// ceiling rather than implying the input was not a number.
//
// A value at or above the retain-forever sentinel is not out of range:
// it is what the edit form pre-fills for a retain-forever webhook, so
// submitting the form back unchanged has to keep meaning "forever"
// rather than being rejected.
func parseRetentionDays(raw string, fallback int) (int, string) {
raw = strings.TrimSpace(raw)
if raw == "" {
return fallback, ""
}
v, err := strconv.Atoi(raw)
if err != nil || v < 0 {
return 0, "Retention must be a whole number of days, or 0 to " +
"retain events forever."
}
if v >= database.RetentionForeverDays {
return database.RetentionForeverDays, ""
}
if v > database.MaxFiniteRetentionDays {
return 0, "Retention must be at most " +
strconv.Itoa(database.MaxFiniteRetentionDays) +
" days, or 0 to retain events forever."
}
return v, ""
}
// ownedWebhook resolves the request's sourceID parameter to a
// webhook the session's user owns.
//
// Ownership and existence are decided by one query, so a
// webhook belonging to another user is indistinguishable from
// one that does not exist: both are a 404, and neither confirms
// the id. Callers that reach further into a webhook's data —
// the event log page and the event body download — share this
// one check rather than restating it, so the download cannot
// come to authorize differently from the page that links to it.
//
// It reports false once it has written the response, which is a
// redirect to the login page for an unauthenticated request and
// a 404 otherwise. The caller returns without writing more.
func (h *Handlers) ownedWebhook(
w http.ResponseWriter,
r *http.Request,
) (database.Webhook, bool) {
var webhook database.Webhook
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return database.Webhook{}, false
}
sourceID := chi.URLParam(r, "sourceID")
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
return database.Webhook{}, false
}
return webhook, true
}
// deleteChildResource returns a handler that deletes a child
// resource (entrypoint or target) belonging to a webhook. The
// optional afterDelete hook runs with the child's id once the
// delete has removed it, before the redirect, which carries done as
// its notice.
func (h *Handlers) deleteChildResource(
idParam string,
model any,
errMsg string,
afterDelete func(childID string),
done noticeCode,
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
sourceID := chi.URLParam(r, "sourceID")
childID := chi.URLParam(r, idParam)
var webhook database.Webhook
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
return
}
result := h.db.DB().Where(
"id = ? AND webhook_id = ?",
childID, webhook.ID,
).Delete(model)
if result.Error != nil {
h.serverError(w, r, errMsg, result.Error)
return
}
// Only for a row this webhook really had: the id came from
// the URL and may name another webhook's child.
if afterDelete != nil && result.RowsAffected > 0 {
afterDelete(childID)
}
http.Redirect(
w, r,
withNotice("/hook/"+webhook.ID, done),
http.StatusSeeOther,
)
}
}
// toggleChildResource returns a handler that toggles the active
// state of a child resource belonging to a webhook. toggleFn returns
// the new state, and the redirect carries activated or deactivated as
// its notice to match.
func (h *Handlers) toggleChildResource(
idParam string,
toggleFn func(webhookID, childID string) (bool, error),
errMsg string,
activated, deactivated noticeCode,
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
sourceID := chi.URLParam(r, "sourceID")
childID := chi.URLParam(r, idParam)
var webhook database.Webhook
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
return
}
active, err := toggleFn(webhook.ID, childID)
if err != nil {
h.serverError(w, r, errMsg, err)
return
}
done := deactivated
if active {
done = activated
}
http.Redirect(
w, r,
withNotice("/hook/"+webhook.ID, done),
http.StatusSeeOther,
)
}
}
// getUserID extracts the user ID from the session.
func (h *Handlers) getUserID(
r *http.Request,
) (string, bool) {
sess, err := h.session.Get(r)
if err != nil {
return "", false
}
if !h.session.IsAuthenticated(sess) {
return "", false
}
return h.session.GetUserID(sess)
}
@@ -0,0 +1,209 @@
package handlers_test
import (
"errors"
"html"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
// submitCreateForm posts the new webhook form and returns the
// recorder.
func submitCreateForm(
env *sourceTestEnv, form url.Values,
) *httptest.ResponseRecorder {
req := formRequest("/hooks/new", env.cookies, form, nil)
w := httptest.NewRecorder()
env.handlers.HandleSourceCreateSubmit().ServeHTTP(w, req)
return w
}
// assertNothingCreated checks that the main database holds no webhook,
// entrypoint or target.
func assertNothingCreated(t *testing.T, db *database.Database) {
t.Helper()
for _, model := range []any{
&database.Webhook{}, &database.Entrypoint{}, &database.Target{},
} {
var count int64
require.NoError(t, db.DB().Model(model).Count(&count).Error)
assert.Zerof(t, count, "%T rows were created", model)
}
}
// TestHandleSourceCreateSubmit_CreatesRequestedTargets submits the new
// webhook form with the HTTP target URL filled in or empty, and with
// the archive checkbox off or on with each pruning choice. The webhook
// gets an HTTP target only for a URL and a database target only for a
// checked archive. The pruning choice is always submitted, as the
// browser submits it while it is hidden, and is ignored when archive
// is off.
func TestHandleSourceCreateSubmit_CreatesRequestedTargets(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
// Each value the archive pruning choice submits, after an empty
// one that stands for the archive checkbox left off.
expiries := []string{
"", "never", "1h", "12h", "24h", "720h", "2160h", "8760h",
}
for _, httpURL := range []string{"", editOriginalURL} {
for _, expiry := range expiries {
name := "url=" + httpURL + " archive=" + expiry
t.Run(name, func(t *testing.T) {
t.Parallel()
form := url.Values{}
form.Set("name", name)
form.Set("http_url", httpURL)
form.Set("archive_expiry", "720h")
if expiry != "" {
form.Set("archive", "on")
form.Set("archive_expiry", expiry)
}
w := submitCreateForm(env, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
var webhook database.Webhook
require.NoError(t, env.db.DB().
Where("name = ?", name).First(&webhook).Error)
byType := map[database.TargetType]database.Target{}
for _, target := range targetsForWebhook(t, env.db, webhook.ID) {
byType[target.Type] = target
}
wantCount := 0
if httpURL != "" {
wantCount++
assert.Equal(t, "HTTP", byType[database.TargetTypeHTTP].Name)
assert.JSONEq(t, `{"url":"`+httpURL+`"}`,
byType[database.TargetTypeHTTP].Config)
}
if expiry != "" {
wantCount++
assert.Equal(t, "Archive",
byType[database.TargetTypeDatabase].Name)
assert.JSONEq(t, `{"expiry":"`+expiry+`"}`,
byType[database.TargetTypeDatabase].Config)
}
assert.Len(t, byType, wantCount)
})
}
}
}
// TestHandleSourceCreateSubmit_RefusedFormKeepsEveryValue refuses the
// new webhook form for an invalid HTTP target URL and for an invalid
// retention, each with archive on. Nothing is created, and the form
// comes back with the reason and every value entered: name,
// description, retention, URL, the checked archive box and the pruning
// and rotation choices.
func TestHandleSourceCreateSubmit_RefusedFormKeepsEveryValue(
t *testing.T,
) {
t.Parallel()
const badURL = "Invalid target URL"
cases := []struct {
name string
retention string
httpURL string
reason string
}{
{"blocked url", "7", editBlockedURL, badURL},
{"unsupported scheme", "7", "ftp://93.184.216.34/hook", badURL},
{"bad retention", "-5", editOriginalURL, "Retention must be"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
form := url.Values{}
form.Set("name", "kept name")
form.Set("description", "kept description")
form.Set("retention_days", tc.retention)
form.Set("http_url", tc.httpURL)
form.Set("archive", "on")
form.Set("archive_expiry", "2160h")
form.Set("archive_rotation", "hourly")
w := submitCreateForm(env, form)
require.Equal(t, http.StatusBadRequest, w.Code)
page := w.Body.String()
assert.Contains(t, page, tc.reason)
assert.Contains(t, page, `value="kept name"`)
assert.Contains(t, page, `>kept description</textarea>`)
assert.Contains(t, page, `value="`+tc.retention+`"`)
assert.Contains(t, page,
`value="`+html.EscapeString(tc.httpURL)+`"`)
assert.Contains(t, page, `name="archive" value="on" checked`)
assert.Contains(t, page, `x-data="collapsible" data-open`)
assert.Contains(t, page, `<option value="2160h" selected>`)
assert.Contains(t, page, `<option value="hourly" selected>`)
assertNothingCreated(t, env.db)
})
}
}
// errInjectedTargetCreate is the failure a test makes the insert of a
// target report.
var errInjectedTargetCreate = errors.New("injected target create failure")
// TestHandleSourceCreateSubmit_FailedTargetInsertCreatesNothing makes
// inserting a target fail after the webhook and its entrypoint were
// inserted, and checks that neither is left behind.
func TestHandleSourceCreateSubmit_FailedTargetInsertCreatesNothing(
t *testing.T,
) {
t.Parallel()
env := setupSourceTest(t)
require.NoError(t, env.db.DB().Callback().Create().
Before("gorm:create").
Register("test:fail_target_create", func(tx *gorm.DB) {
if tx.Statement.Table == "targets" {
_ = tx.AddError(errInjectedTargetCreate)
}
}),
)
form := url.Values{}
form.Set("name", "rolled back")
form.Set("archive", "on")
form.Set("archive_expiry", "never")
w := submitCreateForm(env, form)
require.Equal(t, http.StatusInternalServerError, w.Code)
assertNothingCreated(t, env.db)
}
+103 -2
View File
@@ -233,8 +233,13 @@ func TestHandleSourceDetail_RendersNamedTargetFields(
assert.Contains(t, body, "1 configured") assert.Contains(t, body, "1 configured")
assert.NotContains(t, body, "sekrit") assert.NotContains(t, body, "sekrit")
assert.Contains(t, body, "Archive Expiry") // The database type is called an archive: on its badge, in the
assert.Contains(t, body, "720h") // add target form's type list and in its settings.
list := targetList(t, body)
assert.Contains(t, list, "t-database archive Active")
assert.Contains(t, list, "Archive expiry: 30 days")
assert.Contains(t, list, "Archive rotation: none")
assert.Contains(t, body, `<option value="database">Archive</option>`)
// An unknown type gets the neutral placeholder, never the // An unknown type gets the neutral placeholder, never the
// stored blob. // stored blob.
@@ -275,3 +280,99 @@ func TestHandleSourceDetail_FitsWideAndNarrowWindows(t *testing.T) {
`<div class="flex flex-wrap justify-between items-center gap-2 mt-2">`, `<div class="flex flex-wrap justify-between items-center gap-2 mt-2">`,
) )
} }
// TestHandleSourceDetail_DeletePromptsNameWhatIsLost checks that each
// delete prompt on the webhook page names the webhook, entrypoint or
// target and says what deleting it loses, that the webhook's gives its
// number of stored events (5 received, 2 removed by retention, so 3,
// the statistics pane's "Within retention" figure), and that an
// entrypoint with no description is named by its URL. The template
// writes the slashes after http: as \/, which the browser reads as /.
func TestHandleSourceDetail_DeletePromptsNameWhatIsLost(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &dbMgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
webhookDB, err := dbMgr.GetDB(wh.ID)
require.NoError(t, err)
require.NoError(t, database.AddEventTotals(
webhookDB, database.EventTotals{Events: 5, EventsRemoved: 2},
))
unnamed := seedEntrypoint(t, db, wh.ID)
require.NoError(t, db.DB().Omit(clause.Associations).Create(
&database.Entrypoint{
WebhookID: wh.ID,
Path: "described-" + wh.ID,
Description: "Stripe",
Active: true,
},
).Error)
seedTarget(t, db, wh.ID, database.TargetTypeLog)
body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.Contains(t, body,
`Delete webhook &quot;delete-me&quot;?\n\n`+
`This deletes its stored events (3) and their deliveries. `+
`Any archive files it wrote are kept.`)
assert.Contains(t, body,
`Delete entrypoint &quot;Stripe&quot;?\n\n`+
`Senders using its URL get an error from now on, `+
`and the URL cannot be restored.`)
assert.Contains(t, body,
`Delete entrypoint &quot;http:\/\/example.com/h/`+
unnamed.Path+`&quot;?`)
assert.Contains(t, body,
`Delete target &quot;t-log&quot;?\n\n`+
`Nothing more is delivered to it. `+
`Its past deliveries stay in the event log.`)
}
// TestHandleSourceDetail_DeletePromptKeepsQuotesInName checks that a
// webhook name with quotes, a backslash, a closing script tag and a
// newline reaches its delete prompt escaped for the script, which the
// browser reads back as the name typed: each quote and angle bracket
// as a \u escape, the slash as \/, the newline as \n and the backslash
// doubled. An unescaped newline would break the prompt's script, and
// the form would then submit without asking.
func TestHandleSourceDetail_DeletePromptKeepsQuotesInName(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
)
app := newTestApp(t, &h, &sess, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := &database.Webhook{
UserID: deleteTestUserID,
Name: "Bob's \"best\" \\ hook</script>\nline two",
}
require.NoError(
t, db.DB().Omit(clause.Associations).Create(wh).Error,
)
body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.Contains(t, body,
"Delete webhook &quot;Bob\\u0027s \\u0022best\\u0022 \\\\ hook"+
"\\u003c\\/script\\u003e\\nline two&quot;?")
}
@@ -94,6 +94,44 @@ func TestHandleSourceLogs_NamesDeletedTarget(t *testing.T) {
) )
} }
// TestHandleSourceLogs_OffersNoReplayForDeletedTarget proves a
// finished delivery offers Replay while its target lives and not
// once the target is deleted. A replay to a deleted target is always
// refused, and recreating the target makes a new one that the old
// delivery does not name.
func TestHandleSourceLogs_OffersNoReplayForDeletedTarget(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &dbMgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
tgt := seedTarget(t, db, wh.ID, database.TargetTypeLog)
_, failed := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
replayForm := `action="/hook/` + wh.ID + `/deliveries/` +
failed.ID + `/replay"`
before := renderSourceLogsPage(t, h, sess, wh.ID)
assert.Contains(t, before, replayForm)
deleteTargetThroughHandler(t, h, sess, wh.ID, tgt.ID)
after := renderSourceLogsPage(t, h, sess, wh.ID)
assert.NotContains(t, after, replayForm)
assert.NotContains(t, after, ">Replay<")
assert.Contains(t, after, tgt.Name+deletedMarker)
}
// TestHandleSourceLogs_MasksDeletedTargetConfig proves that // TestHandleSourceLogs_MasksDeletedTargetConfig proves that
// naming a deleted target does not widen what the page shows of // naming a deleted target does not widen what the page shows of
// it: its stored configuration stays masked by exactly the rules // it: its stored configuration stays masked by exactly the rules
+202
View File
@@ -2,9 +2,13 @@ package handlers_test
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"slices"
"strings"
"testing" "testing"
"time"
"github.com/go-chi/chi" "github.com/go-chi/chi"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -153,3 +157,201 @@ func TestHandleSourceLogs_MasksSlackWebhookURL(t *testing.T) {
assert.Contains(t, body, tgt.Name) assert.Contains(t, body, tgt.Name)
assert.Contains(t, body, "delivered") assert.Contains(t, body, "delivered")
} }
// TestHandleSourceLogs_ShowsFiftyNewestEvents proves the event log
// holds the 50 newest events, newest first, and not one more, and says
// how many events there are in all.
func TestHandleSourceLogs_ShowsFiftyNewestEvents(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
base := time.Now().Add(-time.Hour)
for i := range 51 {
f.event(
t, fmt.Sprintf("application/x-log-%02d", i), "{}",
base.Add(time.Duration(i)*time.Second),
)
}
body := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
assert.Equal(t, 50, strings.Count(body, `role="button"`))
assert.NotContains(t, body, "application/x-log-00")
assert.Contains(t, body, "application/x-log-01")
assert.Less(
t,
strings.Index(body, "application/x-log-50"),
strings.Index(body, "application/x-log-49"),
)
assert.Contains(t, body, "50 most recent of 51 events")
}
// TestHandleSourceLogs_ShowsEventsByDeliveryStatus proves that the
// Failed list holds exactly the events with a failed delivery, the
// Pending list exactly those with a delivery pending or retrying, each
// once, and any other show value every event; that each link, and the
// line beside the heading, counts the events its list holds; and that
// the shown link is marked.
func TestHandleSourceLogs_ShowsEventsByDeliveryStatus(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
now := time.Now()
const (
failed = database.DeliveryStatusFailed
delivered = database.DeliveryStatusDelivered
pending = database.DeliveryStatusPending
retrying = database.DeliveryStatusRetrying
)
// Each event is named by its content type. The first failed and
// was then replayed and delivered. The second failed, and so did
// its replay, and the fifth has one delivery pending and another
// retrying: each must still be listed and counted once.
events := []struct {
contentType string
deliveries []database.DeliveryStatus
}{
{"application/x-failed", []database.DeliveryStatus{failed, delivered}},
{"application/x-failed-twice", []database.DeliveryStatus{failed, failed}},
{"application/x-pending", []database.DeliveryStatus{pending}},
{"application/x-retrying", []database.DeliveryStatus{retrying}},
{"application/x-pending-retrying", []database.DeliveryStatus{pending, retrying}},
{"application/x-delivered", []database.DeliveryStatus{delivered}},
{"application/x-no-delivery", nil},
}
all := make([]string, len(events))
for i, e := range events {
event := f.event(
t, e.contentType, "{}", now.Add(time.Duration(i)*time.Second),
)
for _, status := range e.deliveries {
f.delivery(t, event, target.ID, status)
}
all[i] = e.contentType
}
for _, tc := range []struct {
query string
current string
heading string
listed []string
}{
{"", "All", "7 total events", all},
{"?show=failed", "Failed (2)", "2 events with a failed delivery",
[]string{"application/x-failed", "application/x-failed-twice"}},
{"?show=pending", "Pending (3)",
"3 events with a delivery pending or retrying", []string{
"application/x-pending", "application/x-retrying",
"application/x-pending-retrying",
}},
{"?show=unknown", "All", "7 total events", all},
} {
body := renderSourceLogsPageWithQuery(
t, f.h, f.sess, f.webhook.ID, tc.query,
)
// One row per listed event, so with each listed event shown
// no event is listed twice.
assert.Equal(t, len(tc.listed),
strings.Count(body, `role="button"`), tc.query)
for _, contentType := range all {
assert.Equal(t,
slices.Contains(tc.listed, contentType),
strings.Contains(body, ">"+contentType+"<"),
tc.query+" "+contentType)
}
assert.Contains(t, body, ">"+tc.heading+"<", tc.query)
assert.Contains(t, body, "Failed (2)", tc.query)
assert.Contains(t, body, "Pending (3)", tc.query)
assert.Equal(t, 1, strings.Count(body, "aria-current"), tc.query)
assert.Contains(t, body,
`aria-current="page">`+tc.current+"</a>", tc.query)
}
}
// TestHandleSourceLogs_FilteredListShowsFiftyNewest proves a filtered
// list holds the 50 newest matching events, as the full log does,
// while its link and heading count every matching event.
func TestHandleSourceLogs_FilteredListShowsFiftyNewest(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
base := time.Now().Add(-time.Hour)
for i := range 51 {
event := f.event(
t, fmt.Sprintf("application/x-failed-%02d", i), "{}",
base.Add(time.Duration(i)*time.Second),
)
f.delivery(t, event, target.ID, database.DeliveryStatusFailed)
}
// The newest event has no failed delivery.
f.event(t, "application/x-no-delivery", "{}", time.Now())
body := renderSourceLogsPageWithQuery(
t, f.h, f.sess, f.webhook.ID, "?show=failed",
)
assert.Equal(t, 50, strings.Count(body, `role="button"`))
assert.NotContains(t, body, "application/x-failed-00")
assert.NotContains(t, body, "application/x-no-delivery")
assert.Contains(t, body, "Failed (51)")
assert.Contains(t, body,
"50 most recent of 51 events with a failed delivery")
}
// TestHandleSourceLogs_EmptyFilteredList proves an empty filtered list
// says that no event matches rather than that none was recorded.
func TestHandleSourceLogs_EmptyFilteredList(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
f.delivery(
t, f.event(t, contentTypeJSON, "{}", time.Now()),
target.ID, database.DeliveryStatusDelivered,
)
assert.Contains(t, renderSourceLogsPageWithQuery(
t, f.h, f.sess, f.webhook.ID, "?show=failed",
), "No event has a failed delivery.")
assert.Contains(t, renderSourceLogsPageWithQuery(
t, f.h, f.sess, f.webhook.ID, "?show=pending",
), "No event has a delivery pending or retrying.")
}
// TestHandleSourceLogs_OnlyNewestStartsExpanded proves that of the
// events in the log only the newest starts expanded.
func TestHandleSourceLogs_OnlyNewestStartsExpanded(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
now := time.Now()
f.event(t, "application/x-older", "{}", now.Add(-time.Minute))
f.event(t, "application/x-newer", "{}", now)
body := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
assert.Equal(t, 1, strings.Count(body, " data-open>"))
open := strings.Index(body, " data-open>")
newer := strings.Index(body, "application/x-newer")
older := strings.Index(body, "application/x-older")
assert.Less(t, open, newer, "the newest event is not the open one")
assert.Less(t, newer, older)
}
File diff suppressed because it is too large Load Diff
@@ -509,6 +509,51 @@ func TestHandleSourceEditSubmit_InvalidRetentionIsRejected(
) )
} }
// TestHandleSourceEditSubmit_RefusedFormComesBack refuses an edit for
// each reason the form can give and checks that the form comes back
// with the reason and the name, description and retention submitted,
// that the page still reports the stored retention, and that nothing
// is saved.
func TestHandleSourceEditSubmit_RefusedFormComesBack(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
refused := func(name, retention, reason string) {
t.Helper()
wh := seedWebhookWithRetention(t, env.db, 30)
submitted := wh
submitted.Name = name
submitted.Description = "a description worth keeping"
w := submitEdit(t, env, submitted, retention)
assert.Equal(t, http.StatusBadRequest, w.Code)
page := w.Body.String()
assert.Contains(t, page, `class="alert-error">`+reason)
assert.Contains(t, page, `name="name" value="`+name+`"`)
assert.Contains(t, page, ">a description worth keeping</textarea>")
assert.Contains(
t, page, `name="retention_days" value="`+retention+`"`,
)
assert.Contains(t, page, "Currently 30 days.")
var stored database.Webhook
require.NoError(
t, env.db.DB().First(&stored, "id = ?", wh.ID).Error,
)
assert.Equal(t, wh.Name, stored.Name)
assert.Empty(t, stored.Description)
assert.Equal(t, 30, stored.RetentionDays)
}
refused("", "45", "Name is required")
refused("kept-name", "nonsense", "Retention must be")
}
func TestHandleSourceEditSubmit_EmptyRetentionLeavesValueUnchanged( func TestHandleSourceEditSubmit_EmptyRetentionLeavesValueUnchanged(
t *testing.T, t *testing.T,
) { ) {
@@ -809,6 +854,10 @@ func TestHandleSourceEditSubmit_ArchiveNameTaken(t *testing.T) {
w := submitEdit(t, env, wh, "") w := submitEdit(t, env, wh, "")
require.Equal(t, http.StatusConflict, w.Code) require.Equal(t, http.StatusConflict, w.Code)
assert.Contains(t, w.Body.String(), "archive-taken.db") assert.Contains(t, w.Body.String(), "archive-taken.db")
assert.Contains(
t, w.Body.String(), `name="name" value="`+renamedWebhookName+`"`,
"the form comes back with the name submitted",
)
var stored database.Webhook var stored database.Webhook
+384
View File
@@ -0,0 +1,384 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"github.com/go-chi/chi"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
// HandleTargetCreate handles adding a new target to a webhook.
func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
sourceID := chi.URLParam(r, "sourceID")
h.renameMu.Lock()
defer h.renameMu.Unlock()
var webhook database.Webhook
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
return
}
// The body size cap is enforced by the MaxBodySize
// middleware, which runs before CSRF parses the form.
err = r.ParseForm()
if err != nil {
h.renderError(w, r, http.StatusBadRequest)
return
}
h.processTargetCreate(w, r, webhook)
}
}
// processTargetCreate validates and creates a new target. A refused
// submission shows the webhook page again, with the add target form
// open on the chosen type, the values entered, and the reason.
func (h *Handlers) processTargetCreate(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
) {
in := targetFormInputFrom(r)
target, errMsg, err := h.newTarget(r.Context(), webhook.ID, in)
if err != nil {
h.serverError(w, r, "failed to encode target config", err)
return
}
if errMsg != "" {
h.renderSourceDetail(w, r, webhook, in, errMsg)
return
}
err = h.db.DB().Create(target).Error
if err != nil {
h.serverError(w, r, "failed to create target", err)
return
}
http.Redirect(
w, r, withNotice("/hook/"+webhook.ID, targetAdded),
http.StatusSeeOther,
)
}
// newTarget validates a new target for a webhook and returns the row
// to create, or, when it refuses the target, the message the form
// shows. An error is the server's fault, not a refusal: the accepted
// configuration could not be encoded. Every form that creates a
// target goes through here, so they all accept and refuse the same
// things.
func (h *Handlers) newTarget(
ctx context.Context,
webhookID string,
in targetFormInput,
) (*database.Target, string, error) {
target := &database.Target{
WebhookID: webhookID,
Type: in.Type,
Active: true,
}
errMsg, err := h.setTargetFromForm(ctx, target, in)
if err != nil || errMsg != "" {
return nil, errMsg, err
}
return target, "", nil
}
// setTargetFromForm validates a target form against the target's type
// and, when it accepts it, sets the target's name, configuration and
// retry count from it. It returns the message the form shows for
// anything it refuses, an unknown type among them, and then leaves the
// target unchanged; an error is the server's fault, as for newTarget.
// The add target form and the target edit form both go through here,
// so the two cannot come to disagree about what a target may be.
func (h *Handlers) setTargetFromForm(
ctx context.Context,
target *database.Target,
in targetFormInput,
) (string, error) {
if in.Name == "" {
return "Name is required", nil
}
configJSON, errMsg, err := h.buildTargetConfig(ctx, target.Type, in)
if err != nil || errMsg != "" {
return errMsg, err
}
// An empty max_retries keeps the target's count: the
// fire-and-forget default of 0 for a new target, and the stored
// count for an edited one, since the forms for target types that
// do not retry have no such field. A value that is filled in but
// invalid is refused rather than becoming that count, so a typo
// cannot destroy the count a target is delivering with.
maxRetries, err := parseMaxRetries(in.MaxRetries, target.MaxRetries)
if err != nil {
return "Invalid delivery attempts: " + retriesErrorMessage(err), nil
}
target.Name = in.Name
target.Config = configJSON
target.MaxRetries = maxRetries
return "", nil
}
// targetFormInput carries the raw values of a target form. Both the
// create and the edit path fill one and hand it to setTargetFromForm,
// so neither can come to validate a target differently from the
// other. Both forms are filled from one: the edit form with the
// stored values, and a refused form with the values submitted.
type targetFormInput struct {
// Name is the target's name.
Name string
// Type is the type chosen on the add target form. The edit form
// has none: a target's stored type decides.
Type database.TargetType
// URL is the destination for an HTTP target and the webhook URL
// for a Slack target.
URL string
// Headers is an HTTP target's headers, one "Name: value" per
// line.
Headers string
// Timeout is an HTTP target's per-request timeout in seconds.
Timeout string
// MaxRetries is an HTTP or Slack target's max_retries.
MaxRetries string
// Expiry is a database (archive) target's row expiry.
Expiry string
// Rotation is a database (archive) target's rotation.
Rotation string
}
// targetFormInputFrom reads a target form from a request body. The
// body size cap is enforced by the MaxBodySize middleware, which runs
// before CSRF parses the form.
//
// Every field is read with PostFormValue, not FormValue. FormValue
// falls back to the query string, which would let
// `POST /hook/{id}/targets?url=https://hooks.slack.com/...`
// configure a target from a value the request line carries — and the
// request line, unlike the body, is what logs, proxies, Referer
// headers and error trackers record. The headers field is under the
// same rule and for the same reason: its values are authorization
// tokens.
func targetFormInputFrom(r *http.Request) targetFormInput {
return targetFormInput{
Name: r.PostFormValue("name"),
Type: database.TargetType(r.PostFormValue("type")),
URL: r.PostFormValue("url"),
Headers: r.PostFormValue("headers"),
Timeout: r.PostFormValue("timeout"),
MaxRetries: r.PostFormValue("max_retries"),
Expiry: r.PostFormValue("expiry"),
Rotation: r.PostFormValue("rotation"),
}
}
// buildTargetConfig builds the JSON config string for a target from
// the submitted form values, or returns the message the form shows
// for a value it refuses. An error is the server's fault, not a
// refusal: the accepted configuration could not be encoded. Which
// fields of in apply depends on the target type; a type without a URL
// ignores any URL submitted.
func (h *Handlers) buildTargetConfig(
ctx context.Context,
targetType database.TargetType,
in targetFormInput,
) (string, string, error) {
switch targetType {
case database.TargetTypeHTTP:
return h.buildHTTPTargetConfig(ctx, in)
case database.TargetTypeSlack:
return h.buildSlackTargetConfig(ctx, in.URL)
case database.TargetTypeDatabase:
return buildDatabaseTargetConfig(in.Expiry, in.Rotation)
case database.TargetTypeLog:
return "", "", nil
default:
return "", "Invalid target type", nil
}
}
// buildHTTPTargetConfig builds config JSON for an HTTP target: an
// SSRF-validated destination plus the optional headers and timeout
// the delivery path honours.
func (h *Handlers) buildHTTPTargetConfig(
ctx context.Context,
in targetFormInput,
) (string, string, error) {
errMsg := h.validateTargetURL(
ctx, in.URL, "URL is required for HTTP targets",
)
if errMsg != "" {
return "", errMsg, nil
}
headers, err := delivery.ParseTargetHeaders(in.Headers)
if err != nil {
return "", fmt.Sprintf("Invalid headers: %v", err), nil
}
timeout, err := delivery.ParseTargetTimeout(in.Timeout)
if err != nil {
return "", fmt.Sprintf("Invalid timeout: %v", err), nil
}
configJSON, err := marshalTargetConfig(delivery.HTTPTargetConfig{
URL: in.URL,
Headers: headers,
Timeout: timeout,
})
return configJSON, "", err
}
// buildSlackTargetConfig builds config JSON for a Slack target,
// whose whole configuration is one SSRF-validated webhook URL.
func (h *Handlers) buildSlackTargetConfig(
ctx context.Context,
targetURL string,
) (string, string, error) {
errMsg := h.validateTargetURL(
ctx, targetURL,
"Webhook URL is required for Slack targets",
)
if errMsg != "" {
return "", errMsg, nil
}
configJSON, err := marshalTargetConfig(delivery.SlackTargetConfig{
WebhookURL: targetURL,
})
return configJSON, "", err
}
// validateTargetURL refuses an empty or SSRF-blocked destination,
// returning the message the form shows, or "" when the destination
// is accepted. missingMsg is the message for no URL at all.
//
// It is the single point at which a user-supplied destination enters
// the SSRF guard, on create and on edit alike. An edit path that
// reached storage without passing through here would reopen the hole
// the guard closes.
func (h *Handlers) validateTargetURL(
ctx context.Context,
targetURL, missingMsg string,
) string {
if targetURL == "" {
return missingMsg
}
err := h.ssrf.ValidateTargetURL(ctx, targetURL)
if err != nil {
// The submitted URL can be a credential (a Slack
// incoming webhook URL is a bearer token), so the log
// records only its scheme and host.
h.log.Warn(
"target URL blocked by SSRF protection",
"url", delivery.MaskURL(targetURL),
"error", err,
)
msg := "Invalid target URL: " + err.Error()
// Only a private or reserved address's refusal says how
// to allow it. Other refusals never do: link-local, the
// unspecified addresses and the 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)."
}
return msg
}
return ""
}
// marshalTargetConfig serialises a target configuration for storage.
func marshalTargetConfig(cfg any) (string, error) {
configBytes, err := json.Marshal(cfg)
if err != nil {
return "", err
}
return string(configBytes), nil
}
// buildDatabaseTargetConfig builds config JSON for a database
// (archive) target. The optional expiry and rotation are validated
// here, at creation time, so a bad value is refused instead of
// failing every subsequent delivery. Each is stored only when set,
// and with neither the config is empty (the keep-forever, one-file
// default).
func buildDatabaseTargetConfig(
expiry, rotation string,
) (string, string, error) {
expiry = strings.TrimSpace(expiry)
err := delivery.ValidateArchiveExpiry(expiry)
if err != nil {
return "", fmt.Sprintf("Invalid archive expiry: %v", err), nil
}
err = delivery.ValidateArchiveRotation(rotation)
if err != nil {
return "", fmt.Sprintf("Invalid archive rotation: %v", err), nil
}
cfg := map[string]any{}
if expiry != "" {
cfg["expiry"] = expiry
}
if rotation != "" {
cfg["rotation"] = rotation
}
if len(cfg) == 0 {
return "", "", nil
}
configJSON, err := marshalTargetConfig(cfg)
return configJSON, "", err
}
+31
View File
@@ -0,0 +1,31 @@
package handlers
import (
"net/http"
"sneak.berlin/go/webhooker/internal/database"
)
// HandleTargetDelete handles deleting a target. A deleted
// database target's archive writer is evicted and its handle
// closed; the archive file is left on disk.
func (h *Handlers) HandleTargetDelete() http.HandlerFunc {
return h.deleteChildResource(
"targetID", &database.Target{},
"failed to delete target",
h.evictTargetArchiveWriter,
targetDeleted,
)
}
// evictTargetArchiveWriter is evictArchiveWriter for one deleted
// target, and leaves its archive file on disk for the same reason.
// A target that is not a database target has no writer, and
// evicting it does nothing.
func (h *Handlers) evictTargetArchiveWriter(targetID string) {
if h.archives == nil {
return
}
h.archives.EvictTarget(targetID)
}
+40 -14
View File
@@ -27,13 +27,11 @@ func (h *Handlers) HandleTargetDownload() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithoutCancel(r.Context()) ctx := context.WithoutCancel(r.Context())
webhook, target, export, ok := h.openTargetArchive(ctx, w, r) webhook, target, export, ok := h.listTargetArchive(w, r)
if !ok { if !ok {
return return
} }
defer func() { _ = export.Close() }()
now := time.Now() now := time.Now()
w.Header().Set("Content-Type", "application/gzip") w.Header().Set("Content-Type", "application/gzip")
@@ -81,16 +79,15 @@ func (d downloadWriter) Write(b []byte) (int, error) {
return d.w.Write(b) return d.w.Write(b)
} }
// openTargetArchive opens the archive of the request's database target // listTargetArchive lists the archive files of the request's database
// for export, with its reads under ctx. It reports false once it has // target for export. It reports false once it has written the response.
// written the response.
// //
// It holds renameMu, which every archive rename runs under, while it // It holds renameMu, which every archive rename runs under, while it
// reads the stored names and opens the file, so the file it opens is // reads the stored names and lists the files, so the files it lists
// the one those names give. It lets go before the export is streamed: // are the ones those names give. It lets go before the export is
// once the file is open, a rename does not affect the export. // streamed, which takes renameMu again for each file only while it
func (h *Handlers) openTargetArchive( // finds the file under the names stored then and opens it.
ctx context.Context, func (h *Handlers) listTargetArchive(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
) (database.Webhook, *database.Target, *delivery.ArchiveExport, bool) { ) (database.Webhook, *database.Target, *delivery.ArchiveExport, bool) {
@@ -108,14 +105,43 @@ func (h *Handlers) openTargetArchive(
return database.Webhook{}, nil, nil, false return database.Webhook{}, nil, nil, false
} }
export, err := delivery.OpenArchiveExport( export, err := delivery.NewArchiveExport(
ctx, delivery.ArchivePath(h.dbMgr, &webhook, target), h.log, delivery.ArchivePath(h.dbMgr, &webhook, target),
&h.renameMu,
func() (string, error) {
return h.storedArchivePath(webhook.ID, target.ID)
},
h.log,
) )
if err != nil { if err != nil {
h.serverError(w, r, "failed to open archive for export", err) h.serverError(w, r, "failed to list archive for export", err)
return database.Webhook{}, nil, nil, false return database.Webhook{}, nil, nil, false
} }
return webhook, target, export, true return webhook, target, export, true
} }
// storedArchivePath returns the path delivery.ArchivePath gives a
// database target under the names stored for it and its webhook now.
// Its caller holds renameMu. A webhook or target deleted since is still
// found, since deleting one leaves its archive files under their names.
func (h *Handlers) storedArchivePath(
webhookID, targetID string,
) (string, error) {
var webhook database.Webhook
err := h.db.DB().Unscoped().First(&webhook, "id = ?", webhookID).Error
if err != nil {
return "", err
}
var target database.Target
err = h.db.DB().Unscoped().First(&target, "id = ?", targetID).Error
if err != nil {
return "", err
}
return delivery.ArchivePath(h.dbMgr, &webhook, &target), nil
}
+83 -11
View File
@@ -13,6 +13,8 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"os"
"strings"
"sync" "sync"
"testing" "testing"
"time" "time"
@@ -85,7 +87,7 @@ func TestHandleTargetDownload(t *testing.T) {
} }
// TestHandleTargetDownload_WaitsForRename proves a download reads the // TestHandleTargetDownload_WaitsForRename proves a download reads the
// target's names and opens its archive under the lock a rename holds: // target's names and lists its archive under the lock a rename holds:
// started while an edit is renaming the archive, it waits, and is // started while an edit is renaming the archive, it waits, and is
// named for the target's new name. // named for the target's new name.
func TestHandleTargetDownload_WaitsForRename(t *testing.T) { func TestHandleTargetDownload_WaitsForRename(t *testing.T) {
@@ -148,18 +150,17 @@ func (s *stalledWriter) Write(b []byte) (int, error) {
return s.ResponseRecorder.Write(b) return s.ResponseRecorder.Write(b)
} }
// TestHandleTargetDownload_StreamsWithoutTheLock proves a download // startStalledDownload starts a download of the target and returns once
// lets go of the rename lock once its archive is open: while the // it is stalled at its first write, which comes before it opens any
// download is stalled writing, an edit can still rename the target. // archive file. Closing the writer's resume lets it go on; the returned
func TestHandleTargetDownload_StreamsWithoutTheLock(t *testing.T) { // channel is closed when it has finished.
t.Parallel() func startStalledDownload(
t *testing.T, env *sourceTestEnv, webhookID, targetID string,
env := setupSourceTest(t) ) (*stalledWriter, <-chan struct{}) {
wh := seedWebhookWithRetention(t, env.db, 7) t.Helper()
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
req := httptest.NewRequestWithContext( req := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, downloadPath(wh.ID, archive.ID), nil, t.Context(), http.MethodGet, downloadPath(webhookID, targetID), nil,
) )
for _, c := range env.cookies { for _, c := range env.cookies {
req.AddCookie(c) req.AddCookie(c)
@@ -179,6 +180,21 @@ func TestHandleTargetDownload_StreamsWithoutTheLock(t *testing.T) {
<-sw.writing <-sw.writing
return sw, downloaded
}
// TestHandleTargetDownload_StreamsWithoutTheLock proves a download
// lets go of the rename lock once it has listed its archive: while the
// download is stalled writing, an edit can still rename the target.
func TestHandleTargetDownload_StreamsWithoutTheLock(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7)
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
sw, downloaded := startStalledDownload(t, env, wh.ID, archive.ID)
edited := make(chan *httptest.ResponseRecorder, 1) edited := make(chan *httptest.ResponseRecorder, 1)
go func() { go func() {
@@ -197,6 +213,62 @@ func TestHandleTargetDownload_StreamsWithoutTheLock(t *testing.T) {
assert.Equal(t, http.StatusOK, sw.Code) assert.Equal(t, http.StatusOK, sw.Code)
} }
// TestHandleTargetDownload_FindsFilesAfterRename proves a download
// finds each of the target's files again under the names stored when
// it reaches the file: the target is renamed while the download is
// stalled before it has opened any file, and the rows of both its files
// are in the download.
func TestHandleTargetDownload_FindsFilesAfterRename(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7)
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
oldPath := delivery.ArchivePath(env.dbMgr, &wh, archive)
month := func(path string) string {
return strings.TrimSuffix(path, ".db") + "-2026-10.db"
}
seedArchive(t, oldPath, 1, 16)
seedArchive(t, month(oldPath), 1, 16)
sw, downloaded := startStalledDownload(t, env, wh.ID, archive.ID)
require.Equal(t,
http.StatusSeeOther, renameTarget(env, wh.ID, archive.ID).Code,
)
// The test's archives record a rename without moving any file, so
// the files are moved here, as the delivery engine moves them.
var renamed database.Target
require.NoError(t, env.db.DB().First(&renamed, "id = ?", archive.ID).Error)
newPath := delivery.ArchivePath(env.dbMgr, &wh, &renamed)
require.NoError(t, os.Rename(oldPath, newPath))
require.NoError(t, os.Rename(month(oldPath), month(newPath)))
close(sw.resume)
<-downloaded
require.Equal(t, http.StatusOK, sw.Code)
zr, err := gzip.NewReader(sw.Body)
require.NoError(t, err)
var (
got map[string]json.RawMessage
events []map[string]any
)
require.NoError(t, json.NewDecoder(zr).Decode(&got))
require.NoError(t, json.Unmarshal(got["archived_events"], &events))
require.Len(t, events, 2)
assert.NotContains(t, events[0], "period")
assert.Equal(t, "2026-10", events[1]["period"])
}
// seedArchive writes rows to the archive file at path, each with a // seedArchive writes rows to the archive file at path, each with a
// body of bodySize random bytes, which do not compress. Its table has // body of bodySize random bytes, which do not compress. Its table has
// only the columns the test fills; an export writes the others empty. // only the columns the test fills; an export writes the others empty.
+57 -58
View File
@@ -3,6 +3,7 @@ package handlers
import ( import (
"errors" "errors"
"net/http" "net/http"
"strconv"
"github.com/go-chi/chi" "github.com/go-chi/chi"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
@@ -13,10 +14,13 @@ import (
const targetEditTemplate = "target_edit.html" const targetEditTemplate = "target_edit.html"
// tmplKeyTarget is the template data key for the target being // tmplKeyTarget is the template data key for the target being
// edited, and tmplKeyMaxTimeout for the timeout ceiling the form // edited, tmplKeyTargetForm for the values its form shows, and
// tells the user about. // tmplKeyMaxTimeout for the timeout ceiling the form tells the user
// about. The add target form on the webhook page takes its values
// under the same key as the edit form.
const ( const (
tmplKeyTarget = "Target" tmplKeyTarget = "Target"
tmplKeyTargetForm = "TargetForm"
tmplKeyMaxTimeout = "MaxTimeout" tmplKeyMaxTimeout = "MaxTimeout"
) )
@@ -28,20 +32,19 @@ const configUnreadableMessage = "The stored configuration for this " +
"target could not be read. Enter the values below; saving " + "target could not be read. Enter the values below; saving " +
"replaces the stored configuration." "replaces the stored configuration."
// targetEditView is the display model for the target edit page. // targetEditView is the display model for the target edit page: the
// target's row fields as stored. The values the form shows, the
// UNMASKED configuration among them, come separately, as a
// targetFormInput.
// //
// It carries the target's row fields alongside its UNMASKED // It deliberately omits database.Target's raw Config blob: the form
// configuration, and deliberately omits database.Target's raw // renders named fields, and giving the template the blob as well
// Config blob: the form renders named fields, and giving the // would put an unreviewed second path to the credential on the page.
// template the blob as well would put an unreviewed second path to
// the credential on the page.
type targetEditView struct { type targetEditView struct {
ID string ID string
Name string Name string
Type database.TargetType Type database.TargetType
Active bool Active bool
MaxRetries int
Config delivery.TargetConfigForm
} }
// HandleTargetEdit shows the form to edit a target. // HandleTargetEdit shows the form to edit a target.
@@ -73,7 +76,19 @@ func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
msg = configUnreadableMessage msg = configUnreadableMessage
} }
h.renderTargetEdit(w, r, webhook, target, cfg, msg) form := targetFormInput{
Name: target.Name,
URL: cfg.URL,
Headers: cfg.Headers,
Timeout: cfg.Timeout,
MaxRetries: strconv.Itoa(target.MaxRetries),
Expiry: cfg.Expiry,
Rotation: cfg.Rotation,
}
h.renderTargetEdit(
w, r, webhook, target, form, msg, http.StatusOK,
)
} }
} }
@@ -101,11 +116,12 @@ func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc {
} }
} }
// applyTargetEdit validates and saves target edits. // applyTargetEdit validates and saves target edits. A refused save
// shows the edit form again with the values submitted and the reason.
// //
// The submitted configuration goes through buildTargetConfig, the // The submission goes through setTargetFromForm, as a new target
// same builder the create path uses, so an edited destination is // does, so an edited destination is SSRF-validated exactly as a new
// SSRF-validated exactly as a new one is. // one is.
// //
// The target's type is not editable. Each type stores a different // The target's type is not editable. Each type stores a different
// configuration shape and its delivery history is recorded against // configuration shape and its delivery history is recorded against
@@ -118,16 +134,13 @@ func (h *Handlers) applyTargetEdit(
webhook database.Webhook, webhook database.Webhook,
target *database.Target, target *database.Target,
) { ) {
name := r.PostFormValue("name") in := targetFormInputFrom(r)
if name == "" {
http.Error(w, "Name is required", http.StatusBadRequest)
return // edited is the target as the submission leaves it; target stays
} // as stored, for the page shown again when the save is refused.
edited := *target
configJSON, errMsg, err := h.buildTargetConfig( errMsg, err := h.setTargetFromForm(r.Context(), &edited, in)
r.Context(), target.Type, targetFormInputFrom(r),
)
if err != nil { if err != nil {
h.serverError(w, r, "failed to encode target config", err) h.serverError(w, r, "failed to encode target config", err)
@@ -135,45 +148,26 @@ func (h *Handlers) applyTargetEdit(
} }
if errMsg != "" { if errMsg != "" {
http.Error(w, errMsg, http.StatusBadRequest) h.renderTargetEdit(
w, r, webhook, target, in, errMsg, http.StatusBadRequest,
)
return return
} }
// Retries are offered only by the forms for target types that
// retry, so an absent field means "this form does not edit
// retries" rather than "set them to zero". Reading it
// unconditionally would silently disable retries on any target
// saved from a form that does not render the input.
//
// A field that IS submitted but does not parse is a 400, through
// the same validator the create path uses. It is rejected before
// anything is written, so a typo cannot destroy the retry count
// the target is already delivering with.
if r.PostForm.Has("max_retries") {
retries, ok := targetMaxRetries(w, r, target.MaxRetries)
if !ok {
return
}
target.MaxRetries = retries
}
oldName := target.Name
target.Name = name
target.Config = configJSON
// A new name renames the archive file before it is saved (see // A new name renames the archive file before it is saved (see
// delivery.Engine.Rename). If either step fails, it goes back to // delivery.Engine.Rename). If either step fails, it goes back to
// the name that is still stored. // the name that is still stored.
err = h.renameTargetArchive(target, webhook.Name, oldName, name) err = h.renameTargetArchive(
target, webhook.Name, target.Name, edited.Name,
)
if err == nil { if err == nil {
err = h.db.DB().Save(target).Error err = h.db.DB().Save(&edited).Error
} }
if err != nil { if err != nil {
restoreErr := h.renameTargetArchive( restoreErr := h.renameTargetArchive(
target, webhook.Name, name, oldName, target, webhook.Name, edited.Name, target.Name,
) )
if restoreErr != nil { if restoreErr != nil {
h.log.Error( h.log.Error(
@@ -184,8 +178,8 @@ func (h *Handlers) applyTargetEdit(
} }
if errors.Is(err, delivery.ErrArchiveNameTaken) { if errors.Is(err, delivery.ErrArchiveNameTaken) {
http.Error( h.renderTargetEdit(
w, w, r, webhook, target, in,
"Not saved: "+err.Error()+ "Not saved: "+err.Error()+
". Move that archive out of the data directory, "+ ". Move that archive out of the data directory, "+
"its .db together with any -wal and -shm beside "+ "its .db together with any -wal and -shm beside "+
@@ -222,15 +216,17 @@ func (h *Handlers) renameTargetArchive(
return h.archives.Rename(target.ID, webhookName, newName) return h.archives.Rename(target.ID, webhookName, newName)
} }
// renderTargetEdit renders the target edit page with an optional // renderTargetEdit renders the target edit page for the target as
// error message. // stored, its form showing form's values, with an optional error
// message above it.
func (h *Handlers) renderTargetEdit( func (h *Handlers) renderTargetEdit(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
webhook database.Webhook, webhook database.Webhook,
target *database.Target, target *database.Target,
cfg delivery.TargetConfigForm, form targetFormInput,
errMsg string, errMsg string,
status int,
) { ) {
// The template calls Webhook methods, which take pointer // The template calls Webhook methods, which take pointer
// receivers; html/template cannot address a value stored in a // receivers; html/template cannot address a value stored in a
@@ -242,14 +238,17 @@ func (h *Handlers) renderTargetEdit(
Name: target.Name, Name: target.Name,
Type: target.Type, Type: target.Type,
Active: target.Active, Active: target.Active,
MaxRetries: target.MaxRetries,
Config: cfg,
}, },
tmplKeyTargetForm: form,
tmplKeyMaxTimeout: delivery.MaxTargetTimeoutSeconds, tmplKeyMaxTimeout: delivery.MaxTargetTimeoutSeconds,
tmplKeyError: errMsg, tmplKeyError: errMsg,
tmplKeyArchiveExpiryChoices: archiveExpiryOptions(form.Expiry),
tmplKeyArchiveRotationChoices: archiveRotationOptions(
form.Rotation,
),
} }
h.renderTemplate(w, r, targetEditTemplate, data) h.renderTemplateStatus(w, r, targetEditTemplate, data, status)
} }
// ownedTarget resolves the request's sourceID and targetID // ownedTarget resolves the request's sourceID and targetID
+100
View File
@@ -418,6 +418,30 @@ func TestHandleTargetEdit_PrefillsTheStoredValuesUnmasked(
assert.Contains(t, page, "original-name") assert.Contains(t, page, "original-name")
} }
// TestHandleTargetEdit_CallsTheDatabaseTypeArchive pins the names the
// edit page of a database target gives its type and its settings to
// the ones its badge and the add target form use.
func TestHandleTargetEdit_CallsTheDatabaseTypeArchive(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30)
target := seedTarget(t, env.db, webhook.ID, database.TargetTypeDatabase)
w := serveTarget(
env, http.MethodGet,
"/hook/"+webhook.ID+"/targets/"+target.ID+"/edit",
nil,
)
require.Equal(t, http.StatusOK, w.Code)
page := w.Body.String()
assert.Contains(t, page, "Type: archive.")
assert.Contains(t, page, `class="label">Archive expiry</label>`)
assert.Contains(t, page, `class="label">Archive rotation</label>`)
}
// TestHandleTargetEditSubmit_Rejects covers every submission that // TestHandleTargetEditSubmit_Rejects covers every submission that
// must not reach storage. // must not reach storage.
// //
@@ -565,6 +589,78 @@ func assertEditRejectsTimeout(
) )
} }
// TestHandleTargetEditSubmit_RefusedFormComesBack refuses an edit of
// a target of each type and checks that the edit form comes back with
// the reason and every value submitted, and that nothing is saved.
func TestHandleTargetEditSubmit_RefusedFormComesBack(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
// fields is what the operator submitted, as a query string.
cases := []struct {
targetType database.TargetType
fields string
reason string
}{
{
database.TargetTypeHTTP,
"name=edited&url=" + editBlockedURL +
"&headers=X-Edited:+kept&timeout=12&max_retries=3",
"Invalid target URL",
},
{
database.TargetTypeSlack,
"name=edited&url=" + editOriginalURL + "&max_retries=25",
"Invalid delivery attempts",
},
{
database.TargetTypeDatabase,
"name=edited&expiry=7d&rotation=daily",
"Invalid archive expiry",
},
{database.TargetTypeLog, "name=", "Name is required"},
}
for _, tc := range cases {
t.Run(string(tc.targetType), func(t *testing.T) {
t.Parallel()
webhook := seedWebhookWithRetention(t, env.db, 30)
target := seedTarget(t, env.db, webhook.ID, tc.targetType)
form, err := url.ParseQuery(tc.fields)
require.NoError(t, err)
w := submitTargetEdit(env, webhook.ID, target.ID, form)
assert.Equal(t, http.StatusBadRequest, w.Code)
page := w.Body.String()
assert.Contains(t, page, `class="alert-error">`+tc.reason)
// headers is the form's one textarea, and expiry and
// rotation its selects; every other field is an input.
for field := range form {
shown := `name="` + field + `" value="` + form.Get(field) + `"`
switch field {
case "headers":
shown = ">" + form.Get(field) + "</textarea>"
case "expiry", "rotation":
shown = `<option value="` + form.Get(field) + `" selected>`
}
assert.Contains(t, page, shown)
}
assert.Equal(
t, target.Name, storedTarget(t, env, target.ID).Name,
"a refused edit must save nothing",
)
})
}
}
// TestHandleTargetEdit_Scoping keeps the edit routes scoped the way // TestHandleTargetEdit_Scoping keeps the edit routes scoped the way
// the delete and toggle routes are: ownership is decided by the // the delete and toggle routes are: ownership is decided by the
// webhook, and the target is then scoped to it. // webhook, and the target is then scoped to it.
@@ -700,6 +796,10 @@ func TestHandleTargetEditSubmit_RenamesArchive(t *testing.T) {
w = submitTargetEdit(env, wh.ID, archive.ID, again) w = submitTargetEdit(env, wh.ID, archive.ID, again)
require.Equal(t, http.StatusConflict, w.Code) require.Equal(t, http.StatusConflict, w.Code)
assert.Contains(t, w.Body.String(), "archive-taken.db") assert.Contains(t, w.Body.String(), "archive-taken.db")
assert.Contains(
t, w.Body.String(), `name="name" value="Again"`,
"the form comes back with the name submitted",
)
assert.Equal( assert.Equal(
t, renamedTargetName, storedTarget(t, env, archive.ID).Name, t, renamedTargetName, storedTarget(t, env, archive.ID).Name,
) )
+226 -26
View File
@@ -2,11 +2,14 @@ package handlers
import ( import (
"errors" "errors"
"fmt"
"io/fs" "io/fs"
"os"
"path/filepath" "path/filepath"
"time" "time"
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
) )
@@ -15,23 +18,122 @@ import (
type TargetRowView struct { type TargetRowView struct {
delivery.TargetView delivery.TargetView
// Archive is a database target's archive file, and nil for a target // Deliveries counts the target's delivered and failed deliveries,
// of any other type. // and is nil when the webhook's event database could not be read.
Deliveries *TargetDeliveries
// Archive is a database target's archive files, and nil for a
// target of any other type.
Archive *ArchiveFileView Archive *ArchiveFileView
// Paused is set while the target's circuit breaker is turning its
// deliveries away, and nil otherwise.
Paused *PausedView
}
// PausedView is a target's circuit breaker turning deliveries away.
// While the breaker is open, Until is a time in UTC, and Relative how
// long that is from now: on the target's row, when the cooldown ends;
// on a delivery, the earliest it can be tried next. While it is
// half-open both are empty: the cooldown has ended, and the target's
// deliveries are held while one delivery tests whether the target has
// recovered.
type PausedView struct {
Until string
Relative string
}
// pausedView reads the target's circuit breaker for its row, and
// returns nil when the breaker lets the target's deliveries through.
func (h *Handlers) pausedView(targetID string) *PausedView {
state, cooldown := h.breakers.StateAndCooldown(targetID)
switch {
case state == delivery.CircuitHalfOpen:
return &PausedView{}
case state == delivery.CircuitOpen && cooldown > 0:
return newPausedView(time.Now().Add(cooldown))
default:
return nil
}
}
// deliveryPausedView reads the circuit breaker of a retrying delivery's
// target. While it is open, it says the earliest the delivery can be
// tried next: the later of the cooldown's end and the end of the
// delivery's own backoff after its last attempt. It is only the
// earliest: when the cooldown ends, one of the target's waiting
// deliveries is sent to test it while the others wait at least one more
// cooldown. Otherwise it returns nil, half-open included, since the
// delivery may then be the one being sent to test the target.
func (h *Handlers) deliveryPausedView(
targetID string, attempts []deliveryResultRow,
) *PausedView {
state, cooldown := h.breakers.StateAndCooldown(targetID)
if state != delivery.CircuitOpen || cooldown <= 0 {
return nil
}
next := time.Now().Add(cooldown)
if len(attempts) > 0 {
last := attempts[len(attempts)-1]
backoffEnd := last.CreatedAt.Add(delivery.Backoff(last.AttemptNum))
if backoffEnd.After(next) {
next = backoffEnd
}
}
return newPausedView(next)
}
// newPausedView is a PausedView of deliveries paused until the given
// time. A time not on the current UTC day is written with its date.
func newPausedView(until time.Time) *PausedView {
until = until.UTC()
layout := time.TimeOnly
if until.Format(time.DateOnly) != time.Now().UTC().Format(time.DateOnly) {
layout = time.DateTime
}
return &PausedView{
Until: until.Format(layout) + " UTC",
Relative: humanize.Time(until),
}
}
// TargetDeliveries is how many of a target's deliveries became
// delivered and how many failed: in total, which retention does not
// reduce, and in the last 24 hours. Deliveries still pending or
// retrying count in neither.
type TargetDeliveries struct {
Delivered int64
Failed int64
DeliveredLast24Hours int64
FailedLast24Hours int64
} }
// ArchiveFileView is what a database target's row shows about its // ArchiveFileView is what a database target's row shows about its
// archive file. // archive files.
type ArchiveFileView struct { type ArchiveFileView struct {
// Name is the name of the file an event received now goes to.
Name string Name string
// Note stands in for the size and the last write when there are // Note says that file does not exist yet, or that the files could
// none to show, and is empty when there are. // not be read, and is empty otherwise.
Note string Note string
// Size is the size on disk. Written is how long ago the file was // Files counts the target's archive files, and is 0 when there are
// last written, and WrittenUTC the full time the page shows on // none, or they could not be read, and so no size or last write to
// hover. // show.
Files int
// Size is the size on disk of all the files together. Written is
// how long ago the latest of them was last written, and WrittenUTC
// the full time the page shows on hover.
Size string Size string
Written string Written string
WrittenUTC string WrittenUTC string
@@ -44,35 +146,138 @@ func (h *Handlers) targetRows(
) []TargetRowView { ) []TargetRowView {
views := delivery.NewTargetViews(targets) views := delivery.NewTargetViews(targets)
rows := make([]TargetRowView, len(views)) rows := make([]TargetRowView, len(views))
now := time.Now()
deliveries, err := h.loadTargetDeliveries(webhook.ID)
if err != nil {
h.log.Error(
"failed to read target delivery counts",
"webhook_id", webhook.ID,
"error", err,
)
}
// NewTargetViews returns one view per target, in order. // NewTargetViews returns one view per target, in order.
for i := range views { for i := range views {
rows[i].TargetView = views[i] rows[i].TargetView = views[i]
if targets[i].Type == database.TargetTypeDatabase { if err == nil {
rows[i].Archive = h.archiveFileView(webhook, &targets[i]) counts := deliveries[targets[i].ID]
rows[i].Deliveries = &counts
} }
if targets[i].Type == database.TargetTypeDatabase {
rows[i].Archive = h.archiveFileView(webhook, &targets[i], now)
}
rows[i].Paused = h.pausedView(targets[i].ID)
} }
return rows return rows
} }
// archiveFileView describes a database target's archive file from the // loadTargetDeliveries reads the delivery counts of a webhook's targets
// file's metadata alone; the archive is never opened. The file is found // from its event database, keyed by target. A target with no deliveries
// by the name the archive writer uses, so it follows a rename of the // is left out, and so is every target when the event database does not
// webhook or the target. // exist yet, since opening it would create it.
func (h *Handlers) archiveFileView( func (h *Handlers) loadTargetDeliveries(
webhook *database.Webhook, target *database.Target, webhookID string,
) *ArchiveFileView { ) (map[string]TargetDeliveries, error) {
path := delivery.ArchivePath(h.dbMgr, webhook, target) if !h.dbMgr.DBExists(webhookID) {
view := &ArchiveFileView{Name: filepath.Base(path)} return map[string]TargetDeliveries{}, nil
}
file, err := delivery.StatArchive(path) webhookDB, err := h.dbMgr.GetDB(webhookID)
if err != nil {
return nil, err
}
return readTargetDeliveries(webhookDB, time.Now())
}
// readTargetDeliveries counts each target's deliveries that became
// delivered and those that failed: in total from the targets' running
// totals, and in the 24 hours before now from the deliveries' status
// index. Each is one query for all the targets, and neither reads every
// stored delivery.
func readTargetDeliveries(
db *gorm.DB, now time.Time,
) (map[string]TargetDeliveries, error) {
var totals []database.TargetTotals
err := db.Find(&totals).Error
if err != nil {
return nil, fmt.Errorf("reading target totals: %w", err)
}
lastDay, err := finishedByTarget(db, now.Add(-longWindow))
if err != nil {
return nil, err
}
byTarget := make(map[string]TargetDeliveries, len(totals))
for _, total := range totals {
byTarget[total.TargetID] = TargetDeliveries{
Delivered: total.Delivered,
Failed: total.Failed,
}
}
for _, finished := range lastDay {
counts := byTarget[finished.TargetID]
counts.DeliveredLast24Hours = finished.Delivered
counts.FailedLast24Hours = finished.Failed
byTarget[finished.TargetID] = counts
}
return byTarget, nil
}
// archiveFileView describes a database target's archive files from
// their metadata alone; the archive is never opened. It names the file
// an event received at now goes to. The files are found by the name
// the archive writer uses, so they follow a rename of the webhook or
// the target.
func (h *Handlers) archiveFileView(
webhook *database.Webhook, target *database.Target, now time.Time,
) *ArchiveFileView {
current, err := delivery.ArchivePathAt(h.dbMgr, webhook, target, now)
if err != nil {
return h.archiveUnreadable(&ArchiveFileView{}, target, err)
}
view := &ArchiveFileView{Name: filepath.Base(current)}
_, statErr := os.Stat(current)
if errors.Is(statErr, fs.ErrNotExist) {
view.Note = "not created yet"
}
files, err := delivery.StatArchive(
delivery.ArchivePath(h.dbMgr, webhook, target),
)
switch { switch {
case errors.Is(err, fs.ErrNotExist): case errors.Is(err, fs.ErrNotExist):
view.Note = "not created yet" // No files: no size or last write to show.
case err != nil: case err != nil:
return h.archiveUnreadable(view, target, err)
default:
view.Files = files.Files
view.Size = humanize.Bytes(uint64(files.Size)) //nolint:gosec // never negative
view.Written = humanize.Time(files.Written)
view.WrittenUTC = files.Written.UTC().Format(time.DateTime) + " UTC"
}
return view
}
// archiveUnreadable logs why a database target's archive files could
// not be described, and returns view saying so.
func (h *Handlers) archiveUnreadable(
view *ArchiveFileView, target *database.Target, err error,
) *ArchiveFileView {
h.log.Error( h.log.Error(
"failed to read archive file metadata", "failed to read archive file metadata",
"target_id", target.ID, "target_id", target.ID,
@@ -80,11 +285,6 @@ func (h *Handlers) archiveFileView(
) )
view.Note = "could not be read" view.Note = "could not be read"
default:
view.Size = humanize.Bytes(uint64(file.Size)) //nolint:gosec // never negative
view.Written = humanize.Time(file.Written)
view.WrittenUTC = file.Written.UTC().Format(time.DateTime) + " UTC"
}
return view return view
} }
+107 -4
View File
@@ -3,6 +3,7 @@ package handlers_test
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -12,6 +13,7 @@ import (
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/session" "sneak.berlin/go/webhooker/internal/session"
) )
@@ -42,10 +44,10 @@ func TestHandleSourceDetail_ShowsArchiveFile(t *testing.T) {
path := delivery.ArchivePath(dbMgr, wh, archive) path := delivery.ArchivePath(dbMgr, wh, archive)
body := renderSourceDetailPage(t, h, sess, wh.ID) body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.Equal(t, 1, strings.Count(body, "Archive File:")) assert.Equal(t, 1, strings.Count(body, "Archive file:"))
assert.Contains(t, body, filepath.Base(path)) assert.Contains(t, body, filepath.Base(path))
assert.Contains(t, body, "not created yet") assert.Contains(t, body, "not created yet")
assert.NotContains(t, body, "Archive Size:") assert.NotContains(t, body, "Archive size:")
seedArchive(t, path, 1, 100) seedArchive(t, path, 1, 100)
@@ -56,7 +58,7 @@ func TestHandleSourceDetail_ShowsArchiveFile(t *testing.T) {
assert.Contains(t, body, filepath.Base(path)) assert.Contains(t, body, filepath.Base(path))
assert.NotContains(t, body, "not created yet") assert.NotContains(t, body, "not created yet")
assert.Regexp(t, assert.Regexp(t,
`Archive Size:</span>\s*<span>[1-9][0-9.]* [kM]?B</span>`, body, `Archive size:</span>\s*<span>[1-9][0-9.]* [kM]?B</span>`, body,
) )
assert.Contains(t, body, assert.Contains(t, body,
`title="`+file.ModTime().UTC().Format(time.DateTime)+` UTC"`, `title="`+file.ModTime().UTC().Format(time.DateTime)+` UTC"`,
@@ -67,5 +69,106 @@ func TestHandleSourceDetail_ShowsArchiveFile(t *testing.T) {
body = renderSourceDetailPage(t, h, sess, wh.ID) body = renderSourceDetailPage(t, h, sess, wh.ID)
assert.Contains(t, body, filepath.Base(path)) assert.Contains(t, body, filepath.Base(path))
assert.Contains(t, body, "not created yet") assert.Contains(t, body, "not created yet")
assert.NotContains(t, body, "Archive Size:") assert.NotContains(t, body, "Archive size:")
}
// targetList returns the text of the targets section in a rendered
// webhook page, from its heading to the next heading, with the markup
// taken out and each run of space made one space. Each target's row
// then reads as its name, type, state and buttons, followed by the
// lines below them.
func targetList(t *testing.T, page string) string {
t.Helper()
_, list, found := strings.Cut(page, ">Targets</h2>")
require.True(t, found, "the page has no targets section")
list, _, _ = strings.Cut(list, "<h2")
list = regexp.MustCompile(`<[^>]*>`).ReplaceAllString(list, " ")
return strings.Join(strings.Fields(list), " ")
}
// TestHandleSourceDetail_ShowsTargetDeliveries checks each target row's
// delivered and failed deliveries, in total and in the last 24 hours,
// for the history seedStatsHistory builds, before and after the real
// retention reaper removes the oldest event. The http target has one
// delivered, one of them in the last 24 hours, and three failed, one of
// them in the last 24 hours and one of them the oldest event's, which
// retention removes without changing the total. The active log target
// has two failed, both in the last 24 hours, and its pending and
// retrying deliveries count in neither. The four inactive log targets
// have none.
func TestHandleSourceDetail_ShowsTargetDeliveries(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
log *logger.Logger
)
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
app.RequireStart()
t.Cleanup(app.RequireStop)
hist := seedStatsHistory(t, h, sess, db, dbMgr)
const (
httpRow = "Delivered: 1 in total, 1 in the last 24 hours " +
"Failed: 3 in total, 1 in the last 24 hours"
activeLogRow = "t-log log Active Edit Deactivate Delete " +
"Delivered: 0 in total, 0 in the last 24 hours " +
"Failed: 2 in total, 2 in the last 24 hours"
inactiveLogRow = "t-log log Inactive Edit Activate Delete " +
"Delivered: 0 in total, 0 in the last 24 hours " +
"Failed: 0 in total, 0 in the last 24 hours"
)
list := targetList(t, renderSourceDetailPage(t, h, sess, hist.webhook.ID))
assert.Equal(t, 1, strings.Count(list, httpRow))
assert.Equal(t, 1, strings.Count(list, activeLogRow))
assert.Equal(t, 4, strings.Count(list, inactiveLogRow))
statsPrune(t, db, dbMgr, log, hist.webhookDB)
list = targetList(t, renderSourceDetailPage(t, h, sess, hist.webhook.ID))
assert.Equal(t, 1, strings.Count(list, httpRow))
assert.Equal(t, 1, strings.Count(list, activeLogRow))
assert.Equal(t, 4, strings.Count(list, inactiveLogRow))
}
// TestHandleSourceDetail_TargetDeliveriesUnreadable checks that when the
// webhook's event database cannot be read, each target's row says so
// instead of showing zeros.
func TestHandleSourceDetail_TargetDeliveriesUnreadable(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &dbMgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
seedTarget(t, db, wh.ID, database.TargetTypeLog)
webhookDB, err := dbMgr.GetDB(wh.ID)
require.NoError(t, err)
require.NoError(t,
webhookDB.Migrator().DropTable(&database.TargetTotals{}))
list := targetList(t, renderSourceDetailPage(t, h, sess, wh.ID))
assert.Contains(t, list, "t-log log Active Edit Deactivate Delete "+
"The delivery counts could not be read.")
assert.NotContains(t, list, "Delivered:")
} }
+220
View File
@@ -0,0 +1,220 @@
package handlers_test
import (
"net/http"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/session"
)
// cooldownEnds is how the pages write the end of a paused target's
// breaker's cooldown: the time in UTC, with its date when that falls on
// another UTC day, then how long that is from now.
const cooldownEnds = `(\d{4}-\d\d-\d\d )?\d\d:\d\d:\d\d UTC ` +
`\(\d+ seconds from now\)`
// TestPausedTarget_ShownUntilBreakerCloses takes an http target's
// circuit breaker from open through half-open to closed.
//
// Open, the target's row on the webhook page says its deliveries are
// paused and until when, and each retrying delivery says it is waiting
// and why in the event log and on the event's page, with the earliest
// it can be tried next: the later of the cooldown's end and the end of
// its own backoff, with the date when that is another UTC day.
// Half-open, the row says deliveries are held while one delivery tests
// the target, with no time, and no delivery says it is waiting. Closed,
// the pages say neither. The delivered delivery and the log target are
// shown as before throughout.
func TestPausedTarget_ShownUntilBreakerCloses(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
breakers *testCircuitBreakers
)
app := newTestApp(t, &h, &sess, &db, &dbMgr, &breakers)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
target := seedTarget(t, db, wh.ID, database.TargetTypeHTTP)
seedTarget(t, db, wh.ID, database.TargetTypeLog)
retrying := seedStoredEvent(t, dbMgr, wh.ID, `{"n":1}`)
addDelivery(t, dbMgr, wh.ID, retrying.ID, target.ID,
database.DeliveryStatusRetrying)
delivered := seedStoredEvent(t, dbMgr, wh.ID, `{"n":2}`)
addDelivery(t, dbMgr, wh.ID, delivered.ID, target.ID,
database.DeliveryStatusDelivered)
// This delivery's 18th attempt failed a minute ago, so its own
// backoff ends over a day from now: long after the cooldown, and on
// another UTC day, so the page shows the date.
backedOff := seedStoredEvent(t, dbMgr, wh.ID, `{"n":3}`)
backedOffID := addDelivery(t, dbMgr, wh.ID, backedOff.ID, target.ID,
database.DeliveryStatusRetrying)
failedAt := time.Now().Add(-time.Minute).Truncate(time.Second)
addFailedAttempt(t, dbMgr, wh.ID, backedOffID, 18, failedAt)
backoffEnds := failedAt.Add(delivery.Backoff(18)).UTC().
Format("2006-01-02 15:04:05") + " UTC (1 day from now)"
const waiting = "waiting: target paused after repeated failures, " +
"next try no earlier than "
breakers.Set(target.ID, delivery.CircuitOpen, 30*time.Second)
list := targetList(t, renderSourceDetailPage(t, h, sess, wh.ID))
assert.Regexp(t, "t-http http Active Edit Deactivate Delete "+
"Deliveries paused: after repeated failures, until "+cooldownEnds+
", then one waiting delivery is sent to test the target while "+
"the others wait at least one more cooldown", list)
assert.Equal(t, 1, strings.Count(list, "Deliveries paused"))
log := renderSourceLogsPage(t, h, sess, wh.ID)
assert.Equal(t, 2, strings.Count(log, "t-http: waiting"))
assert.Contains(t, log, "t-http: delivered")
assert.Regexp(t, waiting+cooldownEnds, log)
assert.Contains(t, log, waiting+backoffEnds)
// No delivery shows as retrying; the Pending link's title says it.
assert.NotRegexp(t, `t-http: retrying|>retrying<`, log)
page := eventPage(t, h, sess, wh.ID, retrying.ID)
assert.Regexp(t, waiting+cooldownEnds, page)
assert.NotContains(t, page, "retrying")
page = eventPage(t, h, sess, wh.ID, backedOff.ID)
assert.Contains(t, page, waiting+backoffEnds)
assert.NotContains(t, page, "seconds from now")
breakers.Set(target.ID, delivery.CircuitHalfOpen, 0)
list = targetList(t, renderSourceDetailPage(t, h, sess, wh.ID))
assert.Contains(t, list, "t-http http Active Edit Deactivate Delete "+
"Deliveries paused: held while one delivery tests whether the "+
"target has recovered")
// Not the whole list: the add target form above the rows says UTC.
assert.NotContains(t, targetRow(list, "t-http", "t-log"), "UTC")
assertRetryingNotWaiting(t, h, sess, wh.ID, retrying, backedOff)
breakers.Set(target.ID, delivery.CircuitClosed, 0)
list = targetList(t, renderSourceDetailPage(t, h, sess, wh.ID))
assert.NotContains(t, list, "Deliveries paused")
assertRetryingNotWaiting(t, h, sess, wh.ID, retrying, backedOff)
}
// targetRow returns the row of the target named name in a targetList:
// from its name to the name of the target listed after it, next.
func targetRow(list, name, next string) string {
_, row, _ := strings.Cut(list, name+" ")
row, _, _ = strings.Cut(row, next+" ")
return row
}
// assertRetryingNotWaiting checks that the event log and each event's
// page show the http target's delivery of the event as retrying, and
// none of them as waiting.
func assertRetryingNotWaiting(
t *testing.T,
h *handlers.Handlers,
sess *session.Session,
webhookID string,
events ...*database.Event,
) {
t.Helper()
log := renderSourceLogsPage(t, h, sess, webhookID)
assert.Equal(t, len(events), strings.Count(log, "t-http: retrying"))
assert.NotContains(t, log, "waiting")
for _, event := range events {
page := eventPage(t, h, sess, webhookID, event.ID)
assert.Contains(t, page, ">retrying</span>")
assert.NotContains(t, page, "waiting")
}
}
// addDelivery records a delivery of the event to the target, with the
// given status, in the webhook's own database, and returns its ID.
func addDelivery(
t *testing.T,
dbMgr *database.WebhookDBManager,
webhookID, eventID, targetID string,
status database.DeliveryStatus,
) string {
t.Helper()
webhookDB, err := dbMgr.GetDB(webhookID)
require.NoError(t, err)
dlv := &database.Delivery{
EventID: eventID,
TargetID: targetID,
Status: status,
}
require.NoError(t, webhookDB.Omit(clause.Associations).Create(
dlv,
).Error)
return dlv.ID
}
// addFailedAttempt records the delivery's failed attempt attemptNum,
// made at the given time.
func addFailedAttempt(
t *testing.T,
dbMgr *database.WebhookDBManager,
webhookID, deliveryID string,
attemptNum int,
at time.Time,
) {
t.Helper()
webhookDB, err := dbMgr.GetDB(webhookID)
require.NoError(t, err)
require.NoError(t, webhookDB.Omit(clause.Associations).Create(
&database.DeliveryResult{
BaseModel: database.BaseModel{CreatedAt: at},
DeliveryID: deliveryID,
AttemptNum: attemptNum,
Error: "connection refused",
},
).Error)
}
// eventPage runs the real event page handler and returns the
// rendered HTML.
func eventPage(
t *testing.T,
h *handlers.Handlers,
sess *session.Session,
webhookID, eventID string,
) string {
t.Helper()
w := serveEventPage(t, h, sess, webhookID, eventID)
require.Equal(t, http.StatusOK, w.Code)
return w.Body.String()
}
@@ -44,9 +44,9 @@ func TestTargetRefusal_PrivateDestinationSaysHowToAllowIt(
form.Set("type", string(targetType)) form.Set("type", string(targetType))
form.Set("url", editBlockedURL) form.Set("url", editBlockedURL)
// A refused add shows the webhook page again, where // A refused add shows the webhook page again, and a
// the hint is HTML-escaped; a refused edit answers in // refused edit the edit page, where the hint is
// plain text. // HTML-escaped.
added := serveTarget( added := serveTarget(
env, http.MethodPost, targetsPath, form, env, http.MethodPost, targetsPath, form,
) )
@@ -76,7 +76,8 @@ func TestTargetRefusal_PrivateDestinationSaysHowToAllowIt(
) )
assert.Equal(t, http.StatusBadRequest, edited.Code) assert.Equal(t, http.StatusBadRequest, edited.Code)
assert.Contains( assert.Contains(
t, edited.Body.String(), privateRefusalHint, t, edited.Body.String(),
html.EscapeString(privateRefusalHint),
) )
}) })
} }
+3 -33
View File
@@ -2,7 +2,6 @@ package handlers
import ( import (
"errors" "errors"
"net/http"
"strconv" "strconv"
"strings" "strings"
) )
@@ -26,14 +25,14 @@ var (
// errRetriesInvalid signals a max_retries form value that is not // errRetriesInvalid signals a max_retries form value that is not
// a non-negative whole number. // a non-negative whole number.
errRetriesInvalid = errors.New( errRetriesInvalid = errors.New(
"retries must be a whole number of attempts", "must be a whole number",
) )
// errRetriesTooLarge signals a max_retries form value that is a // errRetriesTooLarge signals a max_retries form value that is a
// whole number but above maxTargetRetries. It is distinguished // whole number but above maxTargetRetries. It is distinguished
// from errRetriesInvalid so the message can name the ceiling // from errRetriesInvalid so the message can name the ceiling
// instead of implying the input was not a number. // instead of implying the input was not a number.
errRetriesTooLarge = errors.New("retries out of range") errRetriesTooLarge = errors.New("out of range")
) )
// parseMaxRetries interprets a max_retries form value. // parseMaxRetries interprets a max_retries form value.
@@ -83,38 +82,9 @@ func retriesErrorMessage(err error) string {
if errors.Is(err, errRetriesTooLarge) { if errors.Is(err, errRetriesTooLarge) {
return errRetriesTooLarge.Error() + return errRetriesTooLarge.Error() +
": at most " + strconv.Itoa(maxTargetRetries) + ": at most " + strconv.Itoa(maxTargetRetries) +
" retries" " attempts"
} }
return errRetriesInvalid.Error() + return errRetriesInvalid.Error() +
", or 0 for fire-and-forget" ", or 0 for fire-and-forget"
} }
// targetMaxRetries reads and validates max_retries from a target edit
// submission, answering the request with a 400 and reporting false
// when the value is set but invalid.
//
// It and the create path (newTarget) both use parseMaxRetries and
// retriesErrorMessage, so the two cannot come to disagree about what a
// valid retry count is. The wording matches the timeout control on
// the same submission.
func targetMaxRetries(
w http.ResponseWriter,
r *http.Request,
fallback int,
) (int, bool) {
retries, err := parseMaxRetries(
r.PostFormValue("max_retries"), fallback,
)
if err != nil {
http.Error(
w,
"Invalid max retries: "+retriesErrorMessage(err),
http.StatusBadRequest,
)
return 0, false
}
return retries, true
}
-17
View File
@@ -383,20 +383,3 @@ func TestTargetRetries_CreateAndEditAgreeOnEveryCase(t *testing.T) {
) )
} }
} }
// TestPageOrFirst_CoercesRatherThanRejects pins the one place a
// non-numeric form value legitimately falls back. A page number says
// where to send the browser after an action that has already
// happened, so it is not configuration and rejecting it would report
// a failure that did not occur.
func TestPageOrFirst_CoercesRatherThanRejects(t *testing.T) {
t.Parallel()
for _, s := range []string{"", "abc", "0", "-1", "2.7", " "} {
assert.Equal(t, 1, handlers.PageOrFirstForTest(s),
"%q should fall back to the first page", s)
}
assert.Equal(t, 4, handlers.PageOrFirstForTest("4"))
assert.Equal(t, 4, handlers.PageOrFirstForTest(" 4 "))
}
+35
View File
@@ -0,0 +1,35 @@
package handlers
import (
"net/http"
"sneak.berlin/go/webhooker/internal/database"
)
// HandleTargetToggle handles toggling a target's active state.
func (h *Handlers) HandleTargetToggle() http.HandlerFunc {
return h.toggleChildResource(
"targetID",
func(webhookID, childID string) (bool, error) {
var tgt database.Target
err := h.db.DB().Where(
"id = ? AND webhook_id = ?",
childID, webhookID,
).First(&tgt).Error
if err != nil {
return false, err
}
// Only the active column: saving the whole row would
// write back the name and settings read above over an
// edit saved since.
active := !tgt.Active
return active, h.db.DB().Model(&tgt).
Update("active", active).Error
},
"failed to toggle target",
targetActivated, targetDeactivated,
)
}
+46 -20
View File
@@ -21,6 +21,7 @@ import (
const ( const (
dataKeyWebhook = "Webhook" dataKeyWebhook = "Webhook"
dataKeyError = "Error" dataKeyError = "Error"
dataKeyEvents = "Events"
) )
// testWebhookID is the identifier given to the webhook under test on // testWebhookID is the identifier given to the webhook under test on
@@ -144,9 +145,10 @@ func TestEventLogPageIsCalledFullEventLog(t *testing.T) {
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
// A pointer, as in the handlers: source_detail.html calls // A pointer, as in the handlers: source_detail.html calls
// Webhook.RetentionLabel, a pointer method. Both pages only range // Webhook.RetentionLabel, a pointer method. The webhook page only
// over their lists, and a list left out renders as empty, so the // ranges over its lists, and a list left out renders as empty, so
// lists are left out. // its lists are left out. The event log also counts its events, so
// it gets an empty list.
webhook := &database.Webhook{Name: "wh", RetentionDays: 14} webhook := &database.Webhook{Name: "wh", RetentionDays: 14}
webhook.ID = testWebhookID webhook.ID = testWebhookID
@@ -169,6 +171,7 @@ func TestEventLogPageIsCalledFullEventLog(t *testing.T) {
logBody := renderPage(t, h, sess, "source_logs.html", map[string]any{ logBody := renderPage(t, h, sess, "source_logs.html", map[string]any{
dataKeyWebhook: webhook, dataKeyWebhook: webhook,
dataKeyEvents: []handlers.EventLogView{},
"TotalEvents": int64(0), "TotalEvents": int64(0),
}) })
@@ -196,8 +199,6 @@ func TestCreateFormRetentionCopyMatchesBehaviour(t *testing.T) {
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
body := renderPage(t, h, sess, "sources_new.html", map[string]any{ body := renderPage(t, h, sess, "sources_new.html", map[string]any{
"Name": "",
"Description": "",
"DefaultRetentionDays": database.DefaultRetentionDays, "DefaultRetentionDays": database.DefaultRetentionDays,
dataKeyError: "", dataKeyError: "",
}) })
@@ -320,7 +321,7 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
[]database.Entrypoint{entrypoint}, []database.Entrypoint{entrypoint},
), ),
"Targets": delivery.NewTargetViews(nil), "Targets": delivery.NewTargetViews(nil),
"Events": []database.Event{}, dataKeyEvents: []database.Event{},
"BaseURL": "https://hooks.example.com", "BaseURL": "https://hooks.example.com",
}) })
@@ -353,15 +354,14 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
// and target_http gives up once the attempt number reaches // and target_http gives up once the attempt number reaches
// max_retries), and 0 is special-cased to a single fire-and-forget // max_retries), and 0 is special-cased to a single fire-and-forget
// attempt with no circuit breaker. // attempt with no circuit breaker.
const maxRetriesHelp = "This is the total number of delivery attempts, " + const maxRetriesHelp = "How many times each delivery is attempted in " +
"not retries on top of the first: a value of 3 makes three attempts " + "all, the first attempt included. 0 means a single attempt with no " +
"in all. 0 means a single attempt with no retries and no circuit " + "retries and no circuit breaker."
"breaker."
// TestTargetFormMaxRetriesCopyMatchesBehaviour pins the max_retries // TestTargetFormMaxRetriesCopyMatchesBehaviour pins the max_retries
// help text on both the create form (the add-target form on the webhook // label, "Delivery attempts", and help text on both the create form
// detail page) and the edit form, so the copy cannot drift back to // (the add-target form on the webhook detail page) and the edit form,
// calling the number a retry count. // so the copy cannot drift back to calling the number a retry count.
func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) { func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
t.Parallel() t.Parallel()
@@ -387,20 +387,21 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
[]database.Entrypoint{entrypoint}, []database.Entrypoint{entrypoint},
), ),
"Targets": delivery.NewTargetViews(nil), "Targets": delivery.NewTargetViews(nil),
"Events": []database.Event{}, dataKeyEvents: []database.Event{},
"BaseURL": "https://hooks.example.com", "BaseURL": "https://hooks.example.com",
}, },
) )
assert.Contains(t, createBody, "Delivery attempts:</label>")
assert.Contains( assert.Contains(
t, createBody, maxRetriesHelp, t, createBody, maxRetriesHelp,
"the add-target form must explain max_retries as total attempts", "the add-target form must explain max_retries as total attempts",
) )
// A slack target exercises the same max_retries field while needing // A slack target exercises the same max_retries field while needing
// only Config.URL from the edit template, so the test data stays // only a URL from the edit template, so the test data stays
// minimal. The Target key mirrors the field names the template reads // minimal. The Target and TargetForm keys mirror the field names
// off the handler's view value. // the template reads off the handler's values.
editBody := renderPage( editBody := renderPage(
t, h, sess, "target_edit.html", map[string]any{ t, h, sess, "target_edit.html", map[string]any{
dataKeyWebhook: webhook, dataKeyWebhook: webhook,
@@ -409,17 +410,42 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
"Name": "t", "Name": "t",
"Type": "slack", "Type": "slack",
"Active": true, "Active": true,
"MaxRetries": 3,
"Config": map[string]any{
"URL": "https://hooks.slack.com/services/x",
}, },
"TargetForm": map[string]any{
"URL": "https://hooks.slack.com/services/x",
"MaxRetries": "3",
}, },
dataKeyError: "", dataKeyError: "",
}, },
) )
assert.Contains(t, editBody, `class="label">Delivery attempts</label>`)
assert.Contains( assert.Contains(
t, editBody, maxRetriesHelp, t, editBody, maxRetriesHelp,
"the target edit form must explain max_retries as total attempts", "the target edit form must explain max_retries as total attempts",
) )
} }
// TestCreateFormCallsTheDatabaseTargetAnArchive pins the names the new
// webhook page gives the database target its Archive checkbox creates,
// and that target's settings, to the ones the target forms use.
func TestCreateFormCallsTheDatabaseTargetAnArchive(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
var sess *session.Session
app := newTestApp(t, &h, &sess)
app.RequireStart()
t.Cleanup(app.RequireStop)
body := renderPage(t, h, sess, "sources_new.html", map[string]any{
dataKeyError: "",
})
assert.Contains(t, body, "created with an archive target")
assert.Contains(t, body, `class="label">Archive expiry</label>`)
assert.Contains(t, body, `class="label">Archive rotation</label>`)
}
+13
View File
@@ -326,6 +326,19 @@ func (h *Handlers) createAndFanOut(
return nil, nil, err return nil, nil, err
} }
// A resubmitted copy did not arrive on its entrypoint's URL, so it
// leaves the entrypoint's last event as it is.
if src.ResubmittedFromID == nil {
err = database.AddEntrypointTotals(tx, database.EntrypointTotals{
EntrypointID: event.EntrypointID, LastEventAt: event.CreatedAt,
})
if err != nil {
tx.Rollback()
return nil, nil, err
}
}
err = tx.Commit().Error err = tx.Commit().Error
if err != nil { if err != nil {
return nil, nil, fmt.Errorf( return nil, nil, fmt.Errorf(
+263
View File
@@ -0,0 +1,263 @@
package handlers
import (
"context"
"net/http"
"strconv"
"github.com/google/uuid"
"sneak.berlin/go/webhooker/internal/database"
)
// HandleSourceCreate shows the form to create a new webhook.
func (h *Handlers) HandleSourceCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.renderTemplate(
w, r, "sources_new.html",
newSourceFormData("", sourceFormInput{
RetentionDays: strconv.Itoa(
database.DefaultRetentionDays,
),
}),
)
}
}
// sourceFormInput carries the raw values of the new webhook form. A
// refused submission is shown again from it, so every value entered
// comes back, retention included.
type sourceFormInput struct {
Name string
Description string
RetentionDays string
// HTTPURL, when not empty, asks for an HTTP target with this
// destination.
HTTPURL string
// Archive asks for a database (archive) target, whose rows expire
// after ArchiveExpiry and whose files rotate by ArchiveRotation.
Archive bool
ArchiveExpiry string
ArchiveRotation string
}
// newSourceFormData builds the template data for the webhook creation
// form. It carries the retention default, which the form's help text
// names, from database.DefaultRetentionDays rather than a hardcoded
// copy of the same policy.
func newSourceFormData(
errMsg string, in sourceFormInput,
) map[string]any {
return map[string]any{
tmplKeyError: errMsg,
"Form": in,
"DefaultRetentionDays": database.DefaultRetentionDays,
tmplKeyArchiveExpiryChoices: archiveExpiryOptions(
in.ArchiveExpiry,
),
tmplKeyArchiveRotationChoices: archiveRotationOptions(
in.ArchiveRotation,
),
}
}
// HandleSourceCreateSubmit handles the webhook creation form
// submission.
func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
// The body size cap is enforced by the MaxBodySize
// middleware, which runs before CSRF parses the form.
err := r.ParseForm()
if err != nil {
h.renderError(w, r, http.StatusBadRequest)
return
}
in := sourceFormInput{
Name: r.PostFormValue("name"),
Description: r.PostFormValue("description"),
RetentionDays: r.PostFormValue("retention_days"),
HTTPURL: r.PostFormValue("http_url"),
Archive: r.PostFormValue("archive") != "",
ArchiveExpiry: r.PostFormValue("archive_expiry"),
ArchiveRotation: r.PostFormValue("archive_rotation"),
}
refuse := func(errMsg string) {
h.renderTemplateStatus(
w, r, "sources_new.html",
newSourceFormData(errMsg, in),
http.StatusBadRequest,
)
}
if in.Name == "" {
refuse("Name is required")
return
}
retentionDays, errMsg := parseRetentionDays(
in.RetentionDays, database.DefaultRetentionDays,
)
if errMsg != "" {
refuse(errMsg)
return
}
targets, errMsg, err := h.newWebhookTargets(r.Context(), in)
if err != nil {
h.serverError(w, r, "failed to encode target config", err)
return
}
if errMsg != "" {
refuse(errMsg)
return
}
h.createWebhookWithEntrypoint(w, r, &database.Webhook{
UserID: userID,
Name: in.Name,
Description: in.Description,
RetentionDays: retentionDays,
}, targets)
}
}
// newWebhookTargets validates the targets the new webhook form asks
// for and returns the rows to create with the webhook, or the message
// the form shows for the first one it refuses. A filled-in HTTP URL
// asks for an HTTP target named "HTTP", and the archive checkbox for a
// database target named "Archive". Each goes through newTarget, as on
// the webhook page's add target form. The rows have no WebhookID yet:
// the webhook has no ID until it is created.
func (h *Handlers) newWebhookTargets(
ctx context.Context,
in sourceFormInput,
) ([]*database.Target, string, error) {
var requested []targetFormInput
if in.HTTPURL != "" {
requested = append(requested, targetFormInput{
Name: "HTTP",
Type: database.TargetTypeHTTP,
URL: in.HTTPURL,
})
}
if in.Archive {
requested = append(requested, targetFormInput{
Name: "Archive",
Type: database.TargetTypeDatabase,
Expiry: in.ArchiveExpiry,
Rotation: in.ArchiveRotation,
})
}
targets := make([]*database.Target, 0, len(requested))
for _, form := range requested {
target, errMsg, err := h.newTarget(ctx, "", form)
if err != nil || errMsg != "" {
return nil, errMsg, err
}
targets = append(targets, target)
}
return targets, "", nil
}
// createWebhookWithEntrypoint creates a webhook, its default
// entrypoint and the given targets in a transaction.
func (h *Handlers) createWebhookWithEntrypoint(
w http.ResponseWriter,
r *http.Request,
webhook *database.Webhook,
targets []*database.Target,
) {
err := h.commitWebhook(webhook, targets)
if err != nil {
h.serverError(w, r, "failed to create webhook", err)
return
}
err = h.dbMgr.CreateDB(webhook.ID)
if err != nil {
h.log.Error(
"failed to create webhook event database",
"webhook_id", webhook.ID, "error", err,
)
}
h.log.Info("webhook created",
"webhook_id", webhook.ID,
"name", webhook.Name, "user_id", webhook.UserID,
)
http.Redirect(
w, r, withNotice("/hook/"+webhook.ID, webhookCreated),
http.StatusSeeOther,
)
}
// commitWebhook creates a webhook, its default entrypoint and the
// given targets in a transaction. Returns an error on failure (rolls
// back).
func (h *Handlers) commitWebhook(
webhook *database.Webhook,
targets []*database.Target,
) error {
tx := h.db.DB().Begin()
if tx.Error != nil {
return tx.Error
}
err := tx.Create(webhook).Error
if err != nil {
tx.Rollback()
return err
}
entrypoint := &database.Entrypoint{
WebhookID: webhook.ID,
Path: uuid.New().String(),
Description: "Default entrypoint",
Active: true,
}
err = tx.Create(entrypoint).Error
if err != nil {
tx.Rollback()
return err
}
for _, target := range targets {
target.WebhookID = webhook.ID
err = tx.Create(target).Error
if err != nil {
tx.Rollback()
return err
}
}
return tx.Commit().Error
}
+170
View File
@@ -0,0 +1,170 @@
package handlers
import (
"errors"
"net/http"
"github.com/go-chi/chi"
"sneak.berlin/go/webhooker/internal/database"
)
// HandleSourceDelete handles webhook deletion.
func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
sourceID := chi.URLParam(r, "sourceID")
var webhook database.Webhook
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
return
}
h.deleteWebhookResources(w, r, webhook, userID)
}
}
// The messages deleteWebhookResources logs when a file of the event
// database cannot be removed: the database file itself, or only a
// sidecar once the database file is gone.
const (
eventDBLeftMsg = "webhook deleted, but its event database file is " +
"still on disk; remove it by hand"
sidecarLeftMsg = "webhook deleted and its events are gone, but a " +
"-wal or -shm sidecar of its event database is " +
"still on disk; remove it by hand"
)
// deleteWebhookResources soft-deletes config and hard-deletes
// the per-webhook event database.
func (h *Handlers) deleteWebhookResources(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
userID string,
) {
// The configuration delete commits before the event database
// is touched. No transaction spans the main database and the
// filesystem, so one side has to go first: committing the
// configuration first means a later failure leaves an unused
// event database file on disk, while removing the event
// database first would mean a failed commit destroys the
// history of a webhook that still exists. A leftover file can
// be removed by hand; deleted history cannot be recovered.
err := h.commitWebhookDeletion(&webhook)
if err != nil {
h.serverError(w, r, "failed to delete webhook", err)
return
}
h.log.Info(
"webhook deleted",
"webhook_id", webhook.ID,
"user_id", userID,
)
// Release the delivery engine's per-webhook archiving state
// so a deleted webhook's archive writer (and any handle open
// within its debounce window) does not linger for the
// process lifetime. The archive file itself is deliberately
// left on disk; see evictArchiveWriter.
h.evictArchiveWriter(webhook.ID)
err = h.dbMgr.DeleteDB(webhook.ID)
if err != nil {
// The configuration is committed, so the webhook is gone,
// but a file of its event database is still on disk with
// nothing referencing it. Report the failure rather than
// redirecting as though everything succeeded: the file
// needs removing by hand, and the logged error names it.
// When only a sidecar is left, the events are already
// gone, and the message must not suggest they survive.
msg := eventDBLeftMsg
if errors.Is(err, database.ErrSidecarNotRemoved) {
msg = sidecarLeftMsg
}
h.serverError(w, r, msg, err)
return
}
http.Redirect(
w, r, withNotice("/hooks", webhookDeleted), http.StatusSeeOther,
)
}
// commitWebhookDeletion soft-deletes a webhook's entrypoints,
// targets and the webhook row in one transaction. Every
// statement is checked and any failure rolls the whole
// transaction back, so a caller that gets an error knows the
// configuration is untouched and the event database must be
// left alone.
func (h *Handlers) commitWebhookDeletion(
webhook *database.Webhook,
) error {
tx := h.db.DB().Begin()
if tx.Error != nil {
return tx.Error
}
err := tx.Where(
"webhook_id = ?", webhook.ID,
).Delete(&database.Entrypoint{}).Error
if err != nil {
tx.Rollback()
return err
}
err = tx.Where(
"webhook_id = ?", webhook.ID,
).Delete(&database.Target{}).Error
if err != nil {
tx.Rollback()
return err
}
err = tx.Delete(webhook).Error
if err != nil {
tx.Rollback()
return err
}
return tx.Commit().Error
}
// evictArchiveWriter asks the delivery engine to drop the cached
// archive writers of a webhook's database targets, closing their
// archive file handles.
//
// The archive database files are NOT deleted. Unlike the event
// database — which is per-webhook working storage and is
// hard-deleted with the webhook — an archive is explicitly
// long-term storage that an operator may want to keep or move
// away for offline retention. Destroying it as a side effect of
// deleting a webhook would be a surprising and unrecoverable
// data loss, so the file is left for the operator to handle.
func (h *Handlers) evictArchiveWriter(webhookID string) {
if h.archives == nil {
return
}
h.archives.EvictWebhook(webhookID)
}
+133
View File
@@ -0,0 +1,133 @@
package handlers
import (
"net/http"
"time"
"github.com/go-chi/chi"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/reqtls"
)
// HandleSourceDetail shows details for a specific webhook.
func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
sourceID := chi.URLParam(r, "sourceID")
var webhook database.Webhook
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
return
}
h.renderSourceDetail(w, r, webhook, targetFormInput{}, "")
}
}
// renderSourceDetail loads and renders a source detail page. With a
// targetErr, it is the page shown again for a refused add target
// form: it answers 400, and the form opens on targetForm's type with
// its values and the message.
func (h *Handlers) renderSourceDetail(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
targetForm targetFormInput,
targetErr string,
) {
var entrypoints []database.Entrypoint
h.db.DB().Where(
"webhook_id = ?", webhook.ID,
).Find(&entrypoints)
var targets []database.Target
h.db.DB().Where(
"webhook_id = ?", webhook.ID,
).Find(&targets)
entrypointViews := NewEntrypointViews(entrypoints)
var events []RecentEventView
if h.dbMgr.DBExists(webhook.ID) {
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.serverError(w, r, "failed to get webhook database", err)
return
}
events, err = loadRecentEvents(
webhookDB, webhook.ID, singleHTTPTargetID(targets),
)
if err != nil {
h.serverError(w, r, "failed to load recent events", err)
return
}
err = addEntrypointEvents(
webhookDB, &webhook, entrypointViews, time.Now(),
)
if err != nil {
h.serverError(w, r, "failed to count entrypoint events", err)
return
}
}
scheme := "http"
if reqtls.IsTLS(r) {
scheme = "https"
}
// The host is the client's Host header, unvalidated. It is
// inert only because source_detail.html renders BaseURL as
// text, inside a <code> element and in an entrypoint's delete
// prompt; putting it in an href or any other URL context
// needs it constrained first.
baseURL := scheme + "://" + r.Host
// The template calls Webhook methods, which take pointer
// receivers; html/template cannot address a value stored in a map.
data := map[string]any{
tmplKeyWebhook: &webhook,
// Targets are projected to a display-safe view: a
// target's stored config blob holds a credential, and it
// must never reach a template.
"Entrypoints": entrypointViews,
"Targets": h.targetRows(&webhook, targets),
"Events": events,
"BaseURL": baseURL,
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
tmplKeyTargetForm: targetForm,
"TargetError": targetErr,
// The add target form's selects start on its expiry and
// rotation through Alpine, so no choice is selected here.
tmplKeyArchiveExpiryChoices: archiveExpiryChoices(),
tmplKeyArchiveRotationChoices: archiveRotationChoices(),
}
status := http.StatusOK
if targetErr != "" {
status = http.StatusBadRequest
}
h.renderTemplateStatus(w, r, "source_detail.html", data, status)
}
+245
View File
@@ -0,0 +1,245 @@
package handlers
import (
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
// HandleSourceEdit shows the form to edit a webhook.
func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
sourceID := chi.URLParam(r, "sourceID")
var webhook database.Webhook
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
return
}
h.renderWebhookEdit(
w, r, &webhook,
webhook.Name, webhook.Description,
strconv.Itoa(webhook.RetentionDays),
"", http.StatusOK,
)
}
}
// HandleSourceEditSubmit handles the webhook edit form
// submission.
func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
sourceID := chi.URLParam(r, "sourceID")
h.renameMu.Lock()
defer h.renameMu.Unlock()
var webhook database.Webhook
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
return
}
// The body size cap is enforced by the MaxBodySize
// middleware, which runs before CSRF parses the form.
err = r.ParseForm()
if err != nil {
h.renderError(w, r, http.StatusBadRequest)
return
}
h.applyWebhookEdit(w, r, &webhook)
}
}
// applyWebhookEdit validates and saves webhook edits. A refused save
// shows the edit form again with the values submitted and the reason.
func (h *Handlers) applyWebhookEdit(
w http.ResponseWriter,
r *http.Request,
webhook *database.Webhook,
) {
// The body size cap is enforced by the MaxBodySize middleware,
// which runs before CSRF parses the form.
name := r.PostFormValue("name")
description := r.PostFormValue("description")
retention := r.PostFormValue("retention_days")
if name == "" {
h.renderWebhookEdit(
w, r, webhook, name, description, retention,
"Name is required", http.StatusBadRequest,
)
return
}
// An empty field falls back to the stored value, so submitting the
// form without touching retention leaves the policy alone.
retentionDays, errMsg := parseRetentionDays(
retention, webhook.RetentionDays,
)
if errMsg != "" {
h.renderWebhookEdit(
w, r, webhook, name, description, retention,
errMsg, http.StatusBadRequest,
)
return
}
// edited is the webhook as the submission leaves it; webhook stays
// as stored, for the page shown again when the save is refused.
edited := *webhook
edited.Name = name
edited.Description = description
edited.RetentionDays = retentionDays
// A new name renames the archive files before it is saved (see
// delivery.Engine.Rename). If either step fails, the same targets'
// archives go back to the name that is still stored, without
// reading the main database again.
targets, err := h.renameWebhookArchives(
webhook.ID, webhook.Name, edited.Name,
)
if err == nil {
err = h.db.DB().Save(&edited).Error
}
if err != nil {
restoreErr := h.renameArchives(targets, webhook.Name)
if restoreErr != nil {
h.log.Error(
"failed to rename archives back",
"webhook_id", webhook.ID,
"error", restoreErr,
)
}
if errors.Is(err, delivery.ErrArchiveNameTaken) {
h.renderWebhookEdit(
w, r, webhook, name, description, retention,
"Not saved: "+err.Error()+
". Move that archive out of the data directory, "+
"its .db together with any -wal and -shm beside "+
"it, then save again.",
http.StatusConflict,
)
return
}
h.serverError(w, r, "failed to update webhook", err)
return
}
http.Redirect(
w, r, withNotice("/hook/"+webhook.ID, webhookSaved),
http.StatusSeeOther,
)
}
// renderWebhookEdit renders the webhook edit page for the webhook as
// stored, its form showing name, description and retentionDays, with
// an optional error message above it.
func (h *Handlers) renderWebhookEdit(
w http.ResponseWriter,
r *http.Request,
webhook *database.Webhook,
name, description, retentionDays, errMsg string,
status int,
) {
data := map[string]any{
tmplKeyWebhook: webhook,
tmplKeyError: errMsg,
"Name": name,
"Description": description,
"RetentionDays": retentionDays,
}
h.renderTemplateStatus(w, r, "source_edit.html", data, status)
}
// renameWebhookArchives renames the archive file of every database
// 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. It returns the targets it read, so that a failed edit can
// move those same archives back with renameArchives.
func (h *Handlers) renameWebhookArchives(
webhookID, oldName, newName string,
) ([]database.Target, error) {
if h.archives == nil || oldName == newName {
return nil, nil
}
var targets []database.Target
err := h.db.DB().
Where(
"webhook_id = ? AND type = ?",
webhookID, database.TargetTypeDatabase,
).
Find(&targets).Error
if err != nil {
return nil, err
}
return targets, h.renameArchives(targets, newName)
}
// renameArchives renames the archive file of each of the given
// database targets to the webhook name webhookName, keeping each
// target's own name. It tries every target even after one fails, so
// that moving the archives back after a failed edit leaves none under
// the new name, and returns every failure joined.
func (h *Handlers) renameArchives(
targets []database.Target, webhookName string,
) error {
var errs []error
for i := range targets {
err := h.archives.Rename(
targets[i].ID, webhookName, targets[i].Name,
)
if err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
+178
View File
@@ -0,0 +1,178 @@
package handlers
import (
"fmt"
"net/http"
"time"
"sneak.berlin/go/webhooker/internal/database"
)
// WebhookListItem holds data for the webhook list view.
type WebhookListItem struct {
database.Webhook
EntrypointCount int
InactiveEntrypointCount int
TargetCount int
InactiveTargetCount int
// EventCount is how many events the webhook holds, LastEventAt
// when the newest arrived (nil before the first), and
// FailedLast24Hours how many of its deliveries failed in the last
// 24 hours. When the webhook's event database could not be read,
// EventsUnreadable is set and these three are not known.
EventCount int64
LastEventAt *time.Time
FailedLast24Hours int64
EventsUnreadable bool
}
// HandleSourceList shows a list of user's webhooks.
func (h *Handlers) HandleSourceList() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
var webhooks []database.Webhook
err := h.db.DB().Where(
"user_id = ?", userID,
).Order("created_at DESC").Find(&webhooks).Error
if err != nil {
h.serverError(w, r, "failed to list webhooks", err)
return
}
items, err := h.buildWebhookListItems(webhooks)
if err != nil {
h.serverError(w, r, "failed to list webhooks", err)
return
}
data := map[string]any{
"Webhooks": items,
}
h.renderTemplate(w, r, "sources_list.html", data)
}
}
// buildWebhookListItems builds the list's entry for each webhook. It
// fails when the main database cannot be read. A webhook whose event
// database cannot be read is marked on its own entry, and the error is
// logged.
func (h *Handlers) buildWebhookListItems(
webhooks []database.Webhook,
) ([]WebhookListItem, error) {
items := make([]WebhookListItem, len(webhooks))
since := time.Now().Add(-longWindow)
for i := range webhooks {
item := &items[i]
item.Webhook = webhooks[i]
var err error
item.EntrypointCount, item.InactiveEntrypointCount, err =
h.countWithInactive(&database.Entrypoint{}, item.ID)
if err != nil {
return nil, err
}
item.TargetCount, item.InactiveTargetCount, err =
h.countWithInactive(&database.Target{}, item.ID)
if err != nil {
return nil, err
}
// Opening an event database that does not exist would create
// it, and it would hold nothing to count.
if !h.dbMgr.DBExists(item.ID) {
continue
}
err = h.readListEventFigures(item, since)
if err != nil {
h.log.Error(
"failed to read webhook list figures",
"webhook_id", item.ID,
"error", err,
)
item.EventsUnreadable = true
}
}
return items, nil
}
// countWithInactive returns how many entrypoints or targets, as model
// says, a webhook has, and how many of them are inactive.
func (h *Handlers) countWithInactive(
model any, webhookID string,
) (int, int, error) {
var active []bool
err := h.db.DB().Model(model).
Where("webhook_id = ?", webhookID).
Pluck("active", &active).Error
if err != nil {
return 0, 0, fmt.Errorf(
"reading active flags of webhook %s: %w", webhookID, err,
)
}
inactive := 0
for _, a := range active {
if !a {
inactive++
}
}
return len(active), inactive, nil
}
// readListEventFigures fills in the figures the list shows from the
// webhook's event database, with the statistics pane's own queries:
// the event count and last arrival from the event totals row, and the
// deliveries that failed since the given time from the deliveries'
// status index.
func (h *Handlers) readListEventFigures(
item *WebhookListItem, since time.Time,
) error {
webhookDB, err := h.dbMgr.GetDB(item.ID)
if err != nil {
return err
}
var totals database.EventTotals
err = webhookDB.Take(&totals).Error
if err != nil {
return fmt.Errorf("reading event totals: %w", err)
}
item.EventCount = totals.Events - totals.EventsRemoved
item.LastEventAt = totals.LastEventAt
byTarget, err := finishedByTarget(webhookDB, since)
if err != nil {
return err
}
for _, f := range byTarget {
item.FailedLast24Hours += f.Failed
}
return nil
}
+11 -8
View File
@@ -131,8 +131,9 @@ const (
// //
// - Lines carrying an AUTHENTICATED operator's own input, which // - Lines carrying an AUTHENTICATED operator's own input, which
// are not truncated at all: the webhook name on "webhook // are not truncated at all: the webhook name on "webhook
// created" and the target host on "target URL blocked by SSRF // created" (internal/handlers/webhook_create.go) and the target
// protection" (both internal/handlers/source_management.go), // host on "target URL blocked by SSRF protection"
// (internal/handlers/target_create.go),
// and target_name in internal/delivery/engine.go and // and target_name in internal/delivery/engine.go and
// target_http.go. Each is bounded only by the 1 MB form body // target_http.go. Each is bounded only by the 1 MB form body
// cap, so a 100 KB name writes one line of roughly 600 KB. // cap, so a 100 KB name writes one line of roughly 600 KB.
@@ -278,12 +279,14 @@ func (lrw *loggingResponseWriter) Unwrap() http.ResponseWriter {
// after the '?'. Keeping the path and dropping the query is what makes // after the '?'. Keeping the path and dropping the query is what makes
// this branch as bounded as the pattern branches below. // this branch as bounded as the pattern branches below.
// //
// Nothing debuggable is lost. One route in the service reads a query // Nothing debuggable is lost. The only query parameters the service
// parameter at all — `page`, on the authenticated pagination links in // reads are the login page's `next`, the page to return to,
// internal/handlers/source_management.go — and the alternatives that // `notice`, which names the line a page shows after an action, and
// would preserve more (a key count, a key allowlist) all require // the event log's `show`, which picks the events it lists. The
// parsing an attacker-sized query on every request, which is work an // alternatives that would preserve more (a key count, a key
// unauthenticated client would then be choosing for us. // allowlist) all require parsing an attacker-sized query on every
// request, which is work an unauthenticated client would then be
// choosing for us.
func concreteLogURL(r *http.Request) string { func concreteLogURL(r *http.Request) string {
path := r.URL.EscapedPath() path := r.URL.EscapedPath()
+12
View File
@@ -11,6 +11,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -142,6 +143,14 @@ func (n *noopArchives) Rename(_, _, _ string) error {
return nil return nil
} }
type noopCircuitBreakers struct{}
func (n *noopCircuitBreakers) StateAndCooldown(
string,
) (delivery.CircuitState, time.Duration) {
return delivery.CircuitClosed, 0
}
// newServerApp starts the real login path against dir: the handlers, // newServerApp starts the real login path against dir: the handlers,
// the middleware that bounds password verification, the session store // the middleware that bounds password verification, the session store
// and the database, exactly as internal/handlers builds them. // and the database, exactly as internal/handlers builds them.
@@ -174,6 +183,9 @@ func newServerApp(
session.New, session.New,
func() delivery.Notifier { return &noopNotifier{} }, func() delivery.Notifier { return &noopNotifier{} },
func() delivery.Archives { return &noopArchives{} }, func() delivery.Archives { return &noopArchives{} },
func() delivery.CircuitBreakers {
return &noopCircuitBreakers{}
},
metrics.NewRegistry, metrics.NewRegistry,
metrics.New, metrics.New,
middleware.New, middleware.New,
+755 -64
View File
@@ -18,10 +18,13 @@ import (
"time" "time"
"github.com/chromedp/cdproto/browser" "github.com/chromedp/cdproto/browser"
"github.com/chromedp/cdproto/dom"
"github.com/chromedp/cdproto/input"
"github.com/chromedp/cdproto/log" "github.com/chromedp/cdproto/log"
"github.com/chromedp/cdproto/network" "github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/runtime" "github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp" "github.com/chromedp/chromedp"
"github.com/chromedp/chromedp/kb"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -40,6 +43,16 @@ const (
phoneWidth = 390 phoneWidth = 390
phoneHeight = 844 phoneHeight = 844
// A window short enough that the event log scrolls with its last
// event expanded, and tall enough to show all of that event.
shortWidth = 1024
shortHeight = 450
// A person's double- or triple-click: each press is held a tenth of a
// second, and the next press comes a quarter second after the release.
pressHeld = 100 * time.Millisecond
betweenClicks = 250 * time.Millisecond
// olderBody is the body of the event received before the newest. // olderBody is the body of the event received before the newest.
olderBody = "the older event" olderBody = "the older event"
) )
@@ -47,7 +60,8 @@ const (
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the // TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
// event log in a headless browser, served by the real router and so // event log in a headless browser, served by the real router and so
// under the real Content-Security-Policy, and checks that the pages' // under the real Content-Security-Policy, and checks that the pages'
// Alpine.js directives and the copy control work. // Alpine.js directives and the copy control work, and that a target's
// row shows its delivery counts.
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) { func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
t.Parallel() t.Parallel()
@@ -58,7 +72,51 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
userID, _ := env.seedUser(t, "browser", "browser-password") userID, _ := env.seedUser(t, "browser", "browser-password")
webhook, older, event, target := seedBrowserWebhook(t, env, userID)
require.NoError(t, chromedp.Run(
ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")),
))
page := srv.URL + "/hook/" + webhook.ID
// The checks share one browser tab, so they run one at a time, in
// this order. A new check is one more line here.
checkAddEntrypoint(ctx, t, page)
checkAddEachTargetType(ctx, t, page)
checkArchiveChoices(ctx, t, page)
checkRefusedTarget(ctx, t, page)
checkTargetDeliveries(ctx, t, page, target.Name,
"0 in total, 0 in the last 24 hours",
"1 in total, 1 in the last 24 hours")
checkRefusedEdits(ctx, t, page, target.ID)
checkCopy(ctx, t, page)
checkEntrypointEdit(ctx, t, page, page+"/events")
checkRecentEvents(ctx, t, page)
checkArchiveChoice(ctx, t, srv.URL+"/hooks/new", page)
checkNewWebhookTargets(ctx, t, env, srv.URL+"/hooks/new")
checkRefusedNewWebhook(ctx, t, srv.URL+"/hooks/new")
checkEventLog(ctx, t, page+"/events", event.ID, older.ID, target.Name)
checkMobileMenu(ctx, t, page)
checkPhoneWidth(ctx, t, page, page+"/events", target.Name)
assert.Empty(t, problems(), "the browser reported problems")
}
// seedBrowserWebhook seeds the webhook the browser test loads, owned by
// userID: an entrypoint, two events, and a target whose delivery of the
// newer event failed once with a 502. The webhook's name and the newer
// event's content type are each too long for one line on a phone. It
// returns the webhook, the older and the newer event, and the target.
func seedBrowserWebhook(
t *testing.T, env *testEnv, userID string,
) (*database.Webhook, *database.Event, *database.Event, *database.Target) {
t.Helper()
webhook := env.seedWebhook(t, userID) webhook := env.seedWebhook(t, userID)
require.NoError(t, env.db.DB().Model(webhook).Update(
"name", "payment_provider_production_notifications",
).Error)
require.NoError(t, env.db.DB().Omit(clause.Associations).Create( require.NoError(t, env.db.DB().Omit(clause.Associations).Create(
&database.Entrypoint{ &database.Entrypoint{
WebhookID: webhook.ID, WebhookID: webhook.ID,
@@ -66,13 +124,16 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
Active: true, Active: true,
}, },
).Error) ).Error)
env.seedEvent(t, webhook.ID, olderBody) older := env.seedEvent(t, webhook.ID, olderBody)
event := env.seedEvent(t, webhook.ID, `{"hello":"browser"}`) event := env.seedEvent(t, webhook.ID, `{"hello":"browser"}`)
target := env.seedTarget(t, webhook.ID) target := env.seedTarget(t, webhook.ID)
dlv := env.seedFailedDelivery(t, webhook.ID, event.ID, target.ID) dlv := env.seedFailedDelivery(t, webhook.ID, event.ID, target.ID)
webhookDB, err := env.dbMgr.GetDB(webhook.ID) webhookDB, err := env.dbMgr.GetDB(webhook.ID)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, webhookDB.Model(event).Update(
"content_type", "application/vnd.paymentprovider.event+json",
).Error)
require.NoError(t, webhookDB.Omit(clause.Associations).Create( require.NoError(t, webhookDB.Omit(clause.Associations).Create(
&database.DeliveryResult{ &database.DeliveryResult{
DeliveryID: dlv.ID, DeliveryID: dlv.ID,
@@ -81,50 +142,7 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
}, },
).Error) ).Error)
require.NoError(t, chromedp.Run( return webhook, older, event, target
ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")),
))
page := srv.URL + "/hook/" + webhook.ID
checkAddEntrypoint(ctx, t, page)
// Each target type, with the fields its add target form submits, in
// page order. Only http and slack have a url field.
targetTypes := []struct {
name string
fields string
values map[string]string
}{
{
"http", "csrf_token name type url headers timeout max_retries",
map[string]string{"url": publicTargetURL},
},
{
"slack", "csrf_token name type url max_retries",
map[string]string{"url": publicTargetURL},
},
{
"database", "csrf_token name type expiry",
map[string]string{"expiry": "720h"},
},
{"log", "csrf_token name type", nil},
}
for _, tt := range targetTypes {
checkAddTarget(
ctx, t, page, tt.name, strings.Fields(tt.fields), tt.values,
)
}
checkRefusedTarget(ctx, t, page)
checkCopy(ctx, t, page)
checkEntrypointEdit(ctx, t, page, page+"/events")
checkRecentEvents(ctx, t, page)
checkEventLog(ctx, t, page+"/events", event.ID, target.Name)
checkMobileMenu(ctx, t, page)
assert.Empty(t, problems(), "the browser reported problems")
} }
// startBrowser starts a headless browser for one test. It returns the // startBrowser starts a headless browser for one test. It returns the
@@ -303,17 +321,55 @@ const (
document.querySelector('form[action$="/targets"]')).keys()]` document.querySelector('form[action$="/targets"]')).keys()]`
) )
// checkAddEachTargetType runs checkAddTarget on a webhook page for each
// target type, in page order.
func checkAddEachTargetType(ctx context.Context, t *testing.T, url string) {
t.Helper()
// Each target type, with the badge its targets are listed with and
// the fields its add target form submits, in page order. Only http
// and slack have a url field.
targetTypes := []struct {
name string
badge string
fields string
values map[string]string
}{
{
"http", "http",
"csrf_token name type url headers timeout max_retries",
map[string]string{"url": publicTargetURL},
},
{
"slack", "slack", "csrf_token name type url max_retries",
map[string]string{"url": publicTargetURL},
},
{
"database", "archive", "csrf_token name type expiry rotation",
map[string]string{"expiry": "720h", "rotation": "daily"},
},
{"log", "log", "csrf_token name type", nil},
}
for _, tt := range targetTypes {
checkAddTarget(
ctx, t, url, tt.name, tt.badge,
strings.Fields(tt.fields), tt.values,
)
}
}
// checkAddTarget loads a webhook page and walks the add target form for // checkAddTarget loads a webhook page and walks the add target form for
// one target type. The form shows nothing until Add is clicked; Add // one target type. The form shows nothing until Add is clicked; Add
// shows only the type choice; Cancel there closes it; Next shows the // shows only the type choice; Cancel there closes it; Next shows the
// type's own fields in place of the choice, and the form then submits // type's own fields in place of the choice, and the form then submits
// exactly fields, so a field another type uses, such as url, is absent; // exactly fields, so a field another type uses, such as url, is absent;
// Cancel closes it again. It then adds a target of the type, filling in // Cancel closes it again. It then adds a target of the type, filling in
// values, and checks that the section lists it with that type. // values, and checks that the section lists it with badge.
func checkAddTarget( func checkAddTarget(
ctx context.Context, ctx context.Context,
t *testing.T, t *testing.T,
url, targetType string, url, targetType, badge string,
fields []string, fields []string,
values map[string]string, values map[string]string,
) { ) {
@@ -368,8 +424,8 @@ func checkAddTarget(
click(ctx, t, saveButton) click(ctx, t, saveButton)
assert.Truef(t, shown(ctx, `//span[text()="`+name+ assert.Truef(t, shown(ctx, `//span[text()="`+name+
`"]/following-sibling::div/span[text()="`+targetType+`"]`), `"]/following-sibling::div/span[text()="`+badge+`"]`),
"%s: the added target is not listed with its type", targetType) "%s: the added target is not listed as %s", targetType, badge)
} }
// chooseTargetType clicks Add, picks targetType and clicks Next, and // chooseTargetType clicks Add, picks targetType and clicks Next, and
@@ -391,6 +447,57 @@ func chooseTargetType(ctx context.Context, t *testing.T, targetType string) {
"%s: Add still shows while the form is open", targetType) "%s: Add still shows while the form is open", targetType)
} }
// checkArchiveChoices loads a webhook page and checks that the add
// target form's archive expiry starts on never and its archive
// rotation on none, that the database target checkAddTarget added with
// 720h and daily is listed as 30 days and daily, and that its edit
// form starts on 720h and daily.
func checkArchiveChoices(ctx context.Context, t *testing.T, url string) {
t.Helper()
const (
expiry = `form[action$="/targets"] select[name="expiry"]`
rotation = `form[action$="/targets"] select[name="rotation"]`
)
row := `//span[text()="added-database"]/ancestor::div[@class="p-4"][1]`
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
chooseTargetType(ctx, t, "database")
var startExpiry, startRotation, editedExpiry, editedRotation string
require.NoError(t, chromedp.Run(
ctx,
chromedp.Value(expiry, &startExpiry, chromedp.ByQuery),
chromedp.Value(rotation, &startRotation, chromedp.ByQuery),
))
assert.Equal(t, "never", startExpiry,
"the add target form's archive expiry does not start on never")
assert.Equal(t, "none", startRotation,
"the add target form's archive rotation does not start on none")
assert.True(t, shown(ctx, row+`//span[text()="Archive expiry:"]`+
`/following-sibling::span[text()="30 days"]`),
"a database target added with 720h is not listed as 30 days")
assert.True(t, shown(ctx, row+`//span[text()="Archive rotation:"]`+
`/following-sibling::span[text()="daily"]`),
"a database target added with daily is not listed as daily")
click(ctx, t, row+`//a[text()="Edit"]`)
require.NoError(t, chromedp.Run(
ctx,
chromedp.WaitReady("#expiry", chromedp.ByQuery),
chromedp.Value("#expiry", &editedExpiry, chromedp.ByQuery),
chromedp.Value("#rotation", &editedRotation, chromedp.ByQuery),
))
assert.Equal(t, "720h", editedExpiry,
"the edit form does not start on the stored archive expiry")
assert.Equal(t, "daily", editedRotation,
"the edit form does not start on the stored archive rotation")
}
// checkRefusedTarget submits an http target the server refuses, a // checkRefusedTarget submits an http target the server refuses, a
// loopback destination, and checks that the page comes back with the // loopback destination, and checks that the page comes back with the
// form open on the http fields, the values entered and the reason, and // form open on the http fields, the values entered and the reason, and
@@ -452,6 +559,86 @@ func checkRefusedTarget(ctx context.Context, t *testing.T, url string) {
assert.Empty(t, typed, "after Cancel, the next Add keeps the url entered") assert.Empty(t, typed, "after Cancel, the next Add keeps the url entered")
} }
// checkTargetDeliveries loads a webhook page and checks that the row of
// the target named name shows delivered and failed beside its
// "Delivered:" and "Failed:" labels.
func checkTargetDeliveries(
ctx context.Context, t *testing.T, url, name, delivered, failed string,
) {
t.Helper()
row := `//span[text()="` + name + `"]/ancestor::div[@class="p-4"][1]`
figure := func(label, value string) string {
return row + `//span[text()="` + label +
`"]/following-sibling::span[text()="` + value + `"]`
}
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
assert.Truef(t, shown(ctx, figure("Delivered:", delivered)),
"the row of %s does not show %q delivered", name, delivered)
assert.Truef(t, shown(ctx, figure("Failed:", failed)),
"the row of %s does not show %q failed", name, failed)
}
// checkRefusedEdits fills in the target edit page and the webhook edit
// page of a webhook page with values the server refuses, a loopback
// destination and a retention above the longest finite one, which the
// browser lets through. It saves each and checks that the page comes
// back with the reason and every value still in its field. The values
// are keyed by the id of their field.
func checkRefusedEdits(
ctx context.Context, t *testing.T, page, targetID string,
) {
t.Helper()
const reason = `//div[@class="alert-error"]`
edits := []struct {
url string
values map[string]string
}{
{page + "/targets/" + targetID + "/edit", map[string]string{
"#name": "edited-target",
"#url": "http://127.0.0.1/hook",
"#headers": "X-Edited: kept",
"#timeout": "12",
"#max_retries": "3",
}},
{page + "/edit", map[string]string{
"#name": "edited-webhook",
"#description": "kept description",
"#retention_days": "200000",
}},
}
for _, edit := range edits {
require.NoError(t, chromedp.Run(ctx, loadPage(edit.url)))
for field, value := range edit.values {
require.NoError(t, chromedp.Run(
ctx, chromedp.SetValue(field, value, chromedp.ByQuery),
))
}
click(ctx, t, `//button[text()="Save Changes"]`)
assert.Truef(t, shown(ctx, reason),
"%s: a refused save does not show the reason", edit.url)
for field, value := range edit.values {
var kept string
require.NoError(t, chromedp.Run(
ctx, chromedp.Value(field, &kept, chromedp.ByQuery),
))
assert.Equalf(t, value, kept,
"%s: a refused save does not keep the %s entered",
edit.url, field)
}
}
}
// checkCopy loads a webhook page and checks that the Copy control beside // checkCopy loads a webhook page and checks that the Copy control beside
// its entrypoint's URL is a button, and that clicking it copies the URL // its entrypoint's URL is a button, and that clicking it copies the URL
// and says so: the button reads "Copied" only once the copy succeeded. // and says so: the button reads "Copied" only once the copy succeeded.
@@ -604,28 +791,45 @@ func checkRecentEvents(ctx context.Context, t *testing.T, url string) {
"the event's own page does not show its body") "the event's own page does not show its body")
} }
// checkEventLog loads the event log and checks that clicking an event's // checkEventLog loads the event log and checks that of its events only
// row expands it, that in there clicking its delivery shows the // the newest, eventID, starts expanded: its row says so and its caret is
// delivery's attempts and clicking again hides them, and that clicking // turned up, and the log's last event, lastEventID, starts collapsed. In
// the event's row again collapses it. // the newest event, clicking its delivery shows the delivery's attempts
// and clicking again hides them. Clicking the row's caret collapses the
// event, clicking it again expands it, clicking its ID collapses it and
// clicking the ID again expands it. While the event is collapsed the row
// says so and its caret is turned down. It then runs checkEventSelection
// on lastEventID and checkEventKeyboard on eventID.
func checkEventLog( func checkEventLog(
ctx context.Context, t *testing.T, url, eventID, targetName string, ctx context.Context,
t *testing.T,
url, eventID, lastEventID, targetName string,
) { ) {
t.Helper() t.Helper()
// The event's row shows its ID, and its Resubmit form is in the part // The event's row shows its ID and ends with its caret, which turns
// that expands. The delivery's row there shows the target's name. // up with Tailwind's rotate-180 class, and its Resubmit form is in
eventRow := `//span[text()="` + eventID + `"]` // the part that expands. The delivery's row there shows the target's
// name.
id := `//span[text()="` + eventID + `"]`
row := id + `/ancestor::div[@role="button"]`
caret := row + `//*[local-name()="svg"]`
caretUp := caret + `[contains(@class, "rotate-180")]`
caretDown := caret + `[not(contains(@class, "rotate-180"))]`
expanded := `form[action$="/` + eventID + `/resubmit"]` expanded := `form[action$="/` + eventID + `/resubmit"]`
lastExpanded := `form[action$="/` + lastEventID + `/resubmit"]`
deliveryRow := `//span[text()="` + targetName + `"]` deliveryRow := `//span[text()="` + targetName + `"]`
attempt := `//span[text()="Attempt 1"]` attempt := `//span[text()="Attempt 1"]`
require.NoError(t, chromedp.Run(ctx, loadPage(url))) require.NoError(t, chromedp.Run(ctx, loadPage(url)))
assert.True(t, hidden(ctx, expanded), "the event starts expanded") assert.True(t, shown(ctx, expanded), "the newest event starts collapsed")
assert.True(t, shown(ctx, row+`[@aria-expanded="true"]`),
click(ctx, t, eventRow) "the expanded event's row does not say it is expanded")
assert.True(t, shown(ctx, expanded), "clicking the event does not expand it") assert.True(t, shown(ctx, caretUp),
"the expanded event's caret does not turn up")
assert.True(t, hidden(ctx, lastExpanded),
"an older event starts expanded")
assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown") assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown")
@@ -637,9 +841,439 @@ func checkEventLog(
assert.True(t, hidden(ctx, attempt), assert.True(t, hidden(ctx, attempt),
"clicking the delivery again does not hide its attempts") "clicking the delivery again does not hide its attempts")
click(ctx, t, eventRow) click(ctx, t, caret)
assert.True(t, hidden(ctx, expanded), assert.True(t, hidden(ctx, expanded),
"clicking the event again does not collapse it") "clicking the caret does not collapse the event")
assert.True(t, shown(ctx, row+`[@aria-expanded="false"]`),
"the collapsed event's row does not say it is collapsed")
assert.True(t, shown(ctx, caretDown),
"the collapsed event's caret stays turned up")
click(ctx, t, caret)
assert.True(t, shown(ctx, expanded),
"clicking the caret again does not expand the event")
click(ctx, t, id)
assert.True(t, hidden(ctx, expanded),
"clicking the event's ID does not collapse it")
click(ctx, t, id)
assert.True(t, shown(ctx, expanded),
"clicking the event's ID again does not expand it")
checkEventSelection(ctx, t, url, lastEventID)
checkEventKeyboard(ctx, t, url, eventID)
}
// checkEventSelection loads the event log in a short window and checks
// that selecting the ID of its last event, eventID, with the mouse leaves
// the event as it was, and that its caret toggles it at once. Dragging
// over the ID leaves the event collapsed, and the caret's click expands
// it at once. With the page then scrolled to its end, a double-click on
// the ID that goes on to drag along it, and a triple-click on it, each
// leave the event expanded and select that ID. Had a click there
// collapsed the event, the page would have got shorter and moved under
// the pointer before the next click.
func checkEventSelection(
ctx context.Context, t *testing.T, url, eventID string,
) {
t.Helper()
id := `//span[text()="` + eventID + `"]`
row := id + `/ancestor::div[@role="button"]`
caret := row + `//*[local-name()="svg"]`
expanded := `form[action$="/` + eventID + `/resubmit"]`
var (
selected, state string
hasState bool
scrolled float64
)
// What is selected, and whether the event's row says it is expanded.
read := chromedp.Tasks{
chromedp.Evaluate(`window.getSelection().toString()`, &selected),
chromedp.AttributeValue(
row, "aria-expanded", &state, &hasState, chromedp.BySearch,
),
}
// A click on the ID toggles the event half a second after it, so a
// check that selecting the ID did not toggle it waits a second first.
settle := chromedp.Sleep(time.Second)
// The double-click and the triple-click each start with nothing
// selected, so that their first click waits to toggle the event.
clearSelection := chromedp.Evaluate(
`window.getSelection().removeAllRanges()`, nil,
)
// The newest event starts expanded, which can push this one below
// the short window, where the mouse cannot reach it.
require.NoError(t, chromedp.Run(
ctx, chromedp.EmulateViewport(shortWidth, shortHeight), loadPage(url),
chromedp.ScrollIntoView(id, chromedp.BySearch),
))
selectText(ctx, t, id)
require.NoError(t, chromedp.Run(ctx, settle, read))
assert.Equal(t, eventID, selected, "the event's ID cannot be selected")
require.True(t, hasState, "the event's row does not say if it is expanded")
assert.Equal(t, "false", state, "selecting the event's ID expands it")
click(ctx, t, caret)
require.NoError(t, chromedp.Run(ctx, read))
assert.Equal(t, "true", state,
"clicking the caret does not expand the event at once")
// The row says it is expanded before its expanded part is shown, so
// the scroll waits for that part, to end at the expanded page's end.
require.True(t, shown(ctx, expanded),
"clicking the caret does not show the event's expanded part")
require.NoError(t, chromedp.Run(ctx, chromedp.Evaluate(
`window.scrollTo(0, document.body.scrollHeight); window.scrollY`,
&scrolled,
)))
require.Positive(t, scrolled, "the event log does not scroll")
require.NoError(t, chromedp.Run(ctx, clearSelection))
doubleClickAndDrag(ctx, t, id)
require.NoError(t, chromedp.Run(ctx, settle, read))
assert.Contains(t, selected, eventID,
"a double-click and drag does not select the event's ID")
assert.Equal(t, "true", state,
"a double-click and drag over the event's ID collapses it")
require.NoError(t, chromedp.Run(ctx, clearSelection))
tripleClick(ctx, t, id)
require.NoError(t, chromedp.Run(ctx, settle, read))
assert.Contains(t, selected, eventID,
"a triple-click does not select the event's ID")
assert.Equal(t, "true", state,
"a triple-click selecting the event's ID collapses it")
}
// checkEventKeyboard loads the event log and checks that Tab from the
// page's Pending link, the last link above the list, reaches the row of
// the newest event, the first after it, and that Enter then collapses
// that event, which starts expanded, and Space expands it again.
func checkEventKeyboard(
ctx context.Context, t *testing.T, url, eventID string,
) {
t.Helper()
pending := `//a[starts-with(text(), "Pending")]`
expanded := `form[action$="/` + eventID + `/resubmit"]`
var focused string
require.NoError(t, chromedp.Run(
ctx,
loadPage(url),
chromedp.Focus(pending, chromedp.BySearch),
chromedp.KeyEvent(kb.Tab),
chromedp.Evaluate(`document.activeElement.textContent`, &focused),
))
require.Contains(t, focused, eventID,
"Tab from the Pending link does not reach the event's row")
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(kb.Enter)))
assert.True(t, hidden(ctx, expanded), "Enter does not collapse the event")
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(" ")))
assert.True(t, shown(ctx, expanded), "Space does not expand the event")
}
// selectText selects the text of the element matching an XPath
// expression as a person does with the mouse: pressing the button at the
// text's start, moving to its end and releasing it there.
func selectText(ctx context.Context, t *testing.T, xpath string) {
t.Helper()
left, right, y := textEnds(ctx, t, xpath)
require.NoError(t, chromedp.Run(
ctx, press(left, y, 1), drag(right, y), release(right, y, 1),
))
}
// doubleClickAndDrag double-clicks the start of the text of the element
// matching an XPath expression, which selects its first word, and keeps
// the button down to drag to the text's end, which selects it word by
// word. It holds the button for a second, longer than a single click on
// an event's row waits before it toggles the event.
func doubleClickAndDrag(ctx context.Context, t *testing.T, xpath string) {
t.Helper()
left, right, y := textEnds(ctx, t, xpath)
require.NoError(t, chromedp.Run(
ctx,
press(left, y, 1), chromedp.Sleep(pressHeld), release(left, y, 1),
chromedp.Sleep(betweenClicks),
press(left, y, 2), drag(right, y), chromedp.Sleep(time.Second),
release(right, y, 2),
))
}
// tripleClick clicks three times in the middle of the text of the
// element matching an XPath expression, as a person does to select a
// whole line of text. The browser selects a word on the second click and
// the whole paragraph on the third.
func tripleClick(ctx context.Context, t *testing.T, xpath string) {
t.Helper()
left, right, y := textEnds(ctx, t, xpath)
x := (left + right) / 2
require.NoError(t, chromedp.Run(
ctx,
press(x, y, 1), chromedp.Sleep(pressHeld), release(x, y, 1),
chromedp.Sleep(betweenClicks),
press(x, y, 2), chromedp.Sleep(pressHeld), release(x, y, 2),
chromedp.Sleep(betweenClicks),
press(x, y, 3), chromedp.Sleep(pressHeld), release(x, y, 3),
))
}
// textEnds returns where on screen the text of the element matching an
// XPath expression starts and ends, just inside its left and right
// edges, and the height of its middle: in that order, the x of its
// start, the x of its end, and the y of both.
func textEnds(
ctx context.Context, t *testing.T, xpath string,
) (float64, float64, float64) {
t.Helper()
var box *dom.BoxModel
require.NoError(t, chromedp.Run(
ctx, chromedp.Dimensions(xpath, &box, chromedp.BySearch),
))
// The content box's corners, clockwise from its top left.
return box.Content[0] + 1, box.Content[2] - 1,
(box.Content[1] + box.Content[5]) / 2
}
// press presses the left mouse button at x, y, as the nth click of a
// double- or triple-click.
func press(x, y float64, nth int64) *input.DispatchMouseEventParams {
return input.DispatchMouseEvent(input.MousePressed, x, y).
WithButton(input.Left).WithButtons(1).WithClickCount(nth)
}
// drag moves the pointer to x, y with the left mouse button down.
func drag(x, y float64) *input.DispatchMouseEventParams {
return input.DispatchMouseEvent(input.MouseMoved, x, y).
WithButton(input.Left).WithButtons(1)
}
// release releases the left mouse button at x, y, as the nth click of a
// double- or triple-click.
func release(x, y float64, nth int64) *input.DispatchMouseEventParams {
return input.DispatchMouseEvent(input.MouseReleased, x, y).
WithButton(input.Left).WithClickCount(nth)
}
// The parts of the new webhook page the checks below find and click.
const (
archiveBox = `//input[@name="archive"]`
archiveIsOn = `document.querySelector('input[name="archive"]').checked`
pruningChoice = `//select[@name="archive_expiry"]`
createButton = `//button[text()="Create Webhook"]`
)
// checkArchiveChoice loads the new webhook page and checks that the
// archive pruning choice stays hidden until the archive box is checked
// and hides again when it is unchecked; and that after checking it,
// opening the page at elsewhere and going back, the page again shows
// the box unchecked and the choice hidden.
func checkArchiveChoice(
ctx context.Context, t *testing.T, url, elsewhere string,
) {
t.Helper()
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
assert.True(t, hidden(ctx, pruningChoice),
"the pruning choice shows before archive is checked")
click(ctx, t, archiveBox)
assert.True(t, shown(ctx, pruningChoice),
"checking archive does not show the pruning choice")
click(ctx, t, archiveBox)
assert.True(t, hidden(ctx, pruningChoice),
"unchecking archive does not hide the pruning choice")
var (
loaded string
checked bool
)
click(ctx, t, archiveBox)
require.NoError(t, chromedp.Run(
ctx,
loadPage(elsewhere),
chromedp.NavigateBack(),
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
chromedp.Evaluate(
`performance.getEntriesByType("navigation")[0].type`, &loaded,
),
chromedp.Evaluate(archiveIsOn, &checked),
))
require.Equal(
t, "back_forward", loaded,
"going back, the browser did not load the page again",
)
assert.False(t, checked, "going back leaves archive checked")
assert.True(t, hidden(ctx, pruningChoice),
"going back shows the pruning choice")
}
// checkNewWebhookTargets submits the new webhook page with the HTTP
// target URL filled in or empty, and with archive left off or checked
// with each pruning choice, and checks that each webhook is created
// with exactly the targets asked for.
func checkNewWebhookTargets(
ctx context.Context, t *testing.T, env *testEnv, url string,
) {
t.Helper()
// Each value the pruning choice submits, after an empty one that
// stands for archive left off.
expiries := []string{
"", "never", "1h", "12h", "24h", "720h", "2160h", "8760h",
}
for _, httpURL := range []string{"", publicTargetURL} {
for _, expiry := range expiries {
name := "url=" + httpURL + " archive=" + expiry
want := map[database.TargetType]string{}
require.NoError(t, chromedp.Run(
ctx,
loadPage(url),
chromedp.SetValue("#name", name, chromedp.ByQuery),
))
if httpURL != "" {
require.NoError(t, chromedp.Run(ctx, chromedp.SetValue(
"#http_url", httpURL, chromedp.ByQuery,
)))
want[database.TargetTypeHTTP] = `{"url":"` + httpURL + `"}`
}
if expiry != "" {
// The choice showing moves Create down, so it is
// waited for before Create is clicked.
click(ctx, t, archiveBox)
require.Truef(t, shown(ctx, pruningChoice),
"%s: checking archive does not show the pruning choice",
name)
require.NoError(t, chromedp.Run(ctx, chromedp.SetValue(
pruningChoice, expiry, chromedp.BySearch,
)))
// The rotation choice is left on none.
want[database.TargetTypeDatabase] = `{"expiry":"` + expiry +
`","rotation":"none"}`
}
click(ctx, t, createButton)
require.Truef(t, shown(ctx, `//h1[text()="`+name+`"]`),
"%s: the new webhook's page does not open", name)
assert.Equalf(t, want, targetConfigs(t, env, name),
"%s: the webhook does not have the targets asked for", name)
}
}
}
// targetConfigs reads the targets of the webhook named name, and
// returns each one's stored configuration by its type.
func targetConfigs(
t *testing.T, env *testEnv, name string,
) map[database.TargetType]string {
t.Helper()
var (
webhook database.Webhook
targets []database.Target
)
require.NoError(t, env.db.DB().
Where("name = ?", name).First(&webhook).Error)
require.NoError(t, env.db.DB().
Where("webhook_id = ?", webhook.ID).Find(&targets).Error)
configs := map[database.TargetType]string{}
for _, target := range targets {
configs[target.Type] = target.Config
}
return configs
}
// checkRefusedNewWebhook submits the new webhook page with archive
// checked and an HTTP target URL the server refuses, a loopback
// destination, and checks that the page comes back with the reason and
// every value entered, archive still checked and its pruning and
// rotation choices showing.
func checkRefusedNewWebhook(ctx context.Context, t *testing.T, url string) {
t.Helper()
const refusedURL = "http://127.0.0.1/hook"
require.NoError(t, chromedp.Run(
ctx,
loadPage(url),
chromedp.SetValue("#name", "refused", chromedp.ByQuery),
chromedp.SetValue("#description", "kept", chromedp.ByQuery),
chromedp.SetValue("#retention_days", "7", chromedp.ByQuery),
chromedp.SetValue("#http_url", refusedURL, chromedp.ByQuery),
))
click(ctx, t, archiveBox)
require.True(t, shown(ctx, pruningChoice),
"checking archive does not show the pruning choice")
require.NoError(t, chromedp.Run(
ctx,
chromedp.SetValue(pruningChoice, "2160h", chromedp.BySearch),
chromedp.SetValue("#archive_rotation", "monthly", chromedp.ByQuery),
))
click(ctx, t, createButton)
assert.True(t, shown(ctx, `//div[@class="alert-error"]`),
"a refused webhook does not show the reason")
var (
name, description, retention, typed, expiry, rotation string
checked bool
)
require.NoError(t, chromedp.Run(
ctx,
chromedp.Value("#name", &name, chromedp.ByQuery),
chromedp.Value("#description", &description, chromedp.ByQuery),
chromedp.Value("#retention_days", &retention, chromedp.ByQuery),
chromedp.Value("#http_url", &typed, chromedp.ByQuery),
chromedp.Value("#archive_expiry", &expiry, chromedp.ByQuery),
chromedp.Value("#archive_rotation", &rotation, chromedp.ByQuery),
chromedp.Evaluate(archiveIsOn, &checked),
))
assert.Equal(t, "refused", name, "the name entered is lost")
assert.Equal(t, "kept", description, "the description entered is lost")
assert.Equal(t, "7", retention, "the retention entered is lost")
assert.Equal(t, refusedURL, typed, "the url entered is lost")
assert.True(t, checked, "archive is no longer checked")
assert.True(t, shown(ctx, pruningChoice), "the pruning choice is hidden")
assert.Equal(t, "2160h", expiry, "the pruning chosen is lost")
assert.Equal(t, "monthly", rotation, "the rotation chosen is lost")
} }
// checkMobileMenu loads a page in a phone-sized window and checks that // checkMobileMenu loads a page in a phone-sized window and checks that
@@ -666,3 +1300,60 @@ func checkMobileMenu(ctx context.Context, t *testing.T, url string) {
click(ctx, t, button) click(ctx, t, button)
assert.True(t, hidden(ctx, menu), "the menu button does not close the menu") assert.True(t, hidden(ctx, menu), "the menu button does not close the menu")
} }
// scrollsSideways reports whether the page is wider than the window. A
// page's clientWidth is the window's width less its scroll bar.
const scrollsSideways = `document.documentElement.scrollWidth >
document.documentElement.clientWidth`
// cutOffElements lists each element, without elements inside it, that
// is shown but runs past the page's edge or its card's, by more than a
// pixel of rounding. A card hides what runs past its edge.
const cutOffElements = `[...document.querySelectorAll("body *")]
.filter((el) => {
const box = el.getBoundingClientRect();
const card = el.closest(".card")?.getBoundingClientRect();
const left = card ? card.left : 0;
const right = card ? card.right : document.documentElement.clientWidth;
return el.children.length === 0 && box.width > 0 &&
(box.left < left - 1 || box.right > right + 1);
})
.map((el) => el.outerHTML.slice(0, 120))`
// checkPhoneWidth loads the webhook page, url, and its event log,
// eventLog, in a phone-sized window, the event log with the attempts of
// the newest event's delivery to targetName shown. It checks that
// neither page scrolls sideways and that nothing shown on either, no
// status, time or control, is cut off at the page's or its card's edge.
func checkPhoneWidth(
ctx context.Context, t *testing.T, url, eventLog, targetName string,
) {
t.Helper()
var (
sideways bool
cutOff []string
)
measure := chromedp.Tasks{
chromedp.Evaluate(scrollsSideways, &sideways),
chromedp.Evaluate(cutOffElements, &cutOff),
}
require.NoError(t, chromedp.Run(
ctx,
chromedp.EmulateViewport(phoneWidth, phoneHeight),
loadPage(url),
measure,
))
assert.False(t, sideways, "the webhook page scrolls sideways on a phone")
assert.Empty(t, cutOff, "the webhook page cuts these off on a phone")
require.NoError(t, chromedp.Run(ctx, loadPage(eventLog)))
click(ctx, t, `//span[text()="`+targetName+`"]`)
require.True(t, shown(ctx, `//span[text()="Attempt 1"]`),
"clicking the delivery does not show its attempts")
require.NoError(t, chromedp.Run(ctx, measure))
assert.False(t, sideways, "the event log scrolls sideways on a phone")
assert.Empty(t, cutOff, "the event log cuts these off on a phone")
}
+3 -3
View File
@@ -72,7 +72,7 @@ func TestEventResubmit_SignedOutRequestsNeverReachTheRateLimit(
env.requireNotice( env.requireNotice(
t, env.post(path, csrfForm(token), cookies), t, env.post(path, csrfForm(token), cookies),
logsPath, "resubmit-no-targets", logsPath, "resubmit-no-targets",
"this source has no active targets", cookies, "this webhook has no active targets", cookies,
) )
} }
@@ -117,7 +117,7 @@ func TestEventResubmit_RefusedWithoutAValidCSRFToken(t *testing.T) {
env.requireNotice( env.requireNotice(
t, env.post(path, csrfForm(token), cookies), t, env.post(path, csrfForm(token), cookies),
logsPath, "resubmit-no-targets", logsPath, "resubmit-no-targets",
"this source has no active targets", cookies, "this webhook has no active targets", cookies,
) )
} }
@@ -171,7 +171,7 @@ func TestEventResubmit_AnotherWebhooksEvent404s(t *testing.T) {
csrfForm(token), cookies, csrfForm(token), cookies,
), ),
intrudersLogs, "resubmit-no-targets", intrudersLogs, "resubmit-no-targets",
"this source has no active targets", cookies, "this webhook has no active targets", cookies,
) )
} }
+46 -13
View File
@@ -11,6 +11,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -61,6 +62,17 @@ func (e *noopArchives) Rename(_, _, _ string) error {
return nil return nil
} }
// noopCircuitBreakers satisfies handlers.New's
// delivery.CircuitBreakers dependency with no target's deliveries
// paused.
type noopCircuitBreakers struct{}
func (b *noopCircuitBreakers) StateAndCooldown(
string,
) (delivery.CircuitState, time.Duration) {
return delivery.CircuitClosed, 0
}
// testEnv is the real router from routes.go plus the collaborators // testEnv is the real router from routes.go plus the collaborators
// tests need to seed users and forge sessions. // tests need to seed users and forge sessions.
type testEnv struct { type testEnv struct {
@@ -125,6 +137,9 @@ func newTestEnvWithConfig(
session.New, session.New,
func() delivery.Notifier { return &noopNotifier{} }, func() delivery.Notifier { return &noopNotifier{} },
func() delivery.Archives { return &noopArchives{} }, func() delivery.Archives { return &noopArchives{} },
func() delivery.CircuitBreakers {
return &noopCircuitBreakers{}
},
metrics.NewRegistry, metrics.NewRegistry,
metrics.New, metrics.New,
middleware.New, middleware.New,
@@ -439,7 +454,8 @@ func (e *testEnv) storedEntrypoint(
} }
// seedFailedDelivery records a terminally failed delivery of an event // seedFailedDelivery records a terminally failed delivery of an event
// to a target in the webhook's own database. // to a target in the webhook's own database, as the delivery engine
// leaves one: finished now, and counted in its target's totals.
func (e *testEnv) seedFailedDelivery( func (e *testEnv) seedFailedDelivery(
t *testing.T, t *testing.T,
webhookID, eventID, targetID string, webhookID, eventID, targetID string,
@@ -449,16 +465,21 @@ func (e *testEnv) seedFailedDelivery(
webhookDB, err := e.dbMgr.GetDB(webhookID) webhookDB, err := e.dbMgr.GetDB(webhookID)
require.NoError(t, err) require.NoError(t, err)
finishedAt := time.Now()
dlv := &database.Delivery{ dlv := &database.Delivery{
EventID: eventID, EventID: eventID,
TargetID: targetID, TargetID: targetID,
Status: database.DeliveryStatusFailed, Status: database.DeliveryStatusFailed,
FinishedAt: &finishedAt,
} }
require.NoError( require.NoError(
t, t,
webhookDB.Omit(clause.Associations).Create(dlv).Error, webhookDB.Omit(clause.Associations).Create(dlv).Error,
) )
require.NoError(t, database.AddTargetTotals(webhookDB,
database.TargetTotals{TargetID: targetID, Deliveries: 1, Failed: 1},
))
return dlv return dlv
} }
@@ -893,6 +914,26 @@ func TestPagesLogout_SaysSignedOut(t *testing.T) {
env.requireNotice(t, w, "/pages/login", "signed-out", "Signed out.", nil) env.requireNotice(t, w, "/pages/login", "signed-out", "Signed out.", nil)
} }
// TestSignInAndSignOutWording pins one wording for both: the sign-in
// page's button reads "Sign in" and the navbar's buttons "Sign out", as
// the sign-in page's heading, the error page's link and the notice
// after signing out do.
func TestSignInAndSignOutWording(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
assert.Contains(
t, env.get("/pages/login", nil).Body.String(), ">Sign in</button>",
)
userID, _ := env.seedUser(t, "reader", "somepassword")
page := env.get("/hooks", env.authCookies(t, userID, "reader")).Body.String()
assert.Equal(t, 2, strings.Count(page, ">Sign out</button>"),
"the desktop and the mobile navbar each say Sign out")
}
// --- /user/{username} group --- // --- /user/{username} group ---
// TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged // TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged
@@ -1342,7 +1383,7 @@ func TestHook_ResubmitFromEventLog(t *testing.T) {
) )
env.requireNotice( env.requireNotice(
t, w, logsPath, "resubmit-no-targets", t, w, logsPath, "resubmit-no-targets",
"this source has no active targets", cookies, "this webhook has no active targets", cookies,
) )
webhookDB, err := env.dbMgr.GetDB(wh.ID) webhookDB, err := env.dbMgr.GetDB(wh.ID)
@@ -1358,9 +1399,9 @@ func TestHook_ResubmitFromEventLog(t *testing.T) {
// TestHook_LinksBetweenPages follows each link to a webhook page that // TestHook_LinksBetweenPages follows each link to a webhook page that
// the tests above do not: the navbar's "Webhooks" links, the back and // the tests above do not: the navbar's "Webhooks" links, the back and
// Cancel links, the list's link to a webhook, the "Full Event Log" // Cancel links, the list's link to a webhook, and the "Full Event Log"
// link beside the recent events, and the event log's page links. Each // link beside the recent events. Each must point where it should, and
// must point where it should, and that page must render. // that page must render.
func TestHook_LinksBetweenPages(t *testing.T) { func TestHook_LinksBetweenPages(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1371,12 +1412,6 @@ func TestHook_LinksBetweenPages(t *testing.T) {
wh := env.seedWebhook(t, userID) wh := env.seedWebhook(t, userID)
tgt := env.seedTarget(t, wh.ID) tgt := env.seedTarget(t, wh.ID)
// The event log shows 25 events a page; one more gives it a second
// page, so it renders its Next and Previous links.
for range 26 {
env.seedEvent(t, wh.ID, "paged")
}
list := "/hooks" list := "/hooks"
newForm := list + "/new" newForm := list + "/new"
page := "/hook/" + wh.ID page := "/hook/" + wh.ID
@@ -1408,8 +1443,6 @@ func TestHook_LinksBetweenPages(t *testing.T) {
{targetEdit, back, page}, {targetEdit, back, page},
{targetEdit, cancel, page}, {targetEdit, cancel, page},
{events, back, page}, {events, back, page},
{events, `href="([^"]+)"[^>]*>Next &rarr;<`, events + "?page=2"},
{events + "?page=2", `href="([^"]+)"[^>]*>&larr; Previous<`, events + "?page=1"},
} { } {
got := env.urlFrom(t, link.from, link.pattern, cookies) got := env.urlFrom(t, link.from, link.pattern, cookies)
assert.Equal(t, link.want, got, "%s: %s", link.from, link.pattern) assert.Equal(t, link.want, got, "%s: %s", link.from, link.pattern)
+3 -2
View File
@@ -218,8 +218,9 @@ func keptSentryHeaders(headers map[string]string) map[string]string {
// sentryKeepsHeader reports whether a request header is routing or // sentryKeepsHeader reports whether a request header is routing or
// content metadata rather than client-chosen payload. Referer is kept // content metadata rather than client-chosen payload. Referer is kept
// on the reasoning that it is browser-set, that this service emits // on the reasoning that it is browser-set, that the only query
// only ?page= in its own links, and that Referrer-Policy is set to // parameters in this service's own URLs are the login page's `next`,
// `notice` and the event log's `show`, and that Referrer-Policy is set to
// strict-origin-when-cross-origin. X-Request-Id ties the event to the // strict-origin-when-cross-origin. X-Request-Id ties the event to the
// local access log line, which holds the rest of the detail. // local access log line, which holds the rest of the detail.
func sentryKeepsHeader(name string) bool { func sentryKeepsHeader(name string) bool {
+8
View File
@@ -0,0 +1,8 @@
{
"private": true,
"devDependencies": {
"eslint": "10.11.0",
"prettier": "3.9.9"
},
"packageManager": "yarn@4.18.1+sha512.b2e1e7524f654f2749d32b4ebcb4622473cb5bcbc485df2007e12a154e50162a4d795526768bc5f5b8f81717bfd79deb2472813d86fb5ae2eb551fa9c872b08f"
}
+5 -4
View File
@@ -3,8 +3,8 @@
# this repo. Idempotent: every install is guarded by a check so already # this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew, # installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes NOTHING is present (not git, # or apk (detected in that order); assumes NOTHING is present (not git,
# make, or go). golangci-lint is deliberately not installed: linting runs # make, or go). golangci-lint, node, ESLint and prettier are deliberately
# only in docker, via script/lint and Dockerfile.lint. # not installed: they run only in docker, via script/lint and script/fmt.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
@@ -60,9 +60,10 @@ main() {
if missing go; then pkg_install go golang go go; fi if missing go; then pkg_install go golang go go; fi
# Not installed here: docker is platform-specific and out of scope for a # Not installed here: docker is platform-specific and out of scope for a
# package-manager bootstrap, but script/lint needs it. # package-manager bootstrap, but script/lint, script/fmt and script/css
# need it.
if missing docker; then if missing docker; then
echo "bootstrap: docker not found; script/lint requires it" >&2 echo "bootstrap: docker not found; script/lint, script/fmt and script/css require it" >&2
fi fi
go mod download go mod download
+3 -2
View File
@@ -1,8 +1,8 @@
#!/bin/sh #!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own # script/check: run all checks (test, lint, fmt-check, css-check). Our own
# extension to scripts-to-rule-them-all. # extension to scripts-to-rule-them-all.
# Writes only the ignored static/js/alpine.min.js, through script/test. # Writes only the ignored static/js/alpine.min.js, through script/test.
# Generic: usually needs no adaptation. # Generic, apart from css-check.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -11,6 +11,7 @@ main() {
"$SCRIPT_DIR/test" "$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint" "$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check" "$SCRIPT_DIR/fmt-check"
"$SCRIPT_DIR/css-check"
} }
main "$@" main "$@"
+4 -4
View File
@@ -1,8 +1,8 @@
#!/bin/sh #!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs the checks # script/cibuild: run the CI build. The Dockerfile runs the checks (the
# (make fmt-check, lint, test), so a successful build implies a green # gofmt check, golangci-lint, the stylesheet check, ESLint, the Markdown
# repo. Generic: needs no adaptation. The Gitea workflow runs this on # check, make test), so a successful build implies a green repo. Generic:
# push. # needs no adaptation. The Gitea workflow runs this on push.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
Executable
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# script/css: regenerate static/css/tailwind.css (writes). tailwindcss is
# never installed locally: it runs in docker, at the version and sha256
# pinned in the Dockerfile's stylesheet stages, which also say what the
# stylesheet is generated from.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
docker build --target css-output --output type=local,dest=static/css .
}
main "$@"
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# script/css-check: fail when static/css/tailwind.css differs from what
# script/css would generate (read-only). The comparison is the Dockerfile's
# css-check stage, which the image build runs too.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
docker build --target css-check --output type=cacheonly .
}
main "$@"

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