108 Commits
Author SHA1 Message Date
sneak 7c9b241704 Make the HSTS max-age configurable with HSTS_MAX_AGE (closes #271)
check / check (push) Successful in 13m19s
HSTS_MAX_AGE, a Go duration, sets the max-age of the
Strict-Transport-Security header. Unset, it is ten years (87600h);
0 sends no header at all. A negative value, or one that is not a
whole number of seconds, stops startup like an unparseable one.
includeSubDomains and preload stay in the header. The value is shown
on /settings and in the startup log, and the README's HSTS section
now says what each part of the header commits the operator to.

Model: opus-5-5
2026-10-13 00:56:34 +00:00
clawbot 9199544999 Document that DATA_DIR must be on a local filesystem (closes #295)
check / check (push) Successful in 12m55s
SQLite in WAL mode maps each database's -shm file into memory, and
network filesystems do not support that reliably, so the README now
says DATA_DIR must be local and names NFS and SMB/CIFS as unsupported.
It also says what a disk I/O error with code 522 points at and what to
try first. ZFS is not called unsupported: the one failure seen there
was never reproduced.

Model: opus-5-5
2026-10-13 01:29:42 +02:00
clawbot 128eb1b644 Keep test helpers out of the shipped binary (closes #506)
check / check (push) Successful in 7m57s
The test helpers lived in ordinary `testing.go` files inside the config, database, middleware and session packages, so they were built into the binary and the shared `test-support` lint rule could not see them. The four files are gone: the session's helpers move into its own `_test.go` file, and the rest into `configtest`, `databasetest` and `middlewaretest`, which the `depguard` deny list now names, so a non-test file importing them fails lint. The test-support packages build through the production constructors.

Judgement call: the session, the middleware and the webhook database manager now take the plain logger they log through, which the application wiring provides.
Judgement call: two idle-expiry tests move the stored timestamps back instead of advancing a fake clock.

Model: opus-5-5
2026-10-06 14:29:47 +02:00
clawbot 3de345fe6f Read the export test's heap only after pools drop their caches (closes #511)
check / check (push) Successful in 7m9s
`TestArchiveExport_Streams` read the heap after one garbage collection, but the libraries the export calls (`regexp` under GORM's table names, `encoding/json`, GORM's row scanning) keep spare buffers in a `sync.Pool`, which keeps them through one collection. Each reading therefore counted however many happened to be cached, which varied by about as much as the test's limit and failed `go test` on `next`. The test now collects twice before each reading, so a reading is the memory the export holds. The limit, the row counts and the claim are unchanged, and an export that does not stream still fails it.

Unverified: the branch's CI run waits for the runner outage to clear.

Model: opus-5-5
2026-10-06 10:56:50 +02:00
clawbot a9d77e20d7 Make the load-sensitive tests wait for what they check (closes #507)
check / check (push) Successful in 9m48s
Three tests failed at random on a busy host. The browser test now waits for each page a click opens to load, and for Alpine.js to start on it, before reading it. The delivery tests' drain takes what is already queued instead of racing a 25 ms timer, since both dispatch paths queue before they return. The test phase keeps the tests' temporary directories on a tmpfs, because SQLite waiting for the disk made `internal/handlers` slow under load; the timeout and the parallel cap are unchanged.

Deviation: the delivery tests do not wait with a deadline; dispatch has finished when they read.
Unverified: `internal/handlers` at a host load of 260 to 290.

Model: opus-5-5
2026-10-06 08:51:35 +02:00
clawbot faf3da9a75 Refresh the package lists before installing in script/bootstrap (closes #508)
check / check (push) Successful in 9m44s
The CI runner's image starts with empty package lists, so `script/bootstrap`, which `script/cibuild` now runs first, failed to install Go and every CI run stopped there. The apt branch of `pkg_install` now runs `apt-get update` once per run, before its first `apt-get install`, with the same code as the pending shared fix in sneak/prompts#116.

Deviation: `script/bootstrap` differs from the vendored copy until the next re-vendor.
Unverified: proven by running the CI job in the runner's image; the branch's own CI run waits for the runner outage to clear.

Model: opus-5-5
2026-10-06 07:21:30 +02:00
clawbot fa6a9ed4dc Re-vendor the shared files from sneak/prompts at dd4027b (closes #504)
check / check (push) Failing after 5s
The shared workflow, lint config, prettier settings and policies are the copies at `sneak/prompts` commit `dd4027b`. `.gitignore`, `.editorconfig` and `.dockerignore` are the shared copy followed by this repository's own entries. Linting is the image build's lint phase on golangci-lint v2.14.0, and tests run in their own test phase. Every scripted `docker build` passes `--no-cache`, so the CI fingerprint step and the superseded-run script are gone. The binary is built with `-trimpath -s -w`, and a build that has `.git` but no version fails. The development run keeps its databases outside the checkout.

Deviation: `.dockerignore` also leaves out SQLite databases at any depth.

Model: opus-5-5
2026-10-06 06:05:42 +02:00
clawbot 46fe7baed0 Mask a target URL's query string when the URL has no path (closes #500)
check / check (push) Successful in 5m57s
`urlSecrets` treated a target URL's request URI as a secret only when the URL had a path, so for a target URL such as `https://example.com/?token=…` a response echoing the request line showed the token in the event log and on the event's page. It now also treats the query string, and the request URI that carries it, as secrets whenever the URL has one, whatever its path. With "Pass the query string on to this target" on, only the target's own part is masked, not the event's. A URL with a path is masked as before. As for paths and userinfo, no length floor applies.

Model: opus-5-5
2026-10-04 03:31:37 +02:00
clawbot ea8cba7264 Store and show an event's query string, and pass it on to an HTTP target when set (closes #312)
check / check (push) Successful in 4m45s
The receiver dropped the query string of every request it received, so a sender's URL parameters were silently lost. Each event now keeps it, as sent, in a new `raw_query` column of the per-webhook `events` table; a resubmitted copy carries its original's. The event log and the event's page show it in the shared request block, the event log leaving out one over 32 KiB with a link, as for headers. The archive and log targets carry it. HTTP targets gain "Pass the query string on to this target", off by default: on, deliveries, replays and resubmits append it to the target URL, joined with `&` to one already there. The access log still hides it.

Model: opus-5-5
2026-10-04 03:00:34 +02:00
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
clawbot da75950e91 Targets section: one Add, then a target type and Next, then that type's fields (closes #370)
check / check (push) Successful in 3m18s
The targets section of the webhook page showed its add form open with every field, a URL included for types that use none. It now lists only its targets until "+ Add" is clicked; "+ Add" shows a choice of target type with Next and Cancel on one row, and Next shows the name and only that type's fields. The database and log types show no URL field and the server stores none for them; the slack form gains its retry field. A refused target brings the page back with the form open on its type, the values entered and the reason, and Cancel empties it. An encoding failure stays a logged 500. Target validation returns its message, so the new-webhook page can reuse it.

Model: opus-5-5
2026-10-02 22:24:38 +02:00
clawbot 719d7013ee Give each event its own page and show bodies the same everywhere (closes #369)
check / check (push) Successful in 3m17s
Each event now has its own page at /hook/ID/events/EVENTID, behind the login, showing its details, its whole body and every delivery; a resubmitted copy links to its original's page. The recent events on the webhook page link there and expand to show their bodies, only the newest expanded on load. One renderer and one template show a body the same way in the recent events, the event log and the event's page: whole up to 32 KiB, cut there in the two lists with links to the event's page and the download; JSON pretty-printed unless that would grow it past four times plus 1 KiB; over 200 lines in a scrolling box; a body holding NUL or control characters treated as binary and never dumped raw.

Model: opus-5-5
2026-10-02 22:00:42 +02:00
clawbot faf7ca1a5e Report or refuse each unusable file webhooker reads (closes #290)
check / check (push) Successful in 3m20s
An audit of every file webhooker reads configuration or required state from found cases that carried on silently. A zero-length webhooker.db, and a missing or zero-length per-webhook database, now log the "created a new, empty database" warning naming the file; restart recovery opens every live webhook's database, checking under the manager's lock that it still exists, so a missing one is reported at start. The main database's open errors name webhooker.db, for the server and webhooker resetpw; resetpw refuses a zero-length webhooker.db. A directory in place of any database file or its -wal or -shm is refused naming it. The README says how each case is treated. Also closes #459.

Model: opus-5-5
2026-10-02 21:38:47 +02:00
clawbot 0f5f6ba6bf Let an entrypoint's description be edited in place (closes #392)
check / check (push) Successful in 3m19s
An entrypoint's description was set when it was added and could never change, so renaming one meant deleting it and adding a new one with a new URL every sender had to be given again. Each entrypoint on the webhook page now has an Edit button, in the shared secondary style, that opens its description in place with Save and Cancel and keeps its URL. The save goes through the same login, CSRF and ownership checks as the other entrypoint actions; an empty description shows as "Entrypoint". Activate and deactivate now write only the active column, so they cannot undo an edit. Tests cover each, through the router and the browser.

Model: opus-5-5
2026-10-02 21:32:43 +02:00
clawbot 820d9391ff Index the event log's resubmit count with deleted_at (closes #325)
check / check (push) Successful in 3m22s
The event log's resubmit count, run on every page load over up to 25 event ids, read every live event in the webhook's database: GORM adds deleted_at IS NULL, and SQLite, keeping no statistics there, chose the deleted_at index over the resubmitted_from_id one. deleted_at is now the second column of idx_events_resubmitted_from_id, so the count is answered from that index for a whole page of events. A test checks SQLite's plan for the statement as GORM builds it, with a full page of ids. The README's event-tier indexes table lists the index. Pre-1.0: the index changes in the schema in place, with nothing for older databases.

Model: opus-5-5
2026-10-02 21:23:33 +02:00
clawbot 2967c475a1 Show a database target's archive file, size and last write in the target list (closes #397)
check / check (push) Successful in 3m17s
For a database target, the target list showed only its expiry, so the archive file the README's backup and move-away advice depend on could only be found from a shell on the host. Each database target now shows its archive file's name, its size on disk (the file and its -wal together) and when it was last written, relative with the full UTC time on hover, all from the files' metadata without opening the archive. Before the first write, and after the file has been moved away, it shows the name and "not created yet". Tests cover all three states.

Model: opus-5-5
2026-10-02 21:22:33 +02:00
clawbot 35d2f28c67 Name each embedded static file so a missing Alpine.js fails the build (closes #166)
check / check (push) Successful in 3m20s
static/static.go embedded the css and js directories, so a build without the extracted Alpine.js file compiled and produced a binary whose admin pages silently had no Alpine. It now names the four files the pages load, so such a build fails naming js/alpine.min.js; make build extracts the file first, so the failure shows when that step is skipped. Both lint stages extract Alpine.js before linting, since the static package no longer compiles without it, and the README's lint stage says so. static/css/input.css, the Tailwind source no page loads, is no longer embedded or served.

Model: opus-5-5
2026-10-02 20:56:35 +02:00
clawbot 9526e961b5 Add a Download button that exports a database target's archive as gzipped JSON (closes #374)
check / check (push) Successful in 3m29s
Each database target on the webhook page has a Download button that streams its archive as gzipped JSON, archive-WEBHOOKNAME-TARGETNAME-TIME.json.gz, with names made safe by delivery.ArchiveFileName's function. The export reads one consistent snapshot through one cursor in a read-only transaction, so archive writes carry on, and holds the rename lock only while it reads the stored names and opens the file. It extends its write deadline as it writes, so a large archive downloads for as long as the client reads; a failure after the response has started aborts the connection so the browser marks the download failed. The request limit is now the service's own middleware, which no longer writes a 504 over a response already started.

Model: opus-5-5
2026-10-02 20:32:09 +02:00
clawbot 4915d60d8e Route the delivery tests' gorm.Open through gormlog (closes #462)
check / check (push) Successful in 3m17s
Six test-only gorm.Open calls in internal/delivery passed a bare gorm.Config, leaving the unfiltered idiom in the tree to be copied into production code, where every gorm.Open goes through gormlog.New. They now pass gormlog.New over a logger that discards, so no gorm.Open in the tree uses a bare gorm.Config. The stale sentence saying the tree has one test-only (*gorm.DB).Scan caller is corrected in the README and in the ParamsFilter comment: only tests call Scan, and what a test binds is fixture data. Test and documentation change only.

Model: opus-5-5
2026-10-02 20:20:49 +02:00
clawbot 0945831442 Clamp the HTTP drain by the tail-hook reserve (closes #170)
check / check (push) Successful in 3m17s
The HTTP drain at shutdown waited up to ShutdownTimeout regardless of how much of the stop budget earlier hooks had used, so a slow archive sweeper or retention reaper could eat the reserve the hooks after the server need, and the database close was skipped. The drain now waits at most the shorter of ShutdownTimeout and what is left of the budget less TailHookReserve, as the Sentry flush already does. The reserve is documented as derived from the two timeouts. Tests cover earlier hooks having spent part of the budget, on a clock that host speed cannot move, and pin that a drain on the full budget gets all of ShutdownTimeout.

Model: opus-5-5
2026-10-02 20:11:43 +02:00
clawbot f82b730c31 Pin the HTTP target's unpinned error checks (closes #285)
check / check (push) Successful in 3m20s
Seven error checks in internal/delivery/target_http.go could be removed without any test noticing, among them withRetry's check on writing the delivery result, the branch that leaves a sent delivery retrying and recoverable when its bookkeeping write fails. Each now fails a test when removed. The "send succeeded" case starts from a tripped circuit breaker, so a probe whose send succeeds but whose result write fails must still close the breaker. The checks in remainingBackoff and backoffElapsed stay unpinned: removing them gives the same answer, and they state a rule a reader needs. Test change only.

Model: opus-5-5
2026-10-02 19:37:36 +02:00
clawbot 1a1fee0874 Name the reaper's hard delete in the event body comments (closes #455)
check / check (push) Successful in 3m20s
The comments on eventBodyQuery and on TestHandleEventBodyDownload_ReapedEvent404s credited the soft-delete predicate for refusing a reaped event. The retention reaper deletes event rows outright and nothing soft-deletes an event, so a reaped event is simply gone. Both comments now say so; the test's "soft deleted" case is described as pinning the query's deleted_at predicate for a row no code produces today. Comments only.

Model: opus-5-5
2026-10-02 19:36:30 +02:00
clawbot 290925f184 Fix two resubmit comments and test the resubmit route's middleware (closes #252)
check / check (push) Successful in 3m18s
Two comments named the wrong mechanism: loadResubmitSource credited soft-delete for refusing a reaped event, though the retention reaper deletes event rows outright, and createAndFanOut claimed to be the only path that creates deliveries, though per-delivery replay creates one without an event. Both now say what the code does. The resubmit route's middleware had no tests through the router; new tests drive the production router to pin the refusal without a valid CSRF token, the rate limit, signed-out requests never spending it, and another webhook's event refused by the event lookup while the user's own event is accepted. Each fails with its check removed.

Model: opus-5-5
2026-10-02 19:20:40 +02:00
clawbot 73353bc8e5 Harden the (*gorm.DB).Scan guard test (closes #232)
check / check (push) Successful in 3m17s
The test that refuses production calls to (*gorm.DB).Scan, the one GORM path that bypasses the logger's value suppression, overstated what it checks and could pass while skipping a whole package. Its comments now say it matches receiver method names, not types, and name the evasion this leaves; GORM's Rows is dropped from the accepted names. Method values are stated as out of scope with the reason. The file-count floor is replaced by a check that every package the walk parses, static and templates included, was reached. The planted snippets are valid Go and cover each receiver form the guard claims to handle. Test change only.

Model: opus-5-5
2026-10-02 19:19:45 +02:00
clawbot 40f59ec4d2 Try every event database file on delete and say which was left (closes #275)
check / check (push) Successful in 3m20s
DeleteDB returned on the first event database file it failed to remove, so with WAL sidecars present a failure could be reported as "the file was left behind" after the events themselves were already gone. It now tries the database file and both sidecars, and its error says which case happened and names each file left: the database file is still on disk, or it is gone (the events are lost) and only a -wal or -shm sidecar remains. The webhook delete handler logs a different message for each and never says the events survive when they do not. Tests cover a normal delete with both sidecars, and a failed removal of the database file or a sidecar, through DeleteDB and the handler.

Model: opus-5-5
2026-10-02 18:42:29 +02:00
clawbot 806c95e305 Make every clickable control look clickable, in two shared styles (closes #375)
check / check (push) Successful in 3m16s
Many controls were plain coloured text with no sign they could be clicked: Edit, Activate, Deactivate, Delete, Replay, Resubmit, both Add controls and the copy control beside each entrypoint URL. Every clickable control is now a real button or link in one of two shared styles: the buttons in input.css, now with a pointer cursor, and btn-small, a small bordered secondary action in style.css, which the layout now loads. The site name, the footer links and each webhook list card show at rest that they open something, with focus states. The copy control shows "Copied" after a click. Rows on the webhook page and in the event log wrap at phone width. The browser test covers the copy control.

Model: opus-5-5
2026-10-02 18:32:19 +02:00
clawbot 45bd7e9b94 Pin the close before the reopen in the archive sweep (closes #103)
check / check (push) Successful in 3m35s
The archive sweep closes a target's archive connection before it reopens the file, and no test noticed if that close was removed, which would leak one SQLite connection per target per sweep. A test now keeps the connection from before a sweep that reopens the archive and requires it closed afterwards. The sweeper's query for database targets takes the sweep's context; a sweep whose context is done returns without an error line, so stopping is not logged as a failure, and a test pins that. The comment on the sweeper's cancel function gives the true reason it needs no lock: fx calls the stop hook only after the start hook has returned.

Model: opus-5-5
2026-10-02 18:16:31 +02:00
clawbot bf3df0312b Clear the environment in the cmd/webhooker tests that build a Config (closes #452)
check / check (push) Successful in 3m16s
The two cmd/webhooker tests that build the app graph, TestNewApp_StopTimeout and TestNewApp_SendsFxEventsToTheLogger, built a Config without clearing the environment, so a variable exported in the developer's shell changed their result: a METRICS_USERNAME without METRICS_PASSWORD failed the second. Both now call config.ClearEnvForTest before setting their own variables, as the config tests and the first-boot test already do. No other test outside internal/config builds a Config through config.New. Test change only.

Model: opus-5-5
2026-10-02 18:03:31 +02:00
clawbot 503c57efd9 Assert the body a retry delivers, not only its status (closes #294)
check / check (push) Successful in 3m24s
TestProcessRetryTask_LargeBody_FetchFromDB and TestProcessRetryTask_SuccessfulRetry checked only that the delivery ended delivered, so deleting the event-body fetch on the retry path, the behaviour the first is named for, left both green while a retry could deliver an empty or truncated body. Both now compare the body the target received with the stored event body byte for byte, and both fail when that fetch is deleted. The other retry-path tests are not about the body and are unchanged. Test change only.

Model: opus-5-5
2026-10-02 18:03:20 +02:00
clawbot d084f4f912 Pin the body cap's order in every page route group (closes #93)
check / check (push) Successful in 3m18s
Follow-ups from an August review of the body cap, each checked against the current tree. One route test now requires an oversized POST, with no session and no CSRF token, to be refused with 413 before CSRF runs, in every page route group with a POST route and in /settings/, so moving a group's body cap after CSRF fails it. The three test router helpers build the server through New with a lifecycle that is never started, so no field is set by hand. The middleware test comment names runMaxBodySize, and the MaxBodySize doc comment says methods other than POST, PUT and PATCH pass uncapped on purpose. The README item was already settled.

Model: opus-5-5
2026-10-02 17:53:21 +02:00
clawbot e67fffb05d Check the log charge against every code point (closes #172)
check / check (push) Successful in 3m24s
The access log's 2,560-byte line ceiling holds only if logfield.EncodedBytes charges each code point at least what the log handlers write for it, and the test checked that on a sample. TestEncodedBytes_ChargesAtLeastWhatTheHandlersEmit now covers every code point, surrogates aside, for both handlers. Below U+1000, where the handlers' escaping varies, each code point is measured alone, both in a bare value and in a quoted one, so undercharging any of them, DEL included, fails and names it. From U+1000 up it compares batch sums, which the doc comment says can hide one JSON-only overcharge. Reverting the astral charge to 6 fails the test. It adds under 2 seconds under -race.

Model: opus-5-5
2026-10-02 17:53:10 +02:00
clawbot 4958a6f2e4 Isolate config tests from the shell; any out-of-range PORT is ErrInvalidPort (closes #94)
check / check (push) Successful in 3m17s
The config tests unset variables without restoring them and read whatever the developer's shell exported, so results could differ from one machine to the next. config.ClearEnvForTest, in internal/config/testing.go, unsets every variable in the process environment and, when the test ends, leaves it exactly as it found it; every config test and the first-boot test call it before setting their own. TestEnvPositiveInt and TestEnvPort share one table runner, and the port errors name the bad value. A PORT of zero, below zero, or too large to parse now matches ErrInvalidPort, as one above 65535 already did. The README and the Settings page say an unparseable or non-positive RETENTION_SWEEP_INTERVAL stops startup.

Model: opus-5-5
2026-10-02 17:44:30 +02:00
clawbot 385fbc1a6a Send fx's own events through the service's logger (closes #183)
check / check (push) Successful in 3m14s
fx printed its dependency graph and lifecycle hooks through its own console logger on standard error, so an operator shipping the JSON log to a collector got a second shape on a second stream for every start. The production app now passes fx.WithLogger with a small FxLogger in internal/logger that writes fx's events through the service's logger: graph events at debug, lifecycle at info, failures at error. Its constructor takes the configuration, so DEBUG=true applies before fx replays the events it held back. go.uber.org/fx moves from v1.20.1 to v1.24.0. Tests keep fx.NopLogger. The README says a failure before the logger exists, and the Go runtime's own output, still go to standard error as plain text.

Model: opus-5-5
2026-10-02 17:22:05 +02:00
clawbot c22ca6218e Pin the rate-limit key for an empty RemoteAddr (closes #168)
check / check (push) Successful in 3m25s
A request whose RemoteAddr is empty has no peer identity, so the rate limiters' key falls back to the raw empty string and every such request shares one bucket: it fails closed rather than giving each its own. net/http always fills RemoteAddr for a TCP listener, so normal serving never reaches this. The behaviour is unchanged and now deliberate: a test pins the shared key, and a one-sentence comment at the fallback tells the empty case apart from a Unix-socket listener, where every peer legitimately carries the same address.

Model: opus-5-5
2026-10-02 17:09:23 +02:00
clawbot 0ccb01cada Close the retention follow-ups from the August review (closes #99)
check / check (push) Successful in 3m17s
Follow-ups from an August review of the retention bounds, each checked against the current tree. A test now pins that a retention value above the keep-forever sentinel is stored as the sentinel. The form's retention parser returns its message directly, so the two error values that were never compared, and the function that mapped them to messages, are gone. The sweep's own keep-forever skip, which duplicated the check in retentionCutoff, is removed; the cutoff is now asked before the webhook's database is opened. The create-form refill test uses HTML-special characters and checks they come back escaped. The README item was already settled; handling for rows made by hand is declined.

Model: opus-5-5
2026-10-02 16:50:34 +02:00
clawbot 1f22b30de3 Log the client address next to the peer address (closes #270)
check / check (push) Successful in 3m34s
Behind a trusted proxy every log line named only the proxy, so abuse could not be traced from webhooker's own logs although the rate limiters already knew the client. The access log, the rate-limit rejection lines, the CSRF warning and the receiver's request line now carry clientIP next to remoteIP. remoteIP still means the connecting peer; clientIP is the address the rate limiters key on, the forwarded client when the peer is inside TRUSTED_PROXIES, worked out once per request by the same code. The README says the field is only as trustworthy as TRUSTED_PROXIES. The access log's 2,560-byte line ceiling holds with the field charged, and a size case with an oversized X-Forwarded-For pins it.

Model: opus-5-5
2026-10-02 16:50:22 +02:00
clawbot debe588bba Seed the retention tests 50 rows per insert, not 500 (closes #198)
check / check (push) Successful in 3m18s
Three retention tests made internal/database the slowest test package, mostly by seeding thousands of rows 500 per insert: the SQLite driver finds each parameter's value by scanning the statement's arguments from the first until it reaches that parameter's, so binding grows with the square of the parameter count. They now seed the same rows 50 per insert, about three times faster; no test case or assertion changes. The package drops from 13 to 22s to about 7s. What keeps make test above the 20s target is now mostly the cold -race compile of the tree, which moves with host load. The 90s per-package timeout stays; script/test's header records the new figures.

Model: opus-5-5
2026-10-02 16:27:00 +02:00
clawbot e8379272ae Load Alpine's CSP build so the UI's directives run (closes #371)
check / check (push) Successful in 3m27s
Every page's Content-Security-Policy forbids eval, which the standard Alpine.js build needs, so no directive ran in a browser: both add forms on the webhook page showed open, and events in the event log could not be collapsed. The UI now loads Alpine's CSP build (@alpinejs/csp 3.14.9 in 3p/); the policy is unchanged. Each directive names a property or method of a component registered in static/js/app.js (collapsible, targetForm), and each card holds its own x-data. A browser test, built only with the browser tag, loads the webhook page and the event log under the real headers; make test-browser runs it in Docker. New test-only dependency chromedp, which raises golang.org/x/sys to 0.47.0.

Model: opus-5-5
2026-10-02 16:09:03 +02:00
clawbot 3e209bfe4e Drive the archive reopen debounce test from a clock (closes #190)
check / check (push) Successful in 3m27s
The archive writer's reopen debounce test made two writes that had to land inside the real 2-second window, then slept 2.1 seconds to cross it, so a slow host could turn correct code red. The archive writer now reads the time for its reopen debounce from a clock field, time.Now in production, and the test moves that clock instead of sleeping: two writes at one instant open the file once, and a write one debounce later closes and reopens it once. Removing the debounce check fails the test. This was the last test whose result depended on real elapsed time.

Model: opus-5-5
2026-10-02 16:03:01 +02:00
clawbot 88b961c115 Keep each model's parent out of its JSON (closes #177)
check / check (push) Successful in 3m16s
Every reference from a model to the record it belongs to (a target's or entrypoint's webhook, a webhook's or API key's user, an event's webhook and entrypoint, a delivery's event and target, a delivery result's delivery) is now tagged json:"-", so a preloaded model can be marshalled without the encoder recursing between parent and child. References to child records stay. Tests marshal each of the nine references set, preloaded where it matters, and check the parent's id is absent, so deleting or restoring any one tag fails a test; preloading still fills each reference.

Model: opus-5-5
2026-10-02 15:43:06 +02:00
clawbot dc9deda173 Write a form-error page's status only after it renders (closes #128)
check / check (push) Successful in 3m33s
Six form-error paths (the login error and the webhook form's validation and name-taken branches) called WriteHeader before rendering, so if the form page's own template failed, the answer kept its 400 or 409 status with an error body instead of a 500. The new renderTemplateStatus renders into the buffer and writes the status only after the page has rendered; renderTemplate sends 200 through it, and those handlers pass their status to it. No handler calls WriteHeader before a render. Login-form tests show the 400 page still renders in full and a failing template answers 500.

Model: opus-5-5
2026-10-02 15:34:01 +02:00
clawbot c706199389 Name the CI build step after what the image runs (closes #175)
check / check (push) Successful in 3m48s
The workflow's build step was named "Build Docker image (runs make check)", but the image has never run make check. It is now named for what the image runs: make fmt-check, golangci-lint, make test and make build. No other step name or comment in the workflow names something it does not run. In the README, the Prerequisites bullet now says Docker is needed for make lint and so for make check, and the sentence about what runs on the host is limited to the steps make check runs.

Model: opus-5-5
2026-10-02 15:15:08 +02:00
clawbot d52cac1ec6 Toggle only a target's active state, so it cannot undo an edit (closes #431)
check / check (push) Successful in 3m27s
The target toggle loaded the target, flipped its active flag and saved the whole row, so an edit of the same target's name or settings saved in between was written back over and lost, although it reported success. The toggle now updates only the active column, so it can no longer undo an edit. A test saves an edit just after the toggle has read the target and shows the edit survives and the state flips. The entrypoint toggle is unchanged, since an entrypoint has no edit form.

Model: opus-5-5
2026-10-02 14:43:00 +02:00
clawbot 0f9b68a0e8 Build the middleware test cookie stores with the production constructor (closes #154)
check / check (push) Successful in 3m30s
The middleware tests built their cookie stores by setting store.Options by hand, which left the securecookie codecs at the library's 30-day default instead of the 7-day cap production sets, an invisible divergence that would outlive the next change to store construction. The test store constructor moves from internal/session/export_test.go into internal/session/testing.go so other packages can reach it, and the two middleware test helpers build their stores through it. No test in the repo builds a cookie store by hand any more, and no assertion changes.

Model: opus-5-5
2026-10-02 14:30:51 +02:00
clawbot 8cf5acaf1d Justify the handlers tests' start limit with a measurement (closes #225)
check / check (push) Successful in 3m57s
The internal/handlers tests were load-fragile because every test app hashed the admin password at 64 MB; that went with the cheaper test hashing already on next, and measuring under the host's real load found nothing left to fix in how the tests run. The comment on newTestApp now says its start limit, fx's default, is there to catch a start that hangs, and that the slowest measured start is far inside it. The header of script/test gives current figures in place of ones from before that change. Neither limit changes, and no test changes.

Model: opus-5-5
2026-10-02 14:08:54 +02:00
clawbot 9ade217222 Show each webhook's activity in the webhook list (closes #394)
check / check (push) Successful in 3m38s
Each entry in the webhook list at /hooks now shows when its last event arrived (or "No events yet"), how many of its deliveries failed in the last 24 hours, in red when not zero, and how many entrypoints and targets are inactive, as in "4 targets, 1 inactive". The figures come from the event totals row and the statistics pane's own query, so the list no longer counts every stored event and its event count matches the pane's. A webhook whose event database cannot be read says so in its entry; the rest of the list still shows. A failed read of entrypoints or targets from the main database now returns an error page instead of showing zero.

Model: opus-5-5
2026-10-02 13:57:43 +02:00
clawbot 5551f75251 Say at the receiver route where its 1 MB body cap lives (closes #173)
check / check (push) Successful in 3m22s
The receiver route /h/{uuid} has no MaxBodySize middleware, unlike the page route groups; its 1 MB cap is enforced in the handler, which owns the response senders see. Nothing at the route said so, so a reader could take the receiver for uncapped or remove the only bound on the one unauthenticated endpoint. The route registration and the handler's body-reading function now say where the cap lives and why. A routing test sends a body of exactly 1 MB and one a byte over through the production router and pins that only the second is refused, with the handler's 413 and message.

Model: opus-5-5
2026-10-02 13:40:00 +02:00
clawbot fd036774f9 Name each database target's archive for its webhook and target (closes #376)
check / check (push) Successful in 3m29s
Each database target now writes its own archive file, archive-WEBHOOKNAME-TARGETNAME-TARGETID.db, named by delivery.ArchiveFileName, in place of one archive per webhook keyed on its UUID. Renaming a webhook or a target renames its archive files (with any -wal and -shm) before the new name is saved, never over an existing file, and moves every one back if a rename or the save fails. The webhook edit, the target edit and target creation share one lock so no two interleave. Deleting a webhook or target leaves its files on disk. Nothing looks for the old archive-WEBHOOKID.db files. The README gives the naming and the recovery steps.

Model: opus-5-5
2026-10-02 13:28:49 +02:00
clawbot 21aafbf928 Remove the no-op MAINTENANCE_MODE setting (closes #317)
check / check (push) Successful in 3m25s
MAINTENANCE_MODE did nothing but make the healthcheck JSON report maintenanceMode: true; no request was ever served differently, so an operator who set it expecting requests to be refused got nothing. It is removed from the configuration, the startup configuration log line, the healthcheck JSON, the README and the Settings page, together with the uncalled Server.MaintenanceMode method and the healthcheck's dependency on the configuration. A leftover value in an environment is ignored like any other unknown variable.

Model: opus-5-5
2026-10-02 13:27:43 +02:00
clawbot c87b469dcd Let a handler flush or set a write deadline through the access log and metrics (closes #191)
check / check (push) Successful in 3m17s
The access log's response writer and the metrics middleware's writer hid the writer beneath them, so a handler's flush, hijack or write deadline set through http.ResponseController failed with "not supported" behind them. The access log's writer now has Unwrap. The metrics middleware calls the library's public Measure with a writer of our own that has Unwrap, in place of std.Handler's writer, so the middleware order and what metrics record are unchanged. A test over a real connection sets a write deadline and flushes, metrics on and off, on a global route and in an admin page group, and fails without either Unwrap.

Model: opus-5-5
2026-10-02 13:08:54 +02:00
clawbot b14b27b78b Serve the delivery duration histogram from boot (closes #267)
check / check (push) Successful in 3m25s
The delivery duration histogram was registered only when a delivery first ran, so until then /metrics had no series for it and a dashboard or alert on it saw nothing at all rather than zero. Each of the four target types now has its series registered at boot with zero counts, so the histogram is served from the first scrape. Tests pin that all four series are present before any delivery, on the metrics registry and through the production router.

Model: opus-5-5
2026-10-02 12:45:05 +02:00
clawbot 287e47df7f Add a read-only Settings page for the loaded configuration (closes #402)
check / check (push) Successful in 3m19s
A new page at /settings, linked from the navigation bar and behind the login, lists every configuration field the server loaded at startup: its environment variable, the README table's description, and the value in effect. METRICS_PASSWORD and SENTRY_DSN show only as set or not set; their values are replaced before rendering and never reach the template. The route is GET only and its group is built like the other admin page groups. Tests set the credentials one at a time so each value shown is checked against its own field and a set secret never appears in the page.

Model: opus-5-5
2026-10-02 12:30:21 +02:00
clawbot 8b5541734e Run script/test quietly with coverage, rerun failed tests verbosely (closes #315)
check / check (push) Successful in 3m20s
script/test now runs the suite once with -race -cover and no -v. On a failure it reruns only the failed top-level tests, with -v, in the packages that failed, then exits non-zero whatever the rerun shows. A green run stays quiet, and a red one ends with the failing tests' verbose output, which the Docker build's 2 MiB log limit can hold; a verbose rerun of every package would pass that limit again. A failure that names no test (a build error, a timeout) skips the rerun, since the quiet run already prints that package's output. The timeout and parallelism are unchanged.

Model: opus-5-5
2026-10-02 11:58:43 +02:00
clawbot c513816a55 Refuse [::], 0.0.0.0, IPv6 multicast and documentation space (closes #341)
check / check (push) Successful in 3m20s
On the build host a connection to [::] reaches a listener on ::1, and one to 0.0.0.0 reaches 127.0.0.1, so a delivery target at either reached this host's loopback past the guard. 0.0.0.0/32 and ::/128 are now in alwaysBlockedNetworks, which no allowlist opens; an allowlist reaches loopback only through an entry covering a loopback address. IPv6 multicast (ff00::/8) and documentation space (2001:db8::/32) are refused by default and reopen when listed.

Every default blocklist entry has a one-line comment, each list is pinned on its own, and tests refuse each address at target creation and at delivery. The README and the rules above each list match.

Model: opus-5-5
2026-10-02 11:22:30 +02:00
clawbot 2bb4683512 Discard fx's own log in tests that build an fx app (closes #230)
check / check (push) Successful in 3m19s
Every test that builds an fx app with fxtest.New (handlers, server, resetpw, gormlog, config) now passes fx.NopLogger, so fx's own log no longer goes to t.Logf. A hook still running after a start or stop timeout can then no longer write to a test that has already returned, which the race detector reported as a data race. What the tests assert is unchanged, and nothing about the race detector is suppressed.

Model: opus-5-5
2026-10-02 11:08:38 +02:00
clawbot 5b1d283d06 Say what each action did in a one-line notice (closes #383)
check / check (push) Successful in 3m20s
Saving, deleting, activating or deactivating a webhook, entrypoint or target, and signing out, now land on their page with a one-line notice such as "Webhook deleted." or "Signed out.". The redirect carries a fixed code that maps to fixed text; an unknown code shows nothing, so nothing from the URL is ever echoed. One partial in the page layout shows the notice on every page, and replay and resubmit now use the same codes and partial. Error pages show no notice.

Model: opus-5-5
2026-10-02 10:30:31 +02:00
clawbot b78abdc9da Drop TODO.md's stale docs-only cache caveat (closes #421)
check / check (push) Successful in 3m11s
Since the build context carries .git and the CI fingerprint is the hash of the commit being checked, every commit's check runs the build, docs-only commits included. TODO.md's caveat that a docs-only commit replays from the layer cache was no longer true, and the README's "CI gate honesty" section already says how a check runs, so the paragraph is removed.

Model: opus-5-5
2026-10-02 10:14:40 +02:00
clawbot c23ffbac65 Widen the webhook page by half so an entrypoint URL fits on one line (closes #350)
check / check (push) Successful in 3m16s
The webhook page's maximum width goes from 72rem (1152 px) to 108rem (1728 px), half again as wide, so an entrypoint URL stays on one line in 1920- and 1440-pixel windows; the statistics pane and both columns widen with it. The title row now wraps, so a phone-width window no longer scrolls sideways.

The width is an inline style: static/css/style.css is linked by no page, and the committed Tailwind stylesheet has no class that wide. The webhook list, the event log, the navbar and the footer stay at 72rem.

Model: opus-5-5
2026-10-02 09:49:33 +02:00
clawbot 2ac4d4d793 Send the build version in the outbound User-Agent (closes #313)
check / check (push) Successful in 3m25s
The http and slack targets sent the constant User-Agent webhooker/1.0. They now send webhooker/ followed by the version the build stamped, the same value the footer shows, built in one place on the delivery engine. User-Agent stays a reserved header and is still set after the target's configured headers, so a configured one cannot override it. Tests check the header each target sends against a known version, and that a configured User-Agent is replaced.

Model: opus-5-5
2026-10-02 09:48:34 +02:00
clawbot eb4c4cc849 Serve /metrics from a registry of its own (closes #227)
check / check (push) Successful in 3m25s
A second metrics-enabled router in one process panicked on a duplicate collector registration, because every collector registered on Prometheus's global default registry. metrics.NewRegistry now builds one registry with the Go runtime and process collectors; fx provides it and the delivery metric set built on it. The middleware builds its HTTP recorder once on that registry (NewForTest on a fresh one), the engine and handlers take the metric set from fx, and nothing registers on the global default any more.

/metrics is served from the new registry with the same series names, labels and auth. A test builds two metrics-enabled routers in one process.

Model: opus-5-5
2026-10-02 09:06:20 +02:00
clawbot cb7bafab17 Derive the image's version from the .git in the build context (closes #366)
check / check (push) Successful in 3m23s
upaas uploads its clone as a tar context, which .dockerignore does not filter, so its builds already carried .git; the unknown default of the VERSION build arg is what stamped them. The image now derives its version itself: ARG VERSION has no default, so make build falls back to script/version, which runs git describe on the copied .git; a VERSION build arg still wins.

.dockerignore sends .git without its config in a directory context and no longer leaves out tracked files, which would mark the tree -dirty. The builder installs git, trusts /build as a safe.directory, and fails when .git is present but the version comes out unknown. A shallow single-branch clone stamps its short commit.

Model: opus-5-5
2026-10-02 08:41:20 +02:00
clawbot 2416528b77 Render admin page errors in the normal layout (closes #382)
check / check (push) Successful in 3m12s
Every 400, 403, 404 and 500 on an admin page now answers with an error page in the normal layout: the status, one fixed line explaining it, and a link back to the webhook list, or to sign-in when nobody is signed in. Unknown paths reach it through the router's not-found handler, a refused form token through the CSRF middleware, and a panic through a recoverer each admin page route group installs first. Status codes are unchanged, and the page always sends Cache-Control: no-store.

If the error page fails to render, the answer is the same status in plain text; if it panics, the answer is a 500. The receiver, the healthcheck and /metrics keep their plain answers.

Model: opus-5-5
2026-10-02 08:15:12 +02:00
clawbot 38157d8936 Say how to allow a refused private target address (closes #398)
check / check (push) Successful in 3m16s
Refusing an http or slack target whose address is private or reserved, on add or edit, now adds one sentence: such addresses are refused by default, and the server's ALLOWED_EGRESS_CIDRS setting allows named networks, with a pointer to the README section. It suggests no value, so it never points at allowing everything.

Metadata refusals get no such sentence. To tell them apart, the default blocklist's public addresses now have their own list, blockedPublicNetworks, still checked after the allowlist; a test pins which addresses each list refuses and how listing opens them, unchanged from before.

Model: opus-5-5
2026-10-02 07:52:50 +02:00
clawbot 7d360babed Logging in returns to the page that was asked for (closes #384)
check / check (push) Successful in 3m44s
A logged-out GET of an admin page now redirects to /pages/login with its path and query in a next parameter, when they fit in 2048 bytes. The login form carries next as a hidden field; a successful login redirects there, and a failed one shows the page again with the same next. A POST still redirects to plain /pages/login.

The value is client-chosen, so every read of it goes through one check: after percent-decoding it must start with exactly one / and contain no backslash or control character; anything else becomes /. gosec's open-redirect rule is suppressed on the two redirects that follow it. The login page's navigation bar no longer links to itself.

Model: opus-5-5
2026-10-02 06:57:51 +02:00
clawbot 803a94be37 Add a statistics pane to the webhook page (closes #368)
check / check (push) Successful in 3m56s
The webhook page opens with a statistics pane: entrypoints and targets, deliveries in progress, the last arrival, the retention period; events, deliveries and failures, lifetime and within retention; and events, failures and failure percentage over 10 minutes and 24 hours.

Running totals, one row per target plus one for events, sit in each webhook's event database and are written in the same transaction as the rows they count; retention prunes in paused, stoppable batches and subtracts what it removes. Window figures are index-range counts on a new finished_at column. An existing event database must be recreated.

Model: opus-5-5
2026-10-02 06:41:00 +02:00
clawbot 43ed8d4834 Move webhook pages to /hook/ID and inbound URLs to /h/UUID (closes #367)
check / check (push) Successful in 3m10s
The webhook page and everything under it move from /source/ID to /hook/ID; the list and new-webhook form from /sources to /hooks and /hooks/new; the event log from .../logs to /hook/ID/events; entrypoint URLs, the ones senders post to, from /webhook/UUID to /h/UUID. The old paths are simply gone, per the owner's ruling. Every link, redirect, form action, test, comment and README mention follows; every link to the event log, and its title and heading, read "Full Event Log".

Keeping the UUID out of logs, metrics labels and error reports, and the receiver rate limits, key on the route itself, so they cover /h/ unchanged. Routed tests follow every link and form the pages render to a moved page.

Model: opus-5-5
2026-10-02 06:32:14 +02:00
clawbot 1c721ede41 Run the test suite without -v so a failure shows in the build log (next side of #414)
check / check (push) Successful in 3m23s
script/test now runs go test without -v. The Docker build cuts each step's log at 2 MiB, and the verbose output of the whole suite passed that limit before any failure was printed, so a red script/cibuild showed only passing packages. Without -v, go test prints one result line per package and, for a package that fails, everything its tests wrote, the application's log lines included. -race, -p 4 -parallel 8 and the 90-second per-package timeout are unchanged.

The cause of the red builds was fixed by the cheaper test hashing already on next; the same two changes go to main separately.

Model: opus-5-5
2026-10-02 06:31:53 +02:00
clawbot bfdbc937c6 Keep make test under 2 GB of memory (closes #344)
check / check (push) Successful in 3m41s
Every test that starts a database seeds the admin account, hashing its password with Argon2id at 64 MB, about 150 MB under -race, and internal/handlers and internal/database ran dozens of those at once. That was the memory, and most of internal/handlers' run time; the product code does not leak.

HashPassword now hashes at a 1 MB cost only when testing.Testing() reports a test binary, so every test package gets it with nothing to add and a binary built by go build always hashes at the shipped parameters. TestHashPassword_ShippedParameters still hashes and verifies through HashPassword at the shipped cost. script/test adds -p 4 -parallel 8.

Model: opus-5-5
2026-10-02 03:02:28 +02:00
clawbot 1cafaeb953 Reword the build-architecture history line in TODO.md (closes #412)
check / check (push) Successful in 5s
The architecture is read at run time, so nothing passes it in at build time. The dated history entry for issue 31 in TODO.md now says a build-architecture global was removed, without naming it, so the word no longer appears in the tree.

Model: opus-5-5
2026-10-02 01:08:22 +02:00
clawbot 515c359e56 Trust the RFC 1918 ranges as proxies when TRUSTED_PROXIES is unset (closes #333)
check / check (push) Successful in 4m43s
Unset or empty, TRUSTED_PROXIES now defaults to 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16, so a reverse proxy reaching webhooker from one of those ranges gets per-client rate-limit buckets with nothing set. A set value replaces the default entirely; an unparseable one still fails startup. The startup warning for an empty list is gone.

The README gives the default, one rule (if any client can reach webhooker, or the proxy in front of it, from an RFC 1918 source address, set the list to the proxy's address alone), and where to find that address: the remoteIP field of the http request log line. Loopback is not in the default.

Model: opus-5-5
2026-10-02 00:41:36 +02:00
clawbot 30e65dce53 State what the default blocklist covers (closes #244)
check / check (push) Successful in 5m9s
The README's egress section and the comment above blockedNetworks now state what the default blocklist covers: the IPv4 private and reserved ranges, IPv6 loopback, unique local and link-local addresses, and certain public addresses, each added only because it hands credentials, user data or bootstrap material to whatever can reach it without the caller presenting anything. That is the same material as the rule above alwaysBlockedNetworks, so a future candidate can be accepted or refused against one rule.

A provider's other public addresses, such as 161.26.0.0/16 and 166.8.0.0/14, are not refused. Docs and a comment only; the lists are unchanged.

Model: opus-5-5
2026-10-02 00:21:35 +02:00
clawbot 1ac4fe0be4 Show 50 recent events with size, processing time and status (closes #347)
check / check (push) Successful in 4m32s
The webhook page's list, now headed "50 Most Recent Events", shows the 50 newest events, limited in the query. Each row adds the time received (relative, UTC on hover), the body size, the processing time (slowest delivery, queued to final outcome, retries included, "in progress" while pending), and, when the webhook has exactly one HTTP target, that target's latest HTTP status, coloured by class.

Each event now records its body size in a new body_bytes column when it is stored, so the list never reads bodies; the list loads only the delivery and attempt columns it shows, and a failed load is an error, not an empty list.

An existing event database must be recreated: the column is added in place, with no migration.

Model: opus-5-5
2026-10-01 23:50:00 +02:00
clawbot a56f1fe0c8 Remove the fixed Account Type row from the profile page (closes #401)
check / check (push) Successful in 4m38s
The profile page no longer shows the fixed "Account Type: Standard User" row. webhooker has one account, the administrator it creates, and no account types, so the row was untrue. The Account Information section now lists the username alone, and a test checks the row stays gone.

Model: opus-5-5
2026-10-01 23:24:41 +02:00
clawbot 9d29baaa2d Commit the Alpine.js tarball in 3p/ and extract it at build time (closes #345)
check / check (push) Successful in 6m21s
The build no longer downloads Alpine.js. Its npm package tarball is committed as 3p/alpinejs-3.14.9.tgz, byte for byte the file script/fetch-assets downloaded, with the sha256 that script pinned. script/assets (make assets) extracts package/dist/cdn.min.js to the ignored static/js/alpine.min.js; script/test runs it, so make test, make check, the pre-commit hook and the Dockerfile get the file with no network access, and make build, run and dev run it too.

Removed: script/fetch-assets, its Dockerfile step, static/vendor.sha256 and static/vendor_test.go. The README describes the new flow.

Model: opus-5-5
2026-10-01 22:44:41 +02:00
clawbot 507980a347 Bound username length at creation (closes #184)
check / check (push) Successful in 4m8s
Usernames are limited to 1024 bytes, so no account can exist that is unable to log in. The username rides in the session cookie, which browsers and securecookie refuse past about 4 KB, leaving room for roughly 2000 bytes of username; the limit is about half that.

User.BeforeSave returns ErrUsernameTooLong when a whole User is created or saved. A byte-counting check constraint on users.username catches every other write, including a column update. The limit appears in the constant and in the struct tag; a test fails if they disagree.

Model: opus-5-5
2026-10-01 21:38:48 +02:00
sneak f703b72ce0 Next (#364)
check / check (push) Successful in 3m59s
Reviewed-on: #364
2026-09-29 13:05:57 +02:00
clawbot b79e4649a1 Container sets its data directory's owner and mode itself (#353)
check / check (push) Canceled after 0s
Closes #340.

The image no longer sets `USER`. Its new `ENTRYPOINT`, `deploy/docker-entrypoint.sh`, starts as root, creates `DATA_DIR` if missing, gives the directory and anything in it owned by another user to `webhooker` (UID 1000), sets the directory to `0750`, and runs the command as `webhooker` through `su-exec`. An empty root-owned bind mount, or data left by another UID, now works as mounted; the app never runs as root and is still PID 1. `CMD` is still `/app/webhooker`, so the `resetpw` commands are unchanged. Started with `--user`, the script only runs the command. It is in `/usr/local/bin`, not `/app`, which belongs to `webhooker`.

README: the UID 1000 ownership block, the upaas pre-deploy commands and the restore ownership step are gone; the upaas volume bullet names only the path.

- Judgement call: `su-exec` over `setpriv`: Alpine's small tool for this, needing only musl; busybox's `setpriv` cannot change user, and util-linux's adds `libcap-ng`.
- Deviation: `su-exec` is pinned by version (`0.2-r3`), not by hash; `ca-certificates` beside it is unpinned.
- Judgement call: each start reads every entry's owner but changes only entries owned by someone else.
- `docker exec` and the health check now run as root, since the image sets no `USER`.
- No automated test covers the script: the suite runs inside `docker build`, which cannot start a container.
- A missing host directory under upaas is sneak/upaas#235.

Model: opus-5-5
Reviewed-on: #353
Co-authored-by: clawbot <35+clawbot@noreply.example.org>
2026-09-29 13:05:16 +02:00
clawbot 1428154bbd Let a browser with cookies from an earlier database log in (closes #359)
check / check (push) Canceled after 0s
A new database brings a new session key. A browser still holding the
old session cookie got a 500 on a correct login: Session.Get returned
the cookie's decode error and the login handler answered it with a
500. Get now treats a cookie that does not decode as absent, and
logging in replaces it. gorilla/csrf already did the same for the
CSRF cookie.

A start that creates webhooker.db now logs "created a new, empty
database" at WARN with its path, shortly before the first-boot banner,
so an unexpectedly empty DATA_DIR is noticed.

The codec tests now decode through the store, since Get no longer
reports the codec's reason.

Model: opus-5-5
2026-09-29 12:58:44 +02:00
sneak 8ad2a86e4b Sneak/testdeploy (#356)
check / check (push) Successful in 11s
Reviewed-on: #356
2026-09-29 12:01:33 +02:00
sneak a891b726e5 Milestone: next into main (#342)
check / check (push) Successful in 9s
Reviewed-on: #342
2026-09-29 11:53:19 +02:00
clawbot ab63b5f777 Restrict /s/* to GET and HEAD (closes #169)
check / check (push) Successful in 3m37s
The static file server was attached with Mount, which registers every
method, so POST, PUT and DELETE on an asset were answered 200 with the
file. It is now registered for GET and HEAD only, inside a /s group
whose method-not-allowed handler answers 405 with Allow: GET, HEAD.
A method chi does not route at all, such as PROPFIND, still gets 405
from the top-level router, without Allow. The inverted test and the
README route table say the same.

Model: opus-5-5
2026-09-29 11:10:27 +02:00
162 changed files with 7617 additions and 8519 deletions
+26 -27
View File
@@ -76,6 +76,8 @@ RUN --network=none node_modules/.bin/prettier --check '**/*.md'
# depends on it. # depends on it.
# #
# golangci/golangci-lint:v2.14.0 (Debian-based), 2026-09-24 # golangci/golangci-lint:v2.14.0 (Debian-based), 2026-09-24
# Using Debian-based image because mattn/go-sqlite3 (CGO) does not
# compile on Alpine musl (off64_t is a glibc type).
FROM golangci/golangci-lint:v2.14.0@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint FROM golangci/golangci-lint:v2.14.0@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
WORKDIR /src WORKDIR /src
@@ -111,16 +113,11 @@ RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser
# Nothing is wanted from js-lint; the copy is what makes this phase run it. # Nothing is wanted from js-lint; the copy is what makes this phase run it.
COPY --from=js-lint /src/yarn.lock /dev/null COPY --from=js-lint /src/yarn.lock /dev/null
# Test phase. The tests run with cgo off and so without Go's race detector, # Test phase. -race needs cgo and so a C compiler, which the Debian Go image
# which needs cgo on Linux; sneak ruled the race detector out in # ships and the alpine one does not. `make test` (script/test) builds this
# https://git.eeqj.de/sneak/webhooker/issues/538#issuecomment-153273. # stage alone; the build stage below depends on it.
# `make test` (script/test) builds this stage alone; the build stage below
# depends on it.
# #
# golang:1.26.1-bookworm (Debian-based), 2026-03-17: the builder stage's # golang:1.26.1-bookworm (Debian-based), 2026-03-17
# image, which that stage needs for its git and make. Its test step runs
# under the image's bash, and the tests in internal/versionscript use its
# git, make and sh, and skip when they are missing.
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS test FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS test
WORKDIR /src WORKDIR /src
@@ -136,8 +133,8 @@ RUN script/assets
# -timeout applies to each package on its own, so 90s has only to clear the # -timeout applies to each package on its own, so 90s has only to clear the
# slowest one. -p 4 -parallel 8 keep the run under 2 GB of memory: at most # slowest one. -p 4 -parallel 8 keep the run under 2 GB of memory: at most
# four test binaries build or run at once, each with at most eight parallel # four test binaries build or run at once, each with at most eight parallel
# tests. The defaults are one per core, so without these the memory the run # tests. Under -race every test binary and every link costs a few hundred MB,
# needs grows with the host's core count. # so the defaults (one per core) add up to several GB on a many-core host.
# #
# The first run has no -v: go test then prints one result line per package, # The first run has no -v: go test then prints one result line per package,
# with its coverage, and for a package that fails, everything its tests # with its coverage, and for a package that fails, everything its tests
@@ -159,18 +156,20 @@ RUN script/assets
SHELL ["/bin/bash", "-o", "pipefail", "-c"] SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN --mount=type=tmpfs,target=/tmp/tests,size=512m \ RUN --mount=type=tmpfs,target=/tmp/tests,size=512m \
export TMPDIR=/tmp/tests GOTMPDIR=/tmp; \ export TMPDIR=/tmp/tests GOTMPDIR=/tmp; \
CGO_ENABLED=0 go test -cover -p 4 -parallel 8 -timeout 90s ./... 2>&1 | tee /tmp/go-test.log && exit 0; \ go test -race -cover -p 4 -parallel 8 -timeout 90s ./... 2>&1 | tee /tmp/go-test.log && exit 0; \
tests="$(awk '/^--- FAIL: / { print $3 }' /tmp/go-test.log | paste -s -d '|' -)"; \ tests="$(awk '/^--- FAIL: / { print $3 }' /tmp/go-test.log | paste -s -d '|' -)"; \
packages="$(awk '/^FAIL\t/ { print $2 }' /tmp/go-test.log)"; \ packages="$(awk '/^FAIL\t/ { print $2 }' /tmp/go-test.log)"; \
if [ -n "$tests" ]; then \ if [ -n "$tests" ]; then \
echo "--- Rerunning the failed tests with -v for details ---"; \ echo "--- Rerunning the failed tests with -v for details ---"; \
CGO_ENABLED=0 go test -v -p 4 -parallel 8 -timeout 90s -run "^($tests)\$" $packages; \ go test -race -v -p 4 -parallel 8 -timeout 90s -run "^($tests)\$" $packages; \
fi; \ fi; \
exit 1 exit 1
# Build stage # Build stage
# golang:1.26.1-bookworm (Debian-based), 2026-03-17 # golang:1.26.1-bookworm (Debian-based), 2026-03-17
# The image ships git and make, which the version step below uses. # Using Debian-based image because gorm.io/driver/sqlite pulls in
# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl. The image
# ships git and make, which the version step below uses.
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder
# Nothing is wanted from the lint and test phases or from the stylesheet and # Nothing is wanted from the lint and test phases or from the stylesheet and
@@ -212,11 +211,20 @@ RUN version="$(make version VERSION="$VERSION")"; \
fi fi
# Builds through the Makefile's build target, which runs script/assets # Builds through the Makefile's build target, which runs script/assets
# (Alpine.js, extracted from its tarball in 3p/) first. The target builds # (Alpine.js, extracted from its tarball in 3p/) first.
# with cgo off, so the binary is statically linked and runs on the Alpine
# runtime image below as it is.
RUN make build VERSION="$VERSION" RUN make build VERSION="$VERSION"
# Rebuild with static linking for Alpine runtime.
# make build already verified compilation.
# The CGO binary from `make build` is dynamically linked against glibc,
# which doesn't exist on Alpine (musl). Rebuild with static linking so
# the binary runs on Alpine without glibc.
#
# The static flags go in through GO_LDFLAGS rather than a -ldflags of
# their own: the build target composes them with the -X that stamps the
# version, so this relink cannot silently drop the stamp.
RUN CGO_ENABLED=1 make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"'
# Runtime stage # Runtime stage
# alpine:3.21, 2026-03-17 # alpine:3.21, 2026-03-17
FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
@@ -260,17 +268,8 @@ EXPOSE 8080
# port. # port.
ENV BIND_ADDRESS=0.0.0.0 ENV BIND_ADDRESS=0.0.0.0
# The probe requests the container's own address, not loopback, so a
# listener that answers only on loopback (an empty or loopback
# BIND_ADDRESS) fails it. On a network with IPv4, hostname -i prints the
# container's IPv4 address, which 0.0.0.0 and :: both answer. On an
# IPv6-only network it prints none, and the probe requests the IPv6
# address /etc/hosts lists under the container's hostname instead, which
# only :: answers.
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD addr="$(hostname -i 2>/dev/null | cut -d ' ' -f 1)"; \ CMD wget --no-verbose --tries=1 --spider http://localhost:8080/.well-known/healthcheck || exit 1
[ -n "$addr" ] || addr="[$(getent hosts "$(hostname)" | cut -d ' ' -f 1)]"; \
wget --no-verbose --tries=1 --spider "http://$addr:8080/.well-known/healthcheck" || exit 1
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["/app/webhooker"] CMD ["/app/webhooker"]
+3 -3
View File
@@ -15,9 +15,9 @@ RUN go mod download
COPY . . COPY . .
# The test binary embeds the templates and static files, so the browser # The test binary embeds the templates and static files, so the browser
# stage needs nothing else. It is built with cgo off and -p 4 keeps the # stage needs nothing else. -p 4 keeps the compile's memory down, as in
# compile's memory down, both as in the test phase of Dockerfile. # the test phase of Dockerfile.
RUN make assets && CGO_ENABLED=0 go test -c -p 4 -tags browser -o /browser.test ./internal/server RUN make assets && go test -c -p 4 -tags browser -o /browser.test ./internal/server
# chromedp/headless-shell:151.0.7922.109 (Debian trixie), 2026-08-11. The # chromedp/headless-shell:151.0.7922.109 (Debian trixie), 2026-08-11. The
# browser is on PATH as headless-shell, where the test's browser library # browser is on PATH as headless-shell, where the test's browser library
+10 -5
View File
@@ -16,6 +16,15 @@ VERSION ?= $(shell script/version)
# exists to correct. # exists to correct.
override VERSION := $(or $(strip $(VERSION)),$(shell script/version)) override VERSION := $(or $(strip $(VERSION)),$(shell script/version))
# Extra linker flags for the build target. The static relink in the
# Dockerfile adds -extldflags here rather than passing its own -ldflags,
# so composing flags cannot drop the version stamp.
#
# The build target itself always passes -trimpath and -s -w, as the Go
# Dockerfile in REPO_POLICIES.md does: no build paths, symbol table or
# debug information in the binary.
GO_LDFLAGS ?=
bootstrap: bootstrap:
@script/bootstrap @script/bootstrap
@@ -43,12 +52,8 @@ fmt-check:
check: check:
@script/check @script/check
# Built with cgo off, -trimpath and -s -w, as the Go Dockerfile in
# REPO_POLICIES.md does: the binary carries no build paths, symbol table or
# debug information, and on Linux, the image's included, it is statically
# linked.
build: assets build: assets
CGO_ENABLED=0 go build -trimpath -ldflags '-s -w -X main.version=$(VERSION)' -o bin/webhooker ./cmd/webhooker go build -trimpath -ldflags '$(strip -s -w -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker
run: build run: build
./bin/webhooker ./bin/webhooker
+349 -386
View File
File diff suppressed because it is too large Load Diff
+343 -32
View File
@@ -12,14 +12,24 @@ One issue per unit of work, one branch and one PR per issue:
`main` without notice. One `next` -> `main` PR accumulates the milestone; `main` without notice. One `next` -> `main` PR accumulates the milestone;
releases are cut from `main` separately. releases are cut from `main` separately.
The tracker (https://git.eeqj.de/sneak/webhooker/issues) is the record of what Issue branches do NOT touch this file — the manager maintains it on `next`.
is done and what is under way. This file keeps no list of either. Every branch editing `TODO.md` conflicts with every other (#112).
# Status # Status
What is still open for 1.0.0 is in its milestone The milestone (https://git.eeqj.de/sneak/webhooker/milestone/9) is the
(https://git.eeqj.de/sneak/webhooker/milestone/9). `v1.0.0` is not tagged, and authoritative list, and the only place to read a count or a state of play from.
only @sneak tags it. This file records where the project is, not what is in flight: a sentence whose
truth depends on a branch being unmerged is wrong the moment it merges, and this
file has been wrong that way before.
The durability defect that held the tag has landed
(https://git.eeqj.de/sneak/webhooker/issues/256, commit `8d64259`). Every SQLite
handle opens with WAL journaling and a busy timeout, a bookkeeping write that
fails leaves its delivery in a recoverable state rather than a lying one, and
recovery skips a delivery that already has a successful result row. Final
pre-tag verification exercised it and confirmed it holds. Whatever the milestone
still shows open is what remains before `v1.0.0`.
Delivery is at-least-once by design, not by accident: a send whose result row Delivery is at-least-once by design, not by accident: a send whose result row
does not land is attempted again, so a receiver can see a duplicate. That is does not land is attempted again, so a receiver can see a duplicate. That is
@@ -28,31 +38,338 @@ under Rationale. It is not a defect to re-file.
# Next Step # Next Step
@sneak runs webhooker in limited production, then tags `v1.0.0`. Merging `next` Clear the rest of the open 1.0.0 milestone
into `main` is a separate act from tagging and waits on neither of those: `next` (https://git.eeqj.de/sneak/webhooker/milestone/9) and tag `v1.0.0`. Merging
is kept mergeable at all times, which is the point of the branch. `next` into `main` is a separate act from tagging and waits on neither of those:
`next` is kept mergeable at all times, which is the point of the branch.
# Completed Steps
- 2026-08-24 Bind the plaintext HTTP listener deliberately, via `BIND_ADDRESS`
defaulting to `127.0.0.1`, and document the reverse-proxy deployment. A
hostname, an empty value or a value carrying a port is a startup error, and
the `Dockerfile` sets `0.0.0.0` because a loopback bind inside a container is
unreachable (https://git.eeqj.de/sneak/webhooker/issues/268). The same commit
removed the shutdown race: `httpServer` is built in the constructor rather
than assigned from the serving goroutine, which orders the write before every
fx hook and rules out the nil dereference a SIGTERM arriving first would have
caused, and `sentryEnabled` is an `atomic.Bool`
(https://git.eeqj.de/sneak/webhooker/issues/226)
- 2026-08-24 Remove inbound request signature verification. The entrypoint UUID
is the authentication secret, so the per-entrypoint shared secret, the
`internal/signature` package, the receiver check, the model fields and the
forms are all gone. This reverses the feature that landed earlier in the same
milestone (https://git.eeqj.de/sneak/webhooker/issues/67,
https://git.eeqj.de/sneak/webhooker/issues/279)
- 2026-08-24 Stamp the build version into the binary and render it in the UI
footer. `script/version` is the single source — `$VERSION`, else
`git describe --tags --always --dirty`, else `unknown` — so a `make build`
binary and a `make docker` image from one checkout report the same thing, and
nothing in it varies between two builds of the same commit, which the release
gate's byte-identical assertion would catch
(https://git.eeqj.de/sneak/webhooker/issues/253)
- 2026-08-24 Derive cookie `Secure` and CSRF strictness from the request
transport rather than from `WEBHOOKER_ENVIRONMENT`. Behind a real TLS proxy
with the environment left at its `dev` default, the session cookie silently
lost `Secure` while the CSRF cookie on the same response kept it.
`X-Forwarded-Proto` is now matched case-insensitively on its first
comma-separated element, so `HTTPS` and `https, http` no longer fall to the
relaxed CSRF path (https://git.eeqj.de/sneak/webhooker/issues/269)
- 2026-08-24 Roll back a failed webhook deletion instead of committing it. A
failing delete committed whatever had already succeeded, hard-deleted the
per-webhook event database anyway, and redirected as though it had worked —
orphaned config plus permanently destroyed history, reported as success. All
three delete positions now roll back with the event database intact
(https://git.eeqj.de/sneak/webhooker/issues/262)
- 2026-08-24 Name a deleted target on its historical deliveries, marked
`(deleted)`, rather than leaving the event log unable to say where a delivery
went. A deleted target's credentials stay masked exactly as a live one's, and
it cannot become deliverable again through the receiver, resubmit, replay, the
edit form or the toggle (https://git.eeqj.de/sneak/webhooker/issues/211)
- 2026-08-24 Bound both request-controlled `/metrics` label dimensions, so the
unauthenticated receiver is no longer a memory-exhaustion vector: `handler`
carries the chi route pattern, and `method` folds anything chi cannot route
onto a single `(unmatched)` sentinel. Both were reproduced before the fix —
300 random method tokens took the series count from 106 to 7,631, and path
flooding reached 62,532 — and a label audit across a live scrape found no
third unbounded dimension (https://git.eeqj.de/sneak/webhooker/issues/254,
https://git.eeqj.de/sneak/webhooker/issues/261)
- 2026-08-24 Validate `max_retries` on both target forms. `abc`, `2.7` and `-5`
silently became 0 — fire-and-forget — including on the edit path, where it
destroyed a working value, and `999999999` stored verbatim. The ceiling of 20
is the `max` both templates already declared
(https://git.eeqj.de/sneak/webhooker/issues/221)
- 2026-08-24 Resubmit a stored event as a new undelivered event, so a backend
under development can be tested against real captured traffic. Per-delivery
replay cannot serve that: it re-sends one finished delivery to its own
original target, and a target created for a dev backend has no prior delivery
to replay. Resubmit re-injects the stored event at the top of the receiver
path and fans it out to whatever targets are active now
(https://git.eeqj.de/sneak/webhooker/issues/250)
- 2026-08-20 Take an exclusive lock on `DATA_DIR` at startup, so two instances
on one directory cannot both deliver
(https://git.eeqj.de/sneak/webhooker/issues/201)
- 2026-08-20 Shut down the app when the HTTP listener fails. The `OnStart` hook
returned as soon as the serving goroutine was spawned, so a failed listen left
fx reporting RUNNING and a live process with nothing bound — invisible to
systemd and Docker restart policies
(https://git.eeqj.de/sneak/webhooker/issues/200)
- 2026-08-20 Stop target credentials leaking into the per-webhook event
databases (https://git.eeqj.de/sneak/webhooker/issues/206), log SQL with
placeholders rather than bound values
(https://git.eeqj.de/sneak/webhooker/issues/207), and fail loudly on half-set
metrics auth credentials (https://git.eeqj.de/sneak/webhooker/issues/205)
- 2026-08-20 Read queue depths with `Find`, not `Scan`. `Scan` swaps GORM's own
trace recorder in for the logging adapter, and that recorder does not
implement `gorm.ParamsFilter`, so those statements logged their bound values
interpolated and bypassed the suppression above. The two units gated green
against a `next` that lacked the other, and `next` went red when both landed
(https://git.eeqj.de/sneak/webhooker/issues/234)
- 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
terminally failed delivery (https://git.eeqj.de/sneak/webhooker/issues/203)
- 2026-08-20 Expose delivery metrics on `/metrics`
(https://git.eeqj.de/sneak/webhooker/issues/209) and document the backup,
restore and upgrade procedures
(https://git.eeqj.de/sneak/webhooker/issues/210)
- 2026-08-20 Add a `webhooker resetpw` subcommand and a bootstrap banner. The
admin bootstrap password was printed once among roughly 45 fx lines, and under
`docker run -d` went to container logs subject to rotation; there was no reset
path at all, so recovery meant hand-deleting the users row, documented
nowhere. The password is read from stdin or generated, never from argv where
`/proc` would publish it (https://git.eeqj.de/sneak/webhooker/issues/208)
- 2026-08-20 Add `ALLOWED_EGRESS_CIDRS`, an allowlist-only escape hatch for the
SSRF guard, so a self-hosted proxy can forward into the operator's own
network. The guard's always-blocked set cannot be reopened by configuration
(https://git.eeqj.de/sneak/webhooker/issues/204)
- 2026-08-20 Harden operator-set target headers, which were carried unsafely
across a redirect (https://git.eeqj.de/sneak/webhooker/issues/233)
- 2026-08-20 Add a target edit form with headers and timeout fields
(https://git.eeqj.de/sneak/webhooker/issues/127)
- 2026-08-18 Raise `script/test`'s per-package timeout from 30s to 90s, matching
the org-wide backstop. `go test` applies `-timeout` per package, and
`internal/handlers` had grown past the old budget: a cache-defeated build
failed outright at `GOMAXPROCS=4`, and every run under deliberate host load
breached 30s. The measurement table lives in the script (#194)
- 2026-08-18 Re-sync `REPO_POLICIES.md` from `prompts`. The local copy was stale
and still mandated a 20s test target with a 30s timeout, which the org
replaced with a 60s cap and a 90s backstop. A synced copy is not a source;
reading it as one nearly produced a PR against `prompts` proposing a change
already merged there (#196)
- 2026-08-18 Report handler panics through the logger and answer 500. chi
v1.5.5's `Recoverer` scans for a `panic(0x` frame the runtime no longer emits,
then indexes `pkg[-1:]`, so it panicked inside its own stack printer before
writing a byte: the recovery never ran, the client got a dropped connection
instead of a 500, and the original panic was lost. A local middleware replaces
it, bounded by `MaxPanicLogLineBytes` (#187)
- 2026-08-18 Route GORM's logger through `slog` and bound it. Every `gorm.Open`
left `logger.Default` in place at `Warn` with `IgnoreRecordNotFoundError`
false, so **every record-not-found printed the fully interpolated SQL to
stdout** — including the client-chosen path on `/webhook/{uuid}` and the
submitted username on the login form, at no level the operator set and outside
`internal/logger` entirely. Three call sites, not the two the issue named
(#178)
- 2026-08-18 Bound every `slog` line against client-chosen text. Eight sites
reachable unauthenticated, found by reading every `slog` call in the tree
rather than only the one reported; the budget moved to a shared
`internal/logfield` so no second truncation exists. `DEBUG` being off by
default is not a bound and is not treated as one (#176)
- 2026-08-18 Stop a slow host turning a login-guard test into a segfault. A
non-fatal `assert` on an acquire result was dereferenced on the next line, so
one timing miss killed the whole `internal/middleware` binary and reddened CI
for unrelated PRs. The fix also removed a real production race — `acquire`
could shed a request with a slot standing free, because Go picks uniformly
among ready `select` cases (#186)
- 2026-08-18 Send the chi route pattern to Sentry rather than the concrete path.
The receiver's path carries the entrypoint capability token, so every Sentry
event from `/webhook/{uuid}` shipped a live credential to a third party.
Request `Data`, `QueryString`, `Cookies` and `Env` are dropped and headers
reduced to an allowlist (#179)
- 2026-08-18 Read form fields from the POST body only. `r.FormValue` merges the
query string, so a login could be driven by URL parameters — putting the
password somewhere that lands in access logs, proxy logs and browser history
(#160)
- 2026-08-18 Verify login credentials before spending rate-limit budget, so a
flood of wrong passwords cannot lock out the account it is guessing at. The
manager took this decision rather than stall the queue; it is flagged on the
issue for reversal (#150)
- 2026-08-18 Run all linting in Docker via `Dockerfile.lint`. Host lint was
wrong in both directions from version skew and shared caches. `script/lint`
asserts the summary line, because `--no-cache-filter` silently ignores a stage
name it does not match — the flag that makes the gate meaningful fails open
(#109)
- 2026-08-18 Serve an event's full stored body over HTTP. The list query
truncates for rendering, and that truncated value was the only way to read a
body, so the full payload was unreachable (#157)
- 2026-08-18 Bound the access log line against client-chosen text.
`internal/logfield` budgets by _encoded_ bytes, not runes, so a handler's JSON
escaping cannot multiply a field past its allowance (#146)
- 2026-08-18 Mark superseded CI commits `failure` rather than `skipped`. A
skipped run rolls up green, so a commit that was never tested reported success
(#152)
- 2026-08-18 Set `fx.StopTimeout` inside the container stop grace, so shutdown
hooks are bounded by a deadline the orchestrator will actually honour rather
than being killed mid-flush (#134)
- 2026-08-17 Bucket IPv6 rate-limit keys by `/64`. A single allocation hands out
2^64 addresses, so per-address keying let one client mint unlimited buckets.
Manager decision, recorded on the issue (#125)
- 2026-08-17 Correct release-blocking README and startup-warning inaccuracies,
including claims about behaviour the code does not have (#151)
- 2026-08-17 Fetch and verify Alpine.js at build time against
`static/vendor.sha256` instead of committing the minified blob, so the
dependency is pinned by hash rather than by trust (#145)
- 2026-08-17 Bound the event log's rendered bodies in the query itself, so a
large stored payload cannot be read into memory just to be truncated for
display (#135)
- 2026-08-17 Mask the `http` target's destination URL in the UI: it can carry a
bearer credential in its path or query, and was rendered verbatim. Manager
decision to mask unconditionally (#115)
- 2026-08-14 Bound shutdown hooks by their stop context, so a hook that hangs
cannot hold the process past its grace period (#102)
- 2026-08-14 Render templates via a buffer rather than the `ResponseWriter`, so
a template error part-way through cannot commit a 200 and then fail — the
response is written only once it is whole (#123)
- 2026-08-14 Align the session codec's max-age with the 7-day absolute cap. The
codec accepted cookies the session layer considered expired, 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
default silently discards forwarded headers and every client rate-limits as
the proxy's address (#149)
- 2026-08-12 Bound the receiver rate limit per client IP across the whole
`/webhook/*` route. The existing limiter keyed on the request path and
`/webhook/{uuid}` matches any single segment, so a client that invented a
fresh path per request minted a fresh bucket per request: the limit on the
only unauthenticated endpoint bounded nothing in aggregate, and every request
still cost an entrypoint lookup before it 404ed. An outer limiter keyed on the
client address alone now bounds that, chained in front of the unchanged
per-entrypoint limiter (#139)
- 2026-08-12 Correct release-blocking documentation inaccuracies: the README
promised manual redelivery in the present tense in three places when nothing
implements it (the same false claim also sat in the doc comment that was its
source text), the env table omitted `RETENTION_SWEEP_INTERVAL`, and `TODO.md`
itself omitted five landed units (#141)
- 2026-08-12 Make the CI gate execute the checks it reports on. The workflow now
writes a build-context fingerprint before calling `script/cibuild`, so a code
commit invalidates the `COPY` layer of the lint and builder stages while a
docs-only commit still replays from cache; a superseding run also rewrites the
`failure` status Gitea leaves on commits it cancelled and never tested.
Verified by pushing a deliberately broken test and watching CI go red (#119)
- 2026-08-12 Require a positive `RETENTION_SWEEP_INTERVAL`: a non-positive value
reached `time.NewTicker` in both the retention reaper and the archive sweeper,
panicking two goroutines with no recover after startup had already reported
success (#140)
- 2026-08-12 Bound the `X-Forwarded-For` scan's allocation to the hop cap: the
reverse walk cuts entries with `strings.LastIndexByte` instead of joining and
splitting, so a 1 MB header allocates 16 bytes rather than 1.6 MB per request
on the unauthenticated receiver. Semantics proven unchanged by differential
testing against the previous implementation (#133)
- 2026-08-12 Cap the `X-Forwarded-For` hop walk at 64 entries, so an
attacker-supplied chain cannot burn unbounded CPU in the rate-limit key
function; running off the end falls back to the peer address (#124)
- 2026-08-12 Gate forwarded-header trust behind a `TRUSTED_PROXIES` CIDR list:
all three rate limiters key on the connection's own address unless the direct
peer is a configured proxy, in which case `X-Forwarded-For` is walked right to
left for the first non-proxy hop. Default trusts nothing, and a
set-but-unparseable value aborts startup. Before this, any client could mint a
fresh bucket or drain another's by rotating a spoofed header (#88)
- 2026-08-11 Web UI cleanup: nav terminology unified on Webhooks, the Profile
settings placeholder removed, a progressive-enhancement copy button for the
entrypoint URL, and retention form copy that states the actual policy
(deletion by the reaper, 0 retains forever) (#57)
- 2026-08-11 Mask the webhook credential in delivery errors and logs: Go embeds
the request URL in `*url.Error`, so every transport failure persisted the full
Slack webhook URL into the per-webhook event database via
`DeliveryResult.Error`, a field a future REST API would have served.
`maskURLError` drops path, query and userinfo while preserving the wrapped
cause, so `errors.Is`/`As` and `Timeout()` still work and DNS, TLS and timeout
failures still read differently (#118)
- 2026-08-11 Rate-limit the public webhook receiver endpoint
(`RECEIVER_RATE_LIMIT`, default 120/min), keyed on client IP plus entrypoint
path so one entrypoint cannot exhaust another's budget; over-limit requests
get 429 with `Retry-After`. It was the one unauthenticated, internet-facing
endpoint with no limit at all (#64)
- 2026-08-11 Enforce the body size limit before CSRF parses the form:
`MaxBodySize` is now first in all four form-parsing route groups, so an
oversized request is rejected with 413 instead of being read in full by the
CSRF middleware before any cap applied (#90)
- 2026-08-11 Mask target config on the source detail page, which rendered the
stored blob verbatim and so exposed the Slack incoming-webhook URL — a bearer
credential that cannot be revoked per-holder. Config reaches the template only
as a `TargetView` of labelled fields, and header values are rendered as a
count (#113)
- 2026-08-11 Allow `retention_days` of 0 to mean retain forever, via a sentinel
written in `BeforeSave` so the GORM column default cannot win the race. Also
bounds the reaper's cutoff arithmetic: day counts above 106751 overflowed
`time.Duration` and wrapped the cutoff into the future, where every row
matched and the sweep deleted everything (#79)
- 2026-08-09 Inactivity-based session timeout: sliding idle expiry
(`SESSION_IDLE_TIMEOUT`, default `24h`) refreshed on authenticated requests,
with the 7-day absolute cap kept as an independent backstop that activity
never extends (#66)
- 2026-08-09 Restart recovery and the 60s retry sweep terminally fail an
orphaned `retrying` delivery whose target type no longer supports retries,
recording a `DeliveryResult` with the reason instead of leaving the delivery
stuck forever (#82)
- 2026-08-09 Root the delivery engine's worker pool and the retention reaper's
sweep loop at `context.Background()` rather than the fx `OnStart` hook context
(#97), which carries fx's 15s start timeout and killed both roughly fifteen
seconds after boot: the proxy silently stopped delivering webhooks entirely,
and the reaper never ran a single sweep under its default one-hour interval
- 2026-08-09 Archive writer lifecycle (#89): deleting a webhook (or its last
`database` target) evicts the cached archive writer and closes its handle
while deliberately leaving `archive-{webhookID}.db` on disk, and a new
`ArchiveSweeper` prunes idle archives on the existing
`RETENTION_SWEEP_INTERVAL` without ever creating an archive file
- 2026-08-09 Configuration parsing fails loudly on set-but-unparseable
environment values: `envInt` removed in favour of `envPositiveInt` plus a
`PORT` range check, `envBool` now parses with `strconv.ParseBool`, and
defaults apply only to unset variables (#80)
- 2026-08-07 Automatic event retention cleanup based on `retention_days`,
deleting expired events, deliveries, and delivery results from each
per-webhook event database (#63)
- 2026-08-07 Update golangci-lint to v2.12.2 (Docker image digest in
`Dockerfile`, release-archive sha256 pins in `script/bootstrap`), adopt the
canonical `.golangci.yml` (v2 `linters.settings` layout so
`lll`/`funlen`/`cyclop`/`dupl` thresholds actually apply), and fix all newly
surfaced lint findings
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
shims, README Entrypoints section
- 2026-03-25 pin golangci-lint Docker image for linting (#55)
- 2026-03-18 CSRF middleware detects TLS per-request, fixing login over plain
HTTP and behind reverse proxies (#54)
- 2026-03-17 root path redirects based on auth state (#52)
- 2026-03-17 CSRF protection, SSRF prevention for HTTP delivery targets with DNS
rebinding defense, and per-IP login rate limiting (#42)
- 2026-03-17 Slack target type for incoming webhook notifications (#47)
- 2026-03-17 Dockerfile absolute paths and static linking (#49); absolute dev
DATA_DIR default and clarified env docs (#46)
- 2026-03-05 security headers middleware, session regeneration on login, request
body size limits (#41)
- 2026-03-04 tests for delivery, middleware, and session packages (#32); removed
the build-architecture global (#31)
- 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core delivery
engine with bounded worker pool and circuit breaker, parallel fan-out,
per-webhook event databases, management UI (#16)
- 2026-03-01 repo brought to REPO_POLICIES standards; TODO.md folded into README
(#6)
# Future Steps # Future Steps
- Per-webhook rate limits, set in the web UI and enforced in the receiver - Delivery status and retry management UI. Replay of a terminally failed
handler, on top of the receiver's existing limits, which `RECEIVER_RATE_LIMIT` delivery and per-attempt detail already landed
sets for the whole server (see Rate Limiting in the README) (https://git.eeqj.de/sneak/webhooker/issues/203,
- API key authentication for programmatic access (the `APIKey` model exists, but https://git.eeqj.de/sneak/webhooker/issues/202)
nothing creates a key and no middleware checks one) - Per-webhook rate limiting in the receiver handler (per-webhook config plus
handler enforcement; global limits must not apply to receiver endpoints)
- API key authentication for programmatic access (APIKey model exists; Bearer
token middleware does not)
- REST API v1 - REST API v1
- CRUD for webhooks, entrypoints, targets - CRUD for webhooks, entrypoints, targets
- event viewing and filtering endpoints - event viewing and filtering endpoints
- event redelivery endpoint - event redelivery endpoint
- OpenAPI specification - OpenAPI specification
- Analytics in the web UI: response times summarised per webhook or per target, - Analytics dashboard: success rates, response times, volume
and figures for all webhooks together. The webhook page already shows event, - A remember-me option at login
delivery and failure counts and failure percentages, the target list counts
each target's deliveries, the event log and each event's page show every
attempt's duration, and `/metrics` carries server-wide counts and delivery
durations
- A remember-me option at login. Every session already survives a browser
restart: it lasts up to 7 days, and ends sooner after `SESSION_IDLE_TIMEOUT`
(default 24 hours) without activity
- Password reset flow for a forgotten password over the web. The authenticated - Password reset flow for a forgotten password over the web. The authenticated
password _change_ flow already landed, and a lost password is recoverable from password _change_ flow already landed, and a lost password is recoverable from
the console with `webhooker resetpw` the console with `webhooker resetpw`
@@ -60,13 +377,7 @@ is kept mergeable at all times, which is the point of the branch.
- Later, nice to have - Later, nice to have
- email delivery target type - email delivery target type
- SNS and S3 delivery targets - SNS and S3 delivery targets
- data transformations (e.g. a configurable Slack message format; the Slack - data transformations (e.g. webhook to Slack message formatting)
target sends one fixed format today) - JSONL file delivery with periodic S3 upload
- JSONL file delivery with periodic S3 upload (the `database` target already - webhook event search and filtering
archives events to SQLite files, rotated hourly, daily or monthly if set, - multi-user with role-based access control
and the webhook page offers them as one gzipped JSON download)
- webhook event search, and filtering beyond the event log's failed and
pending views
- multi-user with role-based access control (each webhook already belongs to
one account, but there is only the account made at first boot and no way
to add another)
+10 -9
View File
@@ -10,7 +10,6 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"go.uber.org/fx/fxevent" "go.uber.org/fx/fxevent"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/datadir" "sneak.berlin/go/webhooker/internal/datadir"
@@ -50,7 +49,7 @@ import (
// before the server may already have spent. // before the server may already have spent.
// //
// This does not make the database close unconditional: the // This does not make the database close unconditional: the
// archive.Sweeper and RetentionReaper hooks run before the server. // ArchiveSweeper and RetentionReaper hooks run before the server.
// What they spend comes out of the drain first, but past 3s it comes // What they spend comes out of the drain first, but past 3s it comes
// out of the reserve, and they can consume the whole budget. // out of the reserve, and they can consume the whole budget.
const stopTimeout = 5 * time.Second const stopTimeout = 5 * time.Second
@@ -189,8 +188,8 @@ func newApp() *fx.App {
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
// The plain logger the session, the middleware, the // The plain logger the session, the middleware and the
// webhook database manager and the archive writers take. // webhook database manager take.
func(l *logger.Logger) *slog.Logger { return l.Get() }, func(l *logger.Logger) *slog.Logger { return l.Get() },
config.New, config.New,
database.New, database.New,
@@ -209,12 +208,14 @@ func newApp() *fx.App {
// disagree about a destination. // disagree about a destination.
delivery.NewGuard, delivery.NewGuard,
delivery.New, delivery.New,
archive.NewWriters, delivery.NewArchiveSweeper,
archive.NewSweeper, // Wire *delivery.Engine as delivery.Notifier so the
// Wire *archive.Writers as archive.Archives so deleting // webhook handler can notify the engine of new deliveries.
func(e *delivery.Engine) delivery.Notifier { return e },
// Wire *delivery.Engine as delivery.Archives so deleting
// or renaming a webhook or target reaches its archive // or renaming a webhook or target reaches its archive
// files. // files.
func(w *archive.Writers) archive.Archives { return w }, func(e *delivery.Engine) delivery.Archives { return e },
// Wire *delivery.Engine as delivery.CircuitBreakers so // Wire *delivery.Engine as delivery.CircuitBreakers so
// the pages can show a target whose deliveries are // the pages can show a target whose deliveries are
// paused. // paused.
@@ -226,7 +227,7 @@ func newApp() *fx.App {
*server.Server, *server.Server,
*delivery.Engine, *delivery.Engine,
*database.RetentionReaper, *database.RetentionReaper,
*archive.Sweeper, *delivery.ArchiveSweeper,
) { ) {
}, },
), ),
+1 -1
View File
@@ -252,7 +252,7 @@ const tailHeadroom = 2 * time.Second
// can produce, since a shorter drain leaves the flush more room and // can produce, since a shorter drain leaves the flush more room and
// the worst case is not necessarily at either extreme. // the worst case is not necessarily at either extreme.
// //
// Nor does the hook start on a full budget: the archive.Sweeper and // Nor does the hook start on a full budget: the ArchiveSweeper and
// RetentionReaper hooks run before it, and whatever they spent is // RetentionReaper hooks run before it, and whatever they spent is
// gone. The outer sweep walks every amount they can spend. Once they // gone. The outer sweep walks every amount they can spend. Once they
// have eaten into the headroom themselves, the hook must spend // have eaten into the headroom themselves, the hook must spend
-446
View File
@@ -1,446 +0,0 @@
// Package archive manages the SQLite archive files the database target
// writes: how they are named and rotated, the writers that open, write,
// close and reopen them, pruning the rows past a target's expiry,
// renaming a target's files and moving them back when that fails, the
// gzipped JSON export, the idle sweep, and the locks that keep a rename
// apart from archive writes, from other edits and from exports.
package archive
import (
"fmt"
"log/slog"
"strings"
"sync"
"time"
"go.uber.org/fx"
"sneak.berlin/go/webhooker/internal/database"
)
// archiveNameMaxLen is how many characters of a webhook or target
// name an archive file name keeps.
const archiveNameMaxLen = 40
// Archives is how the handlers keep the database targets' archive
// files in step with the configuration. Deleting a webhook or a
// target releases the cached archive writers, whose open file
// handles would otherwise outlive them; renaming one renames the
// archive files, which are named for the webhook and the target
// (see FileName). RenameLock returns the lock every rename runs under
// (see Writers.RenameLock).
//
// It is an interface so that the handlers' tests can stand in for
// Writers.
//
// Neither eviction deletes an archive file. Both are idempotent
// and are no-ops for a webhook or target with no writer.
type Archives interface {
RenameLock() *sync.Mutex
EvictWebhook(webhookID string)
EvictTarget(targetID string)
Rename(targetID, webhookName, targetName string) error
}
// WritersParams holds the fx dependencies for Writers.
type WritersParams struct {
fx.In
Database *database.Database
DBManager *database.WebhookDBManager
Logger *slog.Logger
}
// Writers holds the archive writer of each database target, and the
// lock every rename of archive files runs under. The database target
// writes an event through it, the handlers rename and evict through
// it, the archive sweeper prunes through it, and the delivery engine
// evicts every writer when it stops. See archiveWriter for the
// close/reopen, auto-recreate, and expiry semantics.
type Writers struct {
db *database.Database
dbMgr *database.WebhookDBManager
log *slog.Logger
// writers holds one archive writer per database target, keyed
// by target ID.
mu sync.Mutex
writers map[string]*archiveWriter
// renameMu makes the webhook edit, the target edit and target
// creation run one at a time, each held from loading the stored
// names through the archive rename, the save and any move back.
// Interleaved, one could rename an archive between another's
// rename and save, leaving the file named for one edit and the
// stored names from the other. An archive download holds it while
// 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. RenameLock returns it.
renameMu sync.Mutex
}
// NewWriters builds the archive writers. It opens no archive file:
// each target's writer is made on its first use.
func NewWriters(params WritersParams) *Writers {
return &Writers{
db: params.Database,
dbMgr: params.DBManager,
log: params.Logger,
}
}
// RenameLock implements Archives. It returns the lock every rename of
// archive files runs under: see renameMu for who holds it and for how
// long. Archive writes and Rename itself do not take it.
func (ws *Writers) RenameLock() *sync.Mutex {
return &ws.renameMu
}
// FileName returns the file name of a database target's
// archive: archive-WEBHOOKNAME-TARGETNAME-TARGETID.db, with both
// names passed through archiveNamePart. The target ID keeps the
// name unique when two targets' names come out the same.
func FileName(webhookName, targetName, targetID string) string {
return "archive-" + archiveNamePart(webhookName) + "-" +
archiveNamePart(targetName) + "-" + targetID + ".db"
}
// archiveNamePart makes a webhook or target name safe to put in a
// file name. It is lowercased; ASCII letters and digits are kept,
// every other run of characters becomes a single "-", and no "-" is
// left at either end. It is cut to archiveNameMaxLen characters, and
// a name with nothing left is "unnamed".
func archiveNamePart(name string) string {
var b strings.Builder
dash := false
for _, r := range strings.ToLower(name) {
if (r < 'a' || r > 'z') && (r < '0' || r > '9') {
dash = b.Len() > 0
continue
}
if dash {
b.WriteByte('-')
dash = false
}
b.WriteRune(r)
}
part := b.String()
if len(part) > archiveNameMaxLen {
part = strings.TrimRight(part[:archiveNameMaxLen], "-")
}
if part == "" {
return "unnamed"
}
return part
}
// Write writes the full event of a delivery to a database target as
// a row into the target's archive database, honouring the target's
// ArchiveExpiry and ArchiveRotation. With rotation, the event goes to
// the file for the period of its receive time.
func (ws *Writers) Write(d *database.Delivery) error {
webhookID := d.Event.WebhookID
if webhookID == "" {
return errArchiveMissingWebhookID
}
// An event whose stored row was gone before its delivery ran has
// no receive time (see delivery's Engine.hydrateEvent), and goes
// to the file for now.
receivedAt := d.Event.CreatedAt
if receivedAt.IsZero() {
receivedAt = time.Now()
}
w, err := ws.writerFor(d.TargetID)
if err != nil {
return err
}
row := archivedEvent{
EventID: d.Event.ID,
WebhookID: webhookID,
EntrypointID: d.Event.EntrypointID,
Method: d.Event.Method,
RawQuery: d.Event.RawQuery,
Headers: d.Event.Headers,
Body: d.Event.Body,
ContentType: d.Event.ContentType,
}
return w.write(
row, d.Target.ArchiveExpiry,
archivePeriod(d.Target.ArchiveRotation, receivedAt),
)
}
// Rename implements Archives. It renames every one of a database
// target's archive files to FileName(webhookName, targetName,
// targetID), each keeping the period in its name. It goes through the
// target's writer, so the move holds the lock that the target's
// archive writes and the idle sweep take, and later writes use the
// new name. It never replaces a file: if one already has a new name,
// the error is ErrNameTaken.
//
// The writer is created if there is none, and it stays cached. The
// handlers rename before they save the new name, so until the save
// the main database still has the old one; a delivery in that window
// must find this writer rather than build one from the old name.
func (ws *Writers) Rename(
targetID, webhookName, targetName string,
) error {
w, err := ws.writerFor(targetID)
if err != nil {
return err
}
return w.rename(FileName(webhookName, targetName, targetID))
}
// EvictTarget implements Archives. It drops a database target's
// archive writer from the registry and closes its handle, so a
// deleted target does not leave a writer (and an open archive handle
// within its debounce window) alive for the process lifetime.
//
// The map entry is removed under the registry lock, which is
// then released before the handle is closed under the writer's
// own lock: that ordering keeps the registry available to other
// targets while an in-flight write on this one drains, and
// closing under the writer's lock means eviction can never race
// a write.
//
// Eviction is idempotent and silent for a target with no writer,
// which is the common case: only a database target that has
// received an event or been renamed has one. It never deletes the
// archive file.
func (ws *Writers) EvictTarget(targetID string) {
ws.mu.Lock()
w, ok := ws.writers[targetID]
if ok {
delete(ws.writers, targetID)
}
ws.mu.Unlock()
if !ok {
return
}
w.evict()
ws.log.Info(
"evicted archive writer",
"target_id", targetID,
"path", w.path,
)
}
// EvictWebhook implements Archives. It evicts, exactly as EvictTarget
// does, the writer of every database target of a webhook. The archive
// files themselves are left on disk — they are long-term storage the
// operator owns.
func (ws *Writers) EvictWebhook(webhookID string) {
ws.mu.Lock()
var gone []*archiveWriter
for targetID, w := range ws.writers {
if w.webhookID == webhookID {
delete(ws.writers, targetID)
gone = append(gone, w)
}
}
ws.mu.Unlock()
for _, w := range gone {
w.evict()
ws.log.Info(
"evicted archive writer",
"webhook_id", webhookID,
"path", w.path,
)
}
}
// EvictAll evicts every cached archive writer, exactly as EvictTarget
// does for one target. The delivery engine calls it at shutdown, once
// its workers have returned. Closing the last handle on an archive
// moves the contents of its -wal into the .db and removes the
// -wal, so a clean stop leaves each archive as a single file.
func (ws *Writers) EvictAll() {
ws.mu.Lock()
writers := ws.writers
ws.writers = nil
ws.mu.Unlock()
for _, w := range writers {
w.evict()
}
}
// writerFor returns the archive writer for a database target,
// creating and caching it on first use. Each target has one writer
// so its close/reopen debounce state is shared across concurrent
// deliveries, and so a rename and the idle sweep take the same lock
// as its writes.
func (ws *Writers) writerFor(
targetID string,
) (*archiveWriter, error) {
ws.mu.Lock()
defer ws.mu.Unlock()
w, ok := ws.writers[targetID]
if !ok {
var err error
w, err = ws.newWriter(targetID)
if err != nil {
return nil, err
}
if ws.writers == nil {
ws.writers = make(map[string]*archiveWriter)
}
ws.writers[targetID] = w
}
// A delivery claims the entry: even if the idle sweep created
// it moments ago, it now belongs to the registry proper and
// the sweep must leave it in place when it finishes.
w.sweepOwned = false
return w, nil
}
// sweepWriterFor returns the archive writer the idle sweep should
// prune a target's archive through, together with whether the sweep
// itself created the registry entry.
//
// The sweep must route its prune through the registered writer so
// the writer's mutex orders it against concurrent writes, but it
// must never leave a registry entry behind: a sweep that ran
// concurrently with the target's deletion would otherwise
// re-create an entry that nothing will ever evict again, which is
// exactly the leak eviction exists to prevent. An entry the sweep
// creates is therefore marked sweep-owned and handed back to
// releaseSweepWriter when the sweep is done.
func (ws *Writers) sweepWriterFor(
targetID string,
) (*archiveWriter, bool, error) {
ws.mu.Lock()
defer ws.mu.Unlock()
w, ok := ws.writers[targetID]
if ok {
return w, false, nil
}
w, err := ws.newWriter(targetID)
if err != nil {
return nil, false, err
}
if ws.writers == nil {
ws.writers = make(map[string]*archiveWriter)
}
w.sweepOwned = true
ws.writers[targetID] = w
return w, true, nil
}
// releaseSweepWriter drops a registry entry that the idle sweep
// created, so a sweep leaves the registry exactly as it found it.
//
// The entry is removed only if it is still the very writer the
// sweep installed and no delivery has claimed it in the meantime
// (writerFor clears sweepOwned when it hands a writer to the
// write path). Both conditions are evaluated under the registry
// lock, so an eviction that raced the sweep — which removes the
// entry outright — simply finds nothing left to do here, and a
// delivery that adopted the writer keeps a registered, evictable
// one.
func (ws *Writers) releaseSweepWriter(
targetID string, w *archiveWriter,
) {
ws.mu.Lock()
defer ws.mu.Unlock()
cur, ok := ws.writers[targetID]
if !ok || cur != w || !cur.sweepOwned {
return
}
delete(ws.writers, targetID)
}
// newWriter builds the writer for a database target's archive. Its
// path is the one Path gives for the webhook and the target as
// the main database names them now; from then on only rename changes
// the name the writer uses. It does not touch the archive files.
func (ws *Writers) newWriter(
targetID string,
) (*archiveWriter, error) {
if ws.dbMgr == nil {
return nil, errArchiveNoDataDir
}
var target database.Target
err := ws.db.DB().
Preload("Webhook").
First(&target, "id = ?", targetID).Error
if err != nil {
return nil, fmt.Errorf(
"loading database target %s: %w", targetID, err,
)
}
w := newArchiveWriter(
Path(ws.dbMgr, &target.Webhook, &target),
ws.log,
)
w.webhookID = target.WebhookID
return w, nil
}
// sweepArchive prunes one database target's archive of rows older
// than expiry, without requiring a write. A missing archive file is
// left missing (see sweepExpired), so a sweep never creates an
// archive for a target that has never received an event.
//
// It also never leaves a registry entry behind: an entry it had
// to create to reach the writer's mutex is released again once
// the prune is done, so a sweep racing a target deletion cannot
// resurrect the writer the eviction just dropped.
func (ws *Writers) sweepArchive(
targetID string, expiry time.Duration,
) error {
w, created, err := ws.sweepWriterFor(targetID)
if err != nil {
return err
}
if created {
defer ws.releaseSweepWriter(targetID, w)
}
return w.sweepExpired(expiry)
}
-757
View File
@@ -1,757 +0,0 @@
package archive_test
import (
"database/sql"
"fmt"
"io/fs"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
_ "modernc.org/sqlite" // Pure Go SQLite driver.
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/gormlog"
)
const (
// testContentType is the event content type used in tests.
testContentType = "application/json"
// eventQuery is the query string an archived event carries.
eventQuery = "a=1&b=2"
)
func archiveTestLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
))
}
// openArchiveDBForRead opens an archive file read-only so a
// test can inspect the rows the writer persisted.
func openArchiveDBForRead(
t *testing.T, path string,
) *gorm.DB {
t.Helper()
sqlDB, err := sql.Open(
"sqlite",
fmt.Sprintf("file:%s?mode=ro", path),
)
require.NoError(t, err)
t.Cleanup(func() { _ = sqlDB.Close() })
gdb, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
)
require.NoError(t, err)
return gdb
}
// archiveFileSuffixes returns the archive file itself and the
// SQLite sidecars that accompany an open database. A test that
// asserts no archive was created has to check all of them.
func archiveFileSuffixes() []string {
return []string{"", "-wal", "-shm"}
}
// removeArchiveFiles simulates an operator moving the archive
// away by deleting the SQLite file and its sidecar files.
func removeArchiveFiles(t *testing.T, path string) {
t.Helper()
for _, suffix := range []string{
"", "-wal", "-shm", "-journal",
} {
err := os.Remove(path + suffix)
if err != nil && !os.IsNotExist(err) {
t.Fatalf("removing %s%s: %v", path, suffix, err)
}
}
}
// assertStoredTime asserts that text, a timestamp as SQLite holds it,
// is in the form GORM stores created_at in: the layout of
// time.Time.String, with no " m=+…" monotonic clock reading after it.
func assertStoredTime(t *testing.T, text string) {
t.Helper()
_, err := time.Parse(
"2006-01-02 15:04:05.999999999 -0700 MST", text,
)
assert.NoError(t, err, "stored as %q", text)
}
// newEvent returns an event received now with the given body, as the
// webhook handler stores it.
func newEvent(body string) database.Event {
return database.Event{
BaseModel: database.BaseModel{
ID: uuid.New().String(),
CreatedAt: time.Now(),
},
WebhookID: uuid.New().String(),
EntrypointID: uuid.New().String(),
Method: http.MethodPost,
Headers: `{"Content-Type":["application/json"]}`,
Body: body,
ContentType: testContentType,
}
}
// databaseTargetDelivery returns a delivery of event to a database
// target, as the database target hands it to Writers.Write.
func databaseTargetDelivery(
event database.Event, tgt *database.Target,
) *database.Delivery {
return &database.Delivery{
EventID: event.ID,
TargetID: tgt.ID,
Event: event,
Target: database.Target{
Name: tgt.Name,
Type: database.TargetTypeDatabase,
ArchiveExpiry: tgt.ArchiveExpiry,
ArchiveRotation: tgt.ArchiveRotation,
},
}
}
func TestArchiveWriter_WritesRow(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive-wh.db")
w := archive.NewExportArchiveWriter(
path, archiveTestLogger(), 0,
)
row := archive.ExportArchivedEvent{
EventID: "ev-1",
WebhookID: "wh-1",
EntrypointID: "ep-1",
Method: "POST",
Headers: `{"X":"Y"}`,
Body: `{"hello":"world"}`,
ContentType: "application/json",
}
require.NoError(t, w.Write(row, 0))
assert.FileExists(t, path)
var got []archive.ExportArchivedEvent
require.NoError(t, w.DB().Find(&got).Error)
require.Len(t, got, 1)
assert.Equal(t, "ev-1", got[0].EventID)
assert.Equal(t, "wh-1", got[0].WebhookID)
assert.Equal(t, "ep-1", got[0].EntrypointID)
assert.Equal(t, row.Method, got[0].Method)
assert.Equal(t, row.ContentType, got[0].ContentType)
assert.JSONEq(t, `{"hello":"world"}`, got[0].Body)
assert.False(t, got[0].ArchivedAt.IsZero())
var archivedAt string
require.NoError(t, w.DB().Raw(
"SELECT CAST(archived_at AS TEXT) FROM archived_events",
).Row().Scan(&archivedAt))
assertStoredTime(t, archivedAt)
}
func TestArchiveWriter_RecreatesAfterRemoval(
t *testing.T,
) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive-wh.db")
w := archive.NewExportArchiveWriter(
path, archiveTestLogger(), 0,
)
require.NoError(t, w.Write(
archive.ExportArchivedEvent{EventID: "a"}, 0,
))
assert.FileExists(t, path)
// The operator moves the archive away while the handle is
// still open.
removeArchiveFiles(t, path)
require.NoFileExists(t, path)
// The next write recreates the file with a fresh schema and
// only the new row.
require.NoError(t, w.Write(
archive.ExportArchivedEvent{EventID: "b"}, 0,
))
assert.FileExists(t, path)
var got []archive.ExportArchivedEvent
require.NoError(t, w.DB().Find(&got).Error)
require.Len(t, got, 1)
assert.Equal(t, "b", got[0].EventID)
}
// TestArchiveHandleIsWAL closes the last gap in the durability
// evidence: the main and per-webhook tiers each assert their journal
// mode on a live handle, and the archive tier gets its settings from
// the same code path but nothing checked the running file.
func TestArchiveHandleIsWAL(t *testing.T) {
t.Parallel()
w := archive.NewExportArchiveWriter(
filepath.Join(t.TempDir(), "archive-wal.db"),
archiveTestLogger(), 0,
)
require.NoError(t, w.Open(0))
var mode string
row := w.DB().Raw("pragma journal_mode").Row()
require.NoError(t, row.Scan(&mode))
assert.Equal(t, "wal", strings.ToLower(mode))
var busy string
row = w.DB().Raw("pragma busy_timeout").Row()
require.NoError(t, row.Scan(&busy))
assert.Equal(t, "10000", busy)
}
// TestStatArchive proves Stat finds no file before the first
// write; after a write still held in the -wal, counts the -wal in the
// size and takes its later time as the last write; and finds no file
// again once the file has been moved away.
func TestStatArchive(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive-wh.db")
_, err := archive.Stat(path)
require.ErrorIs(t, err, fs.ErrNotExist)
// With the clock stopped, the reopen debounce never passes, so
// the handle stays open after the write.
stopped := time.Now()
w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0)
w.SetNow(func() time.Time { return stopped })
require.NoError(t, w.Write(archive.ExportArchivedEvent{EventID: "a"}, 0))
written := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
earlier := written.Add(-time.Hour)
require.NoError(t, os.Chtimes(path, earlier, earlier))
require.NoError(t, os.Chtimes(path+"-wal", written, written))
file, err := os.Stat(path)
require.NoError(t, err)
wal, err := os.Stat(path + "-wal")
require.NoError(t, err)
require.Positive(t, wal.Size())
got, err := archive.Stat(path)
require.NoError(t, err)
assert.Equal(t, 1, got.Files)
assert.Equal(t, file.Size()+wal.Size(), got.Size)
assert.True(t, written.Equal(got.Written), got.Written)
removeArchiveFiles(t, path)
_, err = archive.Stat(path)
require.ErrorIs(t, err, fs.ErrNotExist)
}
func TestArchiveWriter_ReopenDebounce(t *testing.T) {
t.Parallel()
const debounce = 2 * time.Second
path := filepath.Join(t.TempDir(), "archive-wh.db")
w := archive.NewExportArchiveWriter(
path, archiveTestLogger(), debounce,
)
// The writer measures its reopen debounce on this clock, which
// only the test moves, so how long the host takes between
// writes cannot change the result.
now := time.Now()
w.SetNow(func() time.Time { return now })
require.NoError(t, w.Write(
archive.ExportArchivedEvent{EventID: "a"}, 0,
))
require.NoError(t, w.Write(
archive.ExportArchivedEvent{EventID: "b"}, 0,
))
// Two writes inside the debounce window trigger only the
// initial open — no extra close/reopen.
assert.Equal(t, 1, w.Reopens())
now = now.Add(debounce)
require.NoError(t, w.Write(
archive.ExportArchivedEvent{EventID: "c"}, 0,
))
// A write after the window elapses closes and reopens once.
assert.Equal(t, 2, w.Reopens())
}
func TestArchiveWriter_ExpiryPrune(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive-wh.db")
w := archive.NewExportArchiveWriter(
path, archiveTestLogger(), 0,
)
require.NoError(t, w.Open(0))
old := archive.ExportArchivedEvent{
EventID: "old",
ArchivedAt: time.Now().Add(-2 * time.Hour),
}
fresh := archive.ExportArchivedEvent{
EventID: "fresh",
ArchivedAt: time.Now(),
}
require.NoError(t, w.DB().Create(&old).Error)
require.NoError(t, w.DB().Create(&fresh).Error)
// Reopening with a one-hour expiry prunes the old row.
require.NoError(t, w.Reopen(time.Hour))
var got []archive.ExportArchivedEvent
require.NoError(t, w.DB().Find(&got).Error)
require.Len(t, got, 1)
assert.Equal(t, "fresh", got[0].EventID)
}
func TestParseArchiveExpiry(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
want time.Duration
wantErr bool
}{
{"empty", "", 0, false},
{"explicit never", "never", 0, false},
{"hour", "1h", time.Hour, false},
{"thirty days", "720h", 720 * time.Hour, false},
{"minutes", "30m", 30 * time.Minute, false},
{"unparseable", "nonsense", 0, true},
{"days are not a Go unit", "7d", 0, true},
{"zero duration", "0s", 0, true},
{"bare zero", "0", 0, true},
{"negative duration", "-5h", 0, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := archive.ParseExpiry(tc.in)
if tc.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tc.want, got)
})
}
}
// TestArchiveFileName pins the archive file name and the rules
// that make a webhook or target name safe to put in it.
func TestArchiveFileName(t *testing.T) {
t.Parallel()
const id = "3f2a1c9e-8d4b-4c1a-9e2f-0a1b2c3d4e5f"
cases := []struct {
name string
webhook string
target string
want string
}{
{
"plain names", "orders", "archive",
"archive-orders-archive-" + id + ".db",
},
{
"lowercased", "Orders", "Main Archive",
"archive-orders-main-archive-" + id + ".db",
},
{
"a run of other characters is one dash",
`a /\..b`, "c__--d",
"archive-a-b-c-d-" + id + ".db",
},
{
"no dash at either end", " --orders!! ", "(archive)",
"archive-orders-archive-" + id + ".db",
},
{
"path separators", "../../etc/passwd", "a/b",
"archive-etc-passwd-a-b-" + id + ".db",
},
{
"letters outside ASCII are dropped",
"Bestellungen Größe", "café",
"archive-bestellungen-gr-e-caf-" + id + ".db",
},
{
"nothing left is unnamed", "", "!!!",
"archive-unnamed-unnamed-" + id + ".db",
},
{
"cut to 40 characters", strings.Repeat("a", 50), "x",
"archive-" + strings.Repeat("a", 40) + "-x-" + id + ".db",
},
{
"no dash left by the cut",
strings.Repeat("a", 39) + " b", "x",
"archive-" + strings.Repeat("a", 39) + "-x-" + id + ".db",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assert.Equal(
t, tc.want,
archive.FileName(tc.webhook, tc.target, id),
)
})
}
}
// TestWrite_EachTargetHasItsOwnArchive proves two
// database targets of one webhook archive into separate files.
func TestWrite_EachTargetHasItsOwnArchive(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
first := env.seedDatabaseTarget(t, 0, "")
second := env.addDatabaseTarget(t, first.WebhookID, 0, "")
event := newEvent(`{"n":1}`)
for _, tgt := range []*database.Target{first, second} {
require.NoError(t, env.writers.Write(
databaseTargetDelivery(event, tgt),
))
}
require.NotEqual(
t, env.archivePath(first), env.archivePath(second),
)
assert.Equal(
t, []string{event.ID},
archivedEventIDs(t, env.archivePath(first)),
)
assert.Equal(
t, []string{event.ID},
archivedEventIDs(t, env.archivePath(second)),
)
}
// TestRename_MovesTheFile proves a rename moves the archive, rows
// and all, and that later writes go to the new name.
func TestRename_MovesTheFile(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, "")
oldPath := env.archivePath(tgt)
first := newEvent(`{"n":1}`)
require.NoError(t, env.writers.Write(databaseTargetDelivery(first, tgt)))
require.FileExists(t, oldPath)
require.NoError(
t, env.writers.Rename(tgt.ID, "Orders", "Long Term"),
)
newPath := filepath.Join(
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
)
assert.NoFileExists(t, oldPath)
assert.Equal(t, []string{first.ID}, archivedEventIDs(t, newPath))
second := newEvent(`{"n":2}`)
require.NoError(t, env.writers.Write(databaseTargetDelivery(second, tgt)))
assert.ElementsMatch(
t, []string{first.ID, second.ID},
archivedEventIDs(t, newPath),
)
assert.NoFileExists(
t, oldPath, "a write after the rename must use the new name",
)
}
// TestRename_NeverReplacesAFile plants a file at the new name, once
// the .db alone, once a lone -wal and once a lone -shm, and proves
// each time that the rename is refused, the planted file survives,
// and the archive keeps its name and its rows.
func TestRename_NeverReplacesAFile(t *testing.T) {
t.Parallel()
for _, suffix := range archiveFileSuffixes() {
t.Run("planted .db"+suffix, func(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, "")
oldPath := env.archivePath(tgt)
first := newEvent(`{"n":1}`)
require.NoError(
t, env.writers.Write(databaseTargetDelivery(first, tgt)),
)
newPath := filepath.Join(
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
)
plantedPath := newPath + suffix
require.NoError(
t, os.WriteFile(plantedPath, []byte("planted"), 0o600),
)
require.ErrorIs(
t, env.writers.Rename(tgt.ID, "Orders", "Long Term"),
archive.ErrNameTaken,
)
//nolint:gosec // reads the file the test planted under t.TempDir()
planted, err := os.ReadFile(plantedPath)
require.NoError(t, err)
assert.Equal(t, "planted", string(planted))
second := newEvent(`{"n":2}`)
require.NoError(
t, env.writers.Write(databaseTargetDelivery(second, tgt)),
)
assert.ElementsMatch(
t, []string{first.ID, second.ID},
archivedEventIDs(t, oldPath),
)
})
}
}
// TestRename_BeforeTheNameIsSaved covers the order the handlers
// use: they rename before they save the new name, so a delivery in
// between must write under the new name although the main database
// still has the old one. It also shows that renaming an archive that
// does not exist yet is not an error.
func TestRename_BeforeTheNameIsSaved(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, "")
require.NoError(
t, env.writers.Rename(tgt.ID, "Orders", "Archive"),
)
event := newEvent(`{"n":1}`)
require.NoError(t, env.writers.Write(databaseTargetDelivery(event, tgt)))
assert.FileExists(
t,
filepath.Join(
env.dataDir, "archive-orders-archive-"+tgt.ID+".db",
),
)
assert.NoFileExists(t, env.archivePath(tgt))
}
// TestArchiveWriter_RenameMovesSidecars proves a rename carries
// the -wal and -shm a crash can leave beside an archive no handle
// has opened since. SQLite finds them by name, so a -wal left
// behind would lose the transactions it holds.
func TestArchiveWriter_RenameMovesSidecars(t *testing.T) {
t.Parallel()
dir := t.TempDir()
oldPath := filepath.Join(dir, "archive-old.db")
newPath := filepath.Join(dir, "archive-new.db")
for _, suffix := range archiveFileSuffixes() {
require.NoError(
t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600),
)
}
w := archive.NewExportArchiveWriter(
oldPath, archiveTestLogger(), 0,
)
require.NoError(t, w.Rename("archive-new.db"))
for _, suffix := range archiveFileSuffixes() {
assert.NoFileExists(t, oldPath+suffix)
assert.FileExists(t, newPath+suffix)
}
assert.Equal(t, newPath, w.Path())
}
// TestArchiveWriter_RenameMovesBackOnFailure makes the -wal fail to
// move after the .db has moved, and proves the .db is moved back, so
// the archive is never split across two names. The new name is 255
// bytes, the longest a file name may be, so the .db can take it but
// the -wal, four bytes longer, cannot.
func TestArchiveWriter_RenameMovesBackOnFailure(t *testing.T) {
t.Parallel()
dir := t.TempDir()
oldPath := filepath.Join(dir, "archive-old.db")
newName := strings.Repeat("a", 252) + ".db"
for _, suffix := range archiveFileSuffixes() {
require.NoError(
t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600),
)
}
w := archive.NewExportArchiveWriter(
oldPath, archiveTestLogger(), 0,
)
require.Error(t, w.Rename(newName))
for _, suffix := range archiveFileSuffixes() {
assert.FileExists(t, oldPath+suffix)
}
assert.NoFileExists(t, filepath.Join(dir, newName))
assert.Equal(t, oldPath, w.Path())
}
// TestArchiveWriter_RenameMovesBackEveryFile renames a target with a
// file without a period and a file for a month, and proves that when
// the month's file fails to move, the file already moved is moved back:
// both files are under the old name with their rows, and nothing is
// under the new name. The new name is 251 bytes, so the file without a
// period, with its -wal and -shm, can take it, but the month's file,
// eight bytes longer, cannot.
func TestArchiveWriter_RenameMovesBackEveryFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
oldPath := filepath.Join(dir, "archive-old.db")
monthPath := filepath.Join(dir, "archive-old-2026-03.db")
w := archive.NewExportArchiveWriter(oldPath, archiveTestLogger(), 0)
require.NoError(t, w.WritePeriod(
archive.ExportArchivedEvent{EventID: "in-none"}, 0, "",
))
require.NoError(t, w.WritePeriod(
archive.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())
}
// TestRenameLock_HeldByOneEditAtATime holds the rename lock, as an edit
// does from loading the stored names until it has saved them. While it
// is held, the edit's rename and an archive write still finish, since
// neither takes it, and the event ends up under the new name whichever
// ran first. A second edit waits for the lock until the first lets go.
func TestRenameLock_HeldByOneEditAtATime(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, "")
event := newEvent(`{"n":1}`)
env.writers.RenameLock().Lock()
finished := make(chan error, 2)
go func() {
finished <- env.writers.Rename(tgt.ID, "Orders", "Long Term")
}()
go func() {
finished <- env.writers.Write(databaseTargetDelivery(event, tgt))
}()
for range 2 {
select {
case err := <-finished:
require.NoError(t, err)
case <-time.After(10 * time.Second):
t.Fatal("a rename or a write waited for the rename lock")
}
}
newPath := filepath.Join(
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
)
assert.Equal(t, []string{event.ID}, archivedEventIDs(t, newPath))
secondEdit := make(chan struct{})
go func() {
env.writers.RenameLock().Lock()
close(secondEdit)
env.writers.RenameLock().Unlock()
}()
select {
case <-secondEdit:
t.Fatal("a second edit took the rename lock while it was held")
case <-time.After(100 * time.Millisecond):
}
env.writers.RenameLock().Unlock()
select {
case <-secondEdit:
case <-time.After(10 * time.Second):
t.Fatal("a second edit did not get the rename lock once released")
}
}
-280
View File
@@ -1,280 +0,0 @@
package archive
import (
"context"
"log/slog"
"time"
"go.uber.org/fx"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
// ErrExportArchiveWriterEvicted exposes the sentinel returned by
// an evicted archive writer. It carries the Err prefix rather
// than this file's usual Export one because it is a sentinel
// error.
var ErrExportArchiveWriterEvicted = errArchiveWriterEvicted
// ExportArchivedEvent aliases the archive row type so black-box
// tests can construct and read archive rows.
type ExportArchivedEvent = archivedEvent
// ExportArchiveWriter wraps an archiveWriter so black-box tests
// can exercise the archive file mechanics.
type ExportArchiveWriter struct {
w *archiveWriter
}
// NewExportArchiveWriter builds an archive writer for tests,
// optionally overriding the reopen debounce (a non-positive
// debounce keeps the production default).
func NewExportArchiveWriter(
path string, log *slog.Logger, debounce time.Duration,
) *ExportArchiveWriter {
w := newArchiveWriter(path, log)
if debounce > 0 {
w.debounce = debounce
}
return &ExportArchiveWriter{w: w}
}
// Write archives a row through the writer, into the file named
// without a period.
func (e *ExportArchiveWriter) Write(
row ExportArchivedEvent, expiry time.Duration,
) error {
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.
func (e *ExportArchiveWriter) Open(expiry time.Duration) error {
return e.w.open(e.w.path, expiry)
}
// Reopen closes and reopens the archive file.
func (e *ExportArchiveWriter) Reopen(
expiry time.Duration,
) error {
return e.w.reopen(e.w.path, expiry)
}
// SetNow replaces the clock the writer measures its reopen
// debounce on.
func (e *ExportArchiveWriter) SetNow(now func() time.Time) {
e.w.now = now
}
// Reopens reports how many times the file has been opened.
func (e *ExportArchiveWriter) Reopens() int {
return e.w.reopens
}
// DB returns the writer's current open handle for row
// inspection in tests.
func (e *ExportArchiveWriter) DB() *gorm.DB {
return e.w.db
}
// Path returns the archive file the writer owns.
func (e *ExportArchiveWriter) Path() string {
return e.w.path
}
// OpenExisting opens the archive without permitting creation,
// the way the idle sweep does.
func (e *ExportArchiveWriter) OpenExisting(
expiry time.Duration,
) error {
return e.w.openMode(e.w.path, archiveModeExisting, expiry)
}
// SweepExpired runs an idle sweep of the archive.
func (e *ExportArchiveWriter) SweepExpired(
expiry time.Duration,
) error {
return e.w.sweepExpired(expiry)
}
// Evict marks the writer evicted and closes its handle, exactly
// as leaving the registry does.
func (e *ExportArchiveWriter) Evict() {
e.w.evict()
}
// Rename gives the archive file a new name in the same directory,
// as a rename of the webhook or target does.
func (e *ExportArchiveWriter) Rename(name string) error {
return e.w.rename(name)
}
// HandleOpen reports whether the writer currently holds an open
// archive handle.
func (e *ExportArchiveWriter) HandleOpen() bool {
e.w.mu.Lock()
defer e.w.mu.Unlock()
return e.w.db != nil
}
// Same reports whether both wrappers refer to the very same
// underlying archive writer, so a test can prove a registry entry
// is the writer it was handed rather than a replacement.
func (e *ExportArchiveWriter) Same(
other *ExportArchiveWriter,
) bool {
return other != nil && e.w == other.w
}
// ExportArchiveWriterFor returns the archive writer the registry
// currently caches for a database target, or nil when none is
// cached. It never creates one, so a test can hold a reference to
// the very writer an eviction is about to detach.
func (ws *Writers) ExportArchiveWriterFor(
targetID string,
) *ExportArchiveWriter {
ws.mu.Lock()
defer ws.mu.Unlock()
w, ok := ws.writers[targetID]
if !ok {
return nil
}
return &ExportArchiveWriter{w: w}
}
// ExportHasArchiveWriter reports whether the registry currently
// caches an archive writer for a target.
func (ws *Writers) ExportHasArchiveWriter(
targetID string,
) bool {
ws.mu.Lock()
defer ws.mu.Unlock()
_, ok := ws.writers[targetID]
return ok
}
// ExportArchiveHandleOpen reports whether the cached archive
// writer for a target holds an open database handle. It
// returns false when no writer is cached.
func (ws *Writers) ExportArchiveHandleOpen(
targetID string,
) bool {
ws.mu.Lock()
w, ok := ws.writers[targetID]
ws.mu.Unlock()
if !ok {
return false
}
w.mu.Lock()
defer w.mu.Unlock()
return w.db != nil
}
// ExportEnsureArchiveWriter creates (if needed) and returns the
// archive file path of the cached writer for a target, so a
// test can prime the registry the way a delivery would.
func (ws *Writers) ExportEnsureArchiveWriter(
targetID string,
) (string, error) {
w, err := ws.writerFor(targetID)
if err != nil {
return "", err
}
return w.path, nil
}
// ExportSweepWriterFor takes a target's registry writer exactly
// as the idle sweep does, reporting whether the sweep had to
// create the entry. It lets a test drive the registry through the
// sweep's own entry point instead of choreographing goroutines.
func (ws *Writers) ExportSweepWriterFor(
targetID string,
) (*ExportArchiveWriter, bool, error) {
w, created, err := ws.sweepWriterFor(targetID)
if err != nil {
return nil, false, err
}
return &ExportArchiveWriter{w: w}, created, nil
}
// ExportReleaseSweepWriter releases a sweep-created registry entry
// exactly as a finished sweep does.
func (ws *Writers) ExportReleaseSweepWriter(
targetID string, w *ExportArchiveWriter,
) {
ws.releaseSweepWriter(targetID, w.w)
}
// NewTestSweeper builds a Sweeper backed by the given main
// database and archive writers, without the fx lifecycle.
// Intended for tests.
func NewTestSweeper(
db *database.Database,
writers *Writers,
log *slog.Logger,
) *Sweeper {
return &Sweeper{
db: db,
writers: writers,
log: log,
interval: time.Hour,
}
}
// ExportSweep runs a single archive sweep synchronously for
// tests.
func (s *Sweeper) ExportSweep(ctx context.Context) {
s.sweep(ctx)
}
// ExportStart starts the sweeper's background loop for tests.
func (s *Sweeper) ExportStart() {
s.start()
}
// ExportRegisterHooks registers the sweeper's real fx lifecycle
// hooks on a lifecycle supplied by a test, so a test can drive
// the exact OnStart/OnStop functions the application runs and
// hand OnStart the kind of context fx actually supplies.
func (s *Sweeper) ExportRegisterHooks(lc fx.Lifecycle) {
s.registerHooks(lc)
}
// ExportStop stops the sweeper's background loop for tests.
func (s *Sweeper) ExportStop(ctx context.Context) error {
return s.stop(ctx)
}
// ExportWedgeLoop adds a goroutine to the sweeper's WaitGroup
// that never observes cancellation and returns only when release
// is closed. It stands in for a prune stuck on a locked archive.
func (s *Sweeper) ExportWedgeLoop(
release <-chan struct{},
) {
s.wg.Go(func() {
<-release
})
}
// ExportSetInterval overrides the sweep interval for tests.
func (s *Sweeper) ExportSetInterval(d time.Duration) {
s.interval = d
}
+2 -27
View File
@@ -18,7 +18,6 @@ import (
"sneak.berlin/go/webhooker/internal/banner" "sneak.berlin/go/webhooker/internal/banner"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/datadir" "sneak.berlin/go/webhooker/internal/datadir"
"sneak.berlin/go/webhooker/internal/db/migrations"
"sneak.berlin/go/webhooker/internal/gormlog" "sneak.berlin/go/webhooker/internal/gormlog"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
) )
@@ -250,21 +249,12 @@ func (d *Database) connectTo(dataDir string) error {
return fmt.Errorf("migrating %s: %w", dbPath, err) return fmt.Errorf("migrating %s: %w", dbPath, err)
} }
err = parseModels(
db, &Setting{}, &User{}, &APIKey{},
&Webhook{}, &Entrypoint{}, &Target{},
)
if err != nil {
return fmt.Errorf("connecting to %s: %w", dbPath, err)
}
return nil return nil
} }
// migrate applies the main database's SQL files. See
// internal/db/migrations.
func (d *Database) migrate() error { func (d *Database) migrate() error {
err := migrations.Apply(d.db, migrations.Main) // Run GORM auto-migrations
err := d.Migrate()
if err != nil { if err != nil {
d.log.Error( d.log.Error(
"failed to run database migrations", "failed to run database migrations",
@@ -279,21 +269,6 @@ func (d *Database) migrate() error {
return nil return nil
} }
// parseModels has GORM parse each model for db before the handle is
// shared. GORM parses a model the first time a handle uses it, and two
// goroutines doing that at once for models that refer to each other,
// as Webhook and Target do, race inside GORM.
func parseModels(db *gorm.DB, models ...any) error {
for _, model := range models {
err := (&gorm.Statement{DB: db}).Parse(model)
if err != nil {
return fmt.Errorf("parsing model %T: %w", model, err)
}
}
return nil
}
// ensureAdminUser creates the bootstrap admin account when the // ensureAdminUser creates the bootstrap admin account when the
// deployment has no users at all. // deployment has no users at all.
func (d *Database) ensureAdminUser() error { func (d *Database) ensureAdminUser() error {
+113 -8
View File
@@ -2,6 +2,7 @@ package database
import ( import (
"fmt" "fmt"
"log/slog"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -17,14 +18,14 @@ const omitAssociationsCallback = "webhooker:omit_associations"
// //
// A per-webhook database holds the event tier only, but Delivery // A per-webhook database holds the event tier only, but Delivery
// declares belongs-to Event and Target and the delivery engine fills // declares belongs-to Event and Target and the delivery engine fills
// both in memory before writing. Without this guard GORM upserts those // both in memory before writing. Without this guard GORM upserts
// parent rows here on the delivery and retry write paths, and the // those parent rows here on the delivery and retry write paths,
// target, whose config holds destination URLs and bearer credentials, // copying targets.config, which holds destination URLs and bearer
// goes to a targets table this file does not have, which fails the // credentials, into the file most likely to be backed up or handed
// write. Registering the guard on the connection // to someone else. Registering the guard on the connection covers
// covers every write path, including writes inside a transaction and // every write path, including writes inside a transaction and write
// write paths added later. Every event-tier row this file holds is // paths added later. Every event-tier row this file holds is written
// written explicitly, so nothing depends on the automatic save. // explicitly, so nothing depends on the automatic save.
func omitAssociations(db *gorm.DB) error { func omitAssociations(db *gorm.DB) error {
omit := func(tx *gorm.DB) { omit := func(tx *gorm.DB) {
tx.Statement.Omits = append( tx.Statement.Omits = append(
@@ -52,3 +53,107 @@ func omitAssociations(db *gorm.DB) error {
return nil return nil
} }
// eventDBSweptVersion is the PRAGMA user_version purgeTargetRows
// stamps into a per-webhook database once it has removed any leaked
// target rows *and* the VACUUM that removes their bytes has returned.
// Nothing else in the tree uses user_version, so 0 means "not swept
// by this build".
//
// The stamp, not the DELETE, is what records that a file is done. A
// DELETE commits on its own, so a sweep that is interrupted or whose
// VACUUM fails leaves a file whose rows are gone but whose credential
// bytes are still in the free pages -- indistinguishable, by row
// count, from a file that never leaked. Both leave the stamp unset,
// so the next open sweeps again.
const eventDBSweptVersion = 1
// purgeTargetRows deletes target rows that an earlier build's
// association upsert wrote into a per-webhook database, and rewrites
// the file so their bytes are gone with them. AutoMigrate creates a
// targets table in every one of these files because Delivery declares
// a belongs-to Target, but nothing in the event tier may put rows in
// it. The rows it did put there are junk, not history: they carry an
// empty webhook_id, and delivery rows resolve their target against
// the main database, so nothing here refers to them.
//
// The DELETE only unlinks the rows: modernc.org/sqlite leaves
// secure_delete at SQLite's default of off, so the credential bytes
// stay readable in the file's free pages and a backup of a swept file
// would still hand them over. VACUUM rewrites the file without them.
//
// This runs before every migration and is gated on
// eventDBSweptVersion, so a file pays for the rewrite once, on the
// first open that finds it unstamped, and every open after that is a
// PRAGMA read. A file this build created is stamped before its
// targets table exists, so it never vacuums at all. A failure here
// fails the open with the stamp left unset, so the sweep is retried
// rather than skipped -- a webhook whose file cannot be swept stays
// unusable instead of quietly serving from a file that still holds
// recoverable credentials.
func purgeTargetRows(
db *gorm.DB, log *slog.Logger, webhookID string,
) error {
var version int
// Row().Scan, not (*gorm.DB).Scan: see internal/gormlog.
err := db.Raw("PRAGMA user_version").Row().Scan(&version)
if err != nil {
return fmt.Errorf(
"reading sweep marker of webhook database %s: %w",
webhookID, err,
)
}
if version >= eventDBSweptVersion {
return nil
}
var purged int64
if db.Migrator().HasTable("targets") {
res := db.Exec("DELETE FROM targets")
if res.Error != nil {
return fmt.Errorf(
"purging target rows from webhook database %s: %w",
webhookID, res.Error,
)
}
purged = res.RowsAffected
// Unconditional: a zero row count here does not mean there is
// nothing to remove, only that no *live* row is left. See
// eventDBSweptVersion.
err = db.Exec("VACUUM").Error
if err != nil {
return fmt.Errorf(
"purged %d leaked target rows from webhook database "+
"%s but vacuuming it failed, so the deleted "+
"target credentials are still recoverable from "+
"the file; it stays marked unswept and the next "+
"open retries: %w",
purged, webhookID, err,
)
}
}
err = db.Exec(fmt.Sprintf(
"PRAGMA user_version = %d", eventDBSweptVersion,
)).Error
if err != nil {
return fmt.Errorf(
"marking webhook database %s swept: %w", webhookID, err,
)
}
if purged > 0 {
log.Warn(
"purged leaked target rows from per-webhook database",
"webhook_id", webhookID,
"rows", purged,
)
}
return nil
}
+383 -7
View File
@@ -1,15 +1,392 @@
package database_test package database_test
import ( import (
"bytes"
"database/sql"
"fmt"
"os"
"path/filepath"
"testing" "testing"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
_ "modernc.org/sqlite"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/database/databasetest" "sneak.berlin/go/webhooker/internal/database/databasetest"
) )
// testDataDirPerm is the mode the test data directory is created
// with.
const testDataDirPerm = 0o750
// eventDBDataDir returns a data directory that a WebhookDBManager
// can be pointed at.
func eventDBDataDir(t *testing.T) string {
t.Helper()
dir := filepath.Join(t.TempDir(), "events")
require.NoError(t, os.MkdirAll(dir, testDataDirPerm))
return dir
}
// openRawEventDB opens the per-webhook database file directly,
// without the manager, so a test can put a file on disk in a state
// the manager has to cope with, or inspect one afterwards.
func openRawEventDB(
t *testing.T, dataDir, webhookID string,
) *sql.DB {
t.Helper()
path := filepath.Join(
dataDir, fmt.Sprintf("events-%s.db", webhookID),
)
sqlDB, err := sql.Open(
"sqlite",
fmt.Sprintf("file:%s?mode=rwc", path),
)
require.NoError(t, err)
t.Cleanup(func() { _ = sqlDB.Close() })
return sqlDB
}
// eventDBFileBytes reads a per-webhook database file off disk, so a
// test can assert on what the file itself still holds rather than on
// what a query returns.
func eventDBFileBytes(t *testing.T, dataDir, webhookID string) []byte {
t.Helper()
//nolint:gosec // reads a file the test just created under t.TempDir()
raw, err := os.ReadFile(filepath.Join(
dataDir, fmt.Sprintf("events-%s.db", webhookID),
))
require.NoError(t, err)
return raw
}
// eventDBUserVersion returns the PRAGMA user_version of a per-webhook
// database file, which is the marker purgeTargetRows stamps once it
// has swept and vacuumed.
func eventDBUserVersion(t *testing.T, sqlDB *sql.DB) int {
t.Helper()
var version int
require.NoError(t, sqlDB.QueryRowContext(
t.Context(), "PRAGMA user_version",
).Scan(&version))
return version
}
// clearEventDBSweptMarker resets the sweep marker to 0, which is what
// a file written by a build without the sweep looks like. Tests that
// seed a leaked row have to create the file through the manager to
// get the real targets table shape, and that stamps it.
func clearEventDBSweptMarker(t *testing.T, sqlDB *sql.DB) {
t.Helper()
_, err := sqlDB.ExecContext(t.Context(), "PRAGMA user_version = 0")
require.NoError(t, err)
}
// countTargetRows returns the number of rows in the targets table of
// a per-webhook database file, or -1 if the table does not exist.
func countTargetRows(t *testing.T, sqlDB *sql.DB) int {
t.Helper()
var tables int
require.NoError(t, sqlDB.QueryRowContext(
t.Context(),
"SELECT count(*) FROM sqlite_master "+
"WHERE type = 'table' AND name = 'targets'",
).Scan(&tables))
if tables == 0 {
return -1
}
var rows int
require.NoError(t, sqlDB.QueryRowContext(
t.Context(), "SELECT count(*) FROM targets",
).Scan(&rows))
return rows
}
// TestOpenPurgesLeakedTargetRows covers the sweep for event
// databases written by a build that let GORM upsert target rows
// into them: opening the database clears them, and opening it again
// is a no-op.
func TestOpenPurgesLeakedTargetRows(t *testing.T) {
t.Parallel()
dataDir := eventDBDataDir(t)
webhookID := uuid.New().String()
// Create the file the way the application does, so the targets
// table has exactly the shape AutoMigrate gives it, then write
// a leaked row into it the way the association upsert did.
initial := databasetest.NewWebhookDBManager(t, dataDir)
_, err := initial.GetDB(webhookID)
require.NoError(t, err)
require.NoError(t, initial.CloseAll())
seed := openRawEventDB(t, dataDir, webhookID)
_, err = seed.ExecContext(
t.Context(),
"INSERT INTO targets "+
"(id, webhook_id, name, type, config) "+
"VALUES (?, '', ?, ?, ?)",
uuid.New().String(),
"leaked-target",
"slack",
`{"webhookUrl":"https://hooks.example/T000/B000/secret"}`,
)
require.NoError(t, err)
require.Equal(t, 1, countTargetRows(t, seed))
clearEventDBSweptMarker(t, seed)
require.NoError(t, seed.Close())
mgr := databasetest.NewWebhookDBManager(t, dataDir)
_, err = mgr.GetDB(webhookID)
require.NoError(t, err)
require.NoError(t, mgr.CloseAll())
check := openRawEventDB(t, dataDir, webhookID)
assert.Zero(t, countTargetRows(t, check))
assert.Equal(
t, 1, eventDBUserVersion(t, check),
"a completed sweep must mark the file so later opens skip it",
)
require.NoError(t, check.Close())
// Idempotent: a second open leaves it at zero and does not
// error.
again := databasetest.NewWebhookDBManager(t, dataDir)
_, err = again.GetDB(webhookID)
require.NoError(t, err)
require.NoError(t, again.CloseAll())
recheck := openRawEventDB(t, dataDir, webhookID)
assert.Zero(t, countTargetRows(t, recheck))
}
// TestOpenPurgeRemovesCredentialBytes covers the sweep at the level
// that matters for a backup handed to someone else: the leaked
// credential must be gone from the raw bytes of the file, not merely
// unreachable by query. A bare DELETE unlinks the row and leaves the
// bytes readable in the free pages, so this fails without the VACUUM
// in purgeTargetRows.
func TestOpenPurgeRemovesCredentialBytes(t *testing.T) {
t.Parallel()
dataDir := eventDBDataDir(t)
webhookID := uuid.New().String()
credential := "T00000000/B00000000/" + uuid.New().String()
initial := databasetest.NewWebhookDBManager(t, dataDir)
_, err := initial.GetDB(webhookID)
require.NoError(t, err)
require.NoError(t, initial.CloseAll())
seed := openRawEventDB(t, dataDir, webhookID)
_, err = seed.ExecContext(
t.Context(),
"INSERT INTO targets "+
"(id, webhook_id, name, type, config) "+
"VALUES (?, '', ?, ?, ?)",
uuid.New().String(),
"leaked-target",
"slack",
fmt.Sprintf(
`{"webhookUrl":"https://hooks.example/%s"}`, credential,
),
)
require.NoError(t, err)
clearEventDBSweptMarker(t, seed)
require.NoError(t, seed.Close())
// The seed has to be in the file for its absence later to mean
// anything.
require.True(
t,
bytes.Contains(
eventDBFileBytes(t, dataDir, webhookID),
[]byte(credential),
),
"seeded credential is not in the file, so this test proves nothing",
)
mgr := databasetest.NewWebhookDBManager(t, dataDir)
_, err = mgr.GetDB(webhookID)
require.NoError(t, err)
require.NoError(t, mgr.CloseAll())
assert.NotContains(
t,
string(eventDBFileBytes(t, dataDir, webhookID)),
credential,
"leaked credential is still recoverable from the raw file",
)
}
// TestOpenRevacuumsAfterIncompleteSweep covers the case a row count
// cannot see: the rows are already deleted but the file was never
// vacuumed, because an earlier sweep died between the two or its
// VACUUM failed. The credential bytes are still recoverable, and the
// unset marker is the only thing that says so, so the next open must
// vacuum rather than conclude from the empty table that there is
// nothing to do.
func TestOpenRevacuumsAfterIncompleteSweep(t *testing.T) {
t.Parallel()
dataDir := eventDBDataDir(t)
webhookID := uuid.New().String()
credential := "T00000000/B00000000/" + uuid.New().String()
initial := databasetest.NewWebhookDBManager(t, dataDir)
_, err := initial.GetDB(webhookID)
require.NoError(t, err)
require.NoError(t, initial.CloseAll())
seed := openRawEventDB(t, dataDir, webhookID)
_, err = seed.ExecContext(
t.Context(),
"INSERT INTO targets "+
"(id, webhook_id, name, type, config) "+
"VALUES (?, '', ?, ?, ?)",
uuid.New().String(),
"leaked-target",
"slack",
fmt.Sprintf(
`{"webhookUrl":"https://hooks.example/%s"}`, credential,
),
)
require.NoError(t, err)
// Exactly the state an interrupted sweep leaves: rows gone,
// marker unset, bytes still in the free pages.
_, err = seed.ExecContext(t.Context(), "DELETE FROM targets")
require.NoError(t, err)
require.Zero(t, countTargetRows(t, seed))
clearEventDBSweptMarker(t, seed)
require.NoError(t, seed.Close())
require.True(
t,
bytes.Contains(
eventDBFileBytes(t, dataDir, webhookID),
[]byte(credential),
),
"the deleted row's bytes must still be in the file, or this "+
"test proves nothing",
)
mgr := databasetest.NewWebhookDBManager(t, dataDir)
_, err = mgr.GetDB(webhookID)
require.NoError(t, err)
require.NoError(t, mgr.CloseAll())
assert.NotContains(
t,
string(eventDBFileBytes(t, dataDir, webhookID)),
credential,
"an interrupted sweep was not retried, so the credential is "+
"still recoverable from the raw file",
)
check := openRawEventDB(t, dataDir, webhookID)
assert.Equal(t, 1, eventDBUserVersion(t, check))
}
// TestOpenSkipsSweptDatabase covers the other half of the marker: a
// file this build created is marked without ever being vacuumed, and
// a marked file is not swept again.
func TestOpenSkipsSweptDatabase(t *testing.T) {
t.Parallel()
dataDir := eventDBDataDir(t)
webhookID := uuid.New().String()
mgr := databasetest.NewWebhookDBManager(t, dataDir)
_, err := mgr.GetDB(webhookID)
require.NoError(t, err)
require.NoError(t, mgr.CloseAll())
marked := openRawEventDB(t, dataDir, webhookID)
assert.Equal(t, 1, eventDBUserVersion(t, marked))
// A marked file is left alone, so a row written into it survives
// a reopen. Nothing writes target rows any more; this stands in
// for the sweep having run.
_, err = marked.ExecContext(
t.Context(),
"INSERT INTO targets "+
"(id, webhook_id, name, type, config) "+
"VALUES (?, '', ?, ?, ?)",
uuid.New().String(), "sentinel", "slack", `{}`,
)
require.NoError(t, err)
require.NoError(t, marked.Close())
again := databasetest.NewWebhookDBManager(t, dataDir)
_, err = again.GetDB(webhookID)
require.NoError(t, err)
require.NoError(t, again.CloseAll())
check := openRawEventDB(t, dataDir, webhookID)
assert.Equal(
t, 1, countTargetRows(t, check),
"a marked file must not be swept again",
)
}
// TestOpenSucceedsWithoutTargetsTable covers an existing event
// database that never grew a targets table. The sweep must not fail
// startup on it.
func TestOpenSucceedsWithoutTargetsTable(t *testing.T) {
t.Parallel()
dataDir := eventDBDataDir(t)
webhookID := uuid.New().String()
seed := openRawEventDB(t, dataDir, webhookID)
_, err := seed.ExecContext(
t.Context(),
"CREATE TABLE events (id text PRIMARY KEY)",
)
require.NoError(t, err)
require.NoError(t, seed.Close())
mgr := databasetest.NewWebhookDBManager(t, dataDir)
db, err := mgr.GetDB(webhookID)
require.NoError(t, err)
assert.NotNil(t, db)
require.NoError(t, mgr.CloseAll())
}
// TestEventDBCreateOmitsAssociations covers the connection-level // TestEventDBCreateOmitsAssociations covers the connection-level
// guard directly: a Delivery carrying its Event and Target in // guard directly: a Delivery carrying its Event and Target in
// memory, written through the manager's handle, must store only the // memory, written through the manager's handle, must store only the
@@ -17,9 +394,10 @@ import (
func TestEventDBCreateOmitsAssociations(t *testing.T) { func TestEventDBCreateOmitsAssociations(t *testing.T) {
t.Parallel() t.Parallel()
dataDir := eventDBDataDir(t)
webhookID := uuid.New().String() webhookID := uuid.New().String()
mgr := databasetest.NewWebhookDBManager(t, t.TempDir()) mgr := databasetest.NewWebhookDBManager(t, dataDir)
db, err := mgr.GetDB(webhookID) db, err := mgr.GetDB(webhookID)
require.NoError(t, err) require.NoError(t, err)
@@ -28,8 +406,7 @@ func TestEventDBCreateOmitsAssociations(t *testing.T) {
WebhookID: webhookID, WebhookID: webhookID,
Name: "leaky-target", Name: "leaky-target",
Type: database.TargetTypeSlack, Type: database.TargetTypeSlack,
URL: "https://hooks.example/secret", Config: `{"webhookUrl":"https://hooks.example/secret"}`,
MaxAttempts: 1,
} }
target.ID = uuid.New().String() target.ID = uuid.New().String()
@@ -55,9 +432,8 @@ func TestEventDBCreateOmitsAssociations(t *testing.T) {
require.NoError(t, db.Model(d). require.NoError(t, db.Model(d).
Update("status", database.DeliveryStatusDelivered). Update("status", database.DeliveryStatusDelivered).
Error) Error)
// The file has no targets table, so the create and update above
// would have failed had GORM tried to save the target.
assert.False(t, db.Migrator().HasTable(&database.Target{}))
require.NoError(t, mgr.CloseAll()) require.NoError(t, mgr.CloseAll())
check := openRawEventDB(t, dataDir, webhookID)
assert.Zero(t, countTargetRows(t, check))
} }
+25 -6
View File
@@ -13,8 +13,10 @@ import (
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
// TestWebhookDBManager_OpenAddsEventTierIndexes verifies that a new // TestWebhookDBManager_OpenAddsEventTierIndexes verifies that opening a
// per-webhook database has these indexes. // per-webhook database that predates these indexes creates them. It
// stands in for an older database file by dropping the indexes
// AutoMigrate just created, then reopening the same file.
func TestWebhookDBManager_OpenAddsEventTierIndexes(t *testing.T) { func TestWebhookDBManager_OpenAddsEventTierIndexes(t *testing.T) {
t.Parallel() t.Parallel()
@@ -40,9 +42,27 @@ func TestWebhookDBManager_OpenAddsEventTierIndexes(t *testing.T) {
db, err := mgr.GetDB(webhookID) db, err := mgr.GetDB(webhookID)
require.NoError(t, err) require.NoError(t, err)
// A fresh database has them.
for _, ix := range indexes {
require.True(t, db.Migrator().HasIndex(ix.model, ix.name))
}
// Stand in for a database file created before the indexes existed.
for _, ix := range indexes {
require.NoError(t, db.Migrator().DropIndex(ix.model, ix.name))
require.False(t, db.Migrator().HasIndex(ix.model, ix.name))
}
// Drop the cached connection so the next open reopens the file and
// runs AutoMigrate against it, as a restart would.
require.NoError(t, mgr.CloseAll())
db, err = mgr.GetDB(webhookID)
require.NoError(t, err)
for _, ix := range indexes { for _, ix := range indexes {
assert.True(t, db.Migrator().HasIndex(ix.model, ix.name), assert.True(t, db.Migrator().HasIndex(ix.model, ix.name),
"a new database should have %s", ix.name) "opening the existing database should create %s", ix.name)
} }
} }
@@ -79,9 +99,8 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
byStatus := "idx_deliveries_status (status=? AND deleted_at=?)" byStatus := "idx_deliveries_status (status=? AND deleted_at=?)"
byEvent := "idx_deliveries_event_id (event_id=? AND deleted_at=?)" byEvent := "idx_deliveries_event_id (event_id=? AND deleted_at=?)"
// The delivery engine: the recovery routine, which reads the // The delivery engine: recovery and the retry sweep, the sweep for
// retrying deliveries and, on a tick of the sweep, the pending ones // stranded pending deliveries, and the queue depth count.
// untouched for a while; and the queue depth count.
assertPlanUses(t, db, dry.Where( assertPlanUses(t, db, dry.Where(
"status = ?", database.DeliveryStatusRetrying, "status = ?", database.DeliveryStatusRetrying,
).Find(&deliveries), byStatus) ).Find(&deliveries), byStatus)
+2 -1
View File
@@ -45,7 +45,8 @@ type Event struct {
// ResubmittedFromID names the event this one was copied from by // ResubmittedFromID names the event this one was copied from by
// an operator resubmit. It is nil for an event that arrived on // an operator resubmit. It is nil for an event that arrived on
// the receiver. It is not a foreign key: the source event can be // the receiver, which is every event created before the column
// existed. It is not a foreign key: the source event can be
// reaped by retention while its copies remain, and the id is // reaped by retention while its copies remain, and the id is
// kept as the record of where the copy came from either way. // kept as the record of where the copy came from either way.
ResubmittedFromID *string `gorm:"type:uuid;index:idx_events_resubmitted_from_id,priority:1;index:idx_events_entrypoint_id,priority:3" json:"resubmittedFromId,omitempty"` ResubmittedFromID *string `gorm:"type:uuid;index:idx_events_resubmitted_from_id,priority:1;index:idx_events_entrypoint_id,priority:3" json:"resubmittedFromId,omitempty"`
-1
View File
@@ -25,7 +25,6 @@ func TestPreloadedModelsMarshalWithoutTheirParent(t *testing.T) {
Targets: []database.Target{{ Targets: []database.Target{{
Name: "log", Name: "log",
Type: database.TargetTypeLog, Type: database.TargetTypeLog,
MaxAttempts: 1,
}}, }},
} }
require.NoError(t, db.Create(&stored).Error) require.NoError(t, db.Create(&stored).Error)
+7 -20
View File
@@ -29,10 +29,8 @@ func marshalModel(t *testing.T, v any) string {
// these tags the first handler that marshals a model serialises the // these tags the first handler that marshals a model serialises the
// secret with it. Each field below is a live credential: // secret with it. Each field below is a live credential:
// //
// - Target.URL is an incoming-webhook URL whose path segments are // - Target.Config holds an incoming-webhook URL whose path segments
// the bearer token. // are the bearer token.
// - Target.Headers holds header values, routinely authorization
// tokens.
// - APIKey.Key is a bearer token outright. // - APIKey.Key is a bearer token outright.
// - Setting.Value holds the session encryption key. // - Setting.Value holds the session encryption key.
// - User.Password holds the Argon2 hash, and was already tagged. // - User.Password holds the Argon2 hash, and was already tagged.
@@ -46,21 +44,11 @@ func TestModelsDoNotMarshalTheirSecrets(t *testing.T) {
model any model any
}{ }{
{ {
name: "target url", name: "target config",
model: database.Target{ model: database.Target{
Name: keptField, Name: keptField,
Type: database.TargetTypeSlack, Type: database.TargetTypeSlack,
URL: "https://h/s/" + marker, Config: `{"webhookUrl":"https://h/s/` + marker + `"}`,
MaxAttempts: 1,
},
},
{
name: "target headers",
model: database.Target{
Name: keptField,
Type: database.TargetTypeHTTP,
Headers: map[string]string{"Authorization": marker},
MaxAttempts: 1,
}, },
}, },
{ {
@@ -98,10 +86,10 @@ func TestModelsDoNotMarshalTheirSecrets(t *testing.T) {
} }
} }
// TestWebhookMarshalsNoTargetURL covers the nested case: a webhook // TestWebhookMarshalsNoTargetConfig covers the nested case: a webhook
// marshalled with its targets preloaded must not carry the credential // marshalled with its targets preloaded must not carry the credential
// through the association either. // through the association either.
func TestWebhookMarshalsNoTargetURL(t *testing.T) { func TestWebhookMarshalsNoTargetConfig(t *testing.T) {
t.Parallel() t.Parallel()
const marker = "QQNESTEDMARKERQQ" const marker = "QQNESTEDMARKERQQ"
@@ -110,8 +98,7 @@ func TestWebhookMarshalsNoTargetURL(t *testing.T) {
Name: keptField, Name: keptField,
Targets: []database.Target{{ Targets: []database.Target{{
Name: "slack", Name: "slack",
URL: "https://h/s/" + marker, Config: `{"webhookUrl":"https://h/s/` + marker + `"}`,
MaxAttempts: 1,
}}, }},
}) })
+11 -39
View File
@@ -1,7 +1,5 @@
package database package database
import "time"
// TargetType represents the type of delivery target // TargetType represents the type of delivery target
type TargetType string type TargetType string
@@ -13,9 +11,7 @@ const (
TargetTypeSlack TargetType = "slack" TargetTypeSlack TargetType = "slack"
) )
// Target represents a delivery target for a webhook. Its settings // Target represents a delivery target for a webhook
// are the fields from URL to ArchiveRotation; each type uses some of
// them, and the ones a type does not use stay empty.
type Target struct { type Target struct {
BaseModel BaseModel
@@ -24,42 +20,18 @@ type Target struct {
Type TargetType `gorm:"not null" json:"type"` Type TargetType `gorm:"not null" json:"type"`
Active bool `gorm:"default:true" json:"active"` Active bool `gorm:"default:true" json:"active"`
// URL is where an http or slack target sends each event. // Configuration fields (JSON stored based on type).
// //
// json:"-" because the URL is the target's credential: a Slack // json:"-" because the blob holds the target's credential — a
// incoming-webhook URL, or an http destination whose path segments // Slack incoming-webhook URL, or an http destination whose path
// are the secret. delivery.TargetView is the masking barrier for // segments are the secret. delivery.TargetView is the masking
// the HTML path; this tag is the barrier for any handler that // barrier for the HTML path; this tag is the barrier for any
// marshals the model itself. // handler that marshals the model itself.
URL string `json:"-"` Config string `gorm:"type:text" json:"-"` // JSON configuration
// Headers is an http target's extra request headers, name to // For HTTP targets (max_retries=0 means fire-and-forget,
// value, stored as JSON text. json:"-" for the same reason as URL: // >0 enables retries with backoff)
// a header value is routinely an authorization token. MaxRetries int `json:"maxRetries,omitempty"`
Headers map[string]string `gorm:"serializer:json" json:"-"`
// TimeoutSeconds is an http target's per-request timeout; 0 uses
// the delivery engine's shared client timeout.
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
// ForwardQuery passes each event's query string on to an http
// target, appended to URL. Off, URL is sent exactly as stored.
ForwardQuery bool `json:"forwardQuery,omitempty"`
// ArchiveExpiry is how long a database target keeps each archived
// event; 0 keeps them forever.
ArchiveExpiry time.Duration `json:"archiveExpiry,omitempty"`
// ArchiveRotation is how often a database target starts a new
// archive file: none, monthly, daily or hourly.
ArchiveRotation string `json:"archiveRotation,omitempty"`
// MaxAttempts is how many times an HTTP or Slack target attempts
// each delivery in all, at least 1. 1 is a single attempt with no
// circuit breaker; above 1, a failed attempt is retried with
// backoff behind the breaker. The table's check constraint refuses
// a target saved with less than 1, whatever its type.
MaxAttempts int `gorm:"not null;check:max_attempts >= 1" json:"maxAttempts,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.
-63
View File
@@ -1,63 +0,0 @@
package database_test
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
)
// newHTTPTarget returns an http target that makes maxAttempts attempts
// at each delivery.
func newHTTPTarget(maxAttempts int) *database.Target {
return &database.Target{
WebhookID: uuid.New().String(),
Name: "target",
Type: database.TargetTypeHTTP,
URL: "https://example.com/",
MaxAttempts: maxAttempts,
}
}
// TestTargetCreate_RefusesZeroAttempts relies on the targets table
// alone: nothing in the model checks the number, so the check
// constraint is what keeps a target with 0 attempts out of the
// database.
func TestTargetCreate_RefusesZeroAttempts(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
err := db.Create(newHTTPTarget(0)).Error
require.Error(t, err)
assert.Contains(t, err.Error(), "CHECK constraint failed")
}
func TestTargetCreate_AcceptsOneAttempt(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
require.NoError(t, db.Create(newHTTPTarget(1)).Error)
}
// TestTargetHeaders_StoredAsJSON reads the headers column back as
// text, to show it holds the header map as JSON.
func TestTargetHeaders_StoredAsJSON(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
target := newHTTPTarget(1)
target.Headers = map[string]string{"Authorization": "Bearer x"}
require.NoError(t, db.Create(target).Error)
var headers string
require.NoError(t, db.Raw(
"SELECT headers FROM targets WHERE id = ?", target.ID,
).Scan(&headers).Error)
assert.JSONEq(t, `{"Authorization":"Bearer x"}`, headers)
}
+56 -19
View File
@@ -4,13 +4,27 @@ import (
"math" "math"
"strconv" "strconv"
"time" "time"
"gorm.io/gorm"
) )
const ( const (
// DefaultRetentionDays is the event retention period the new // DefaultRetentionDays is the event retention period applied to a
// webhook form applies when its retention field is left blank. // webhook created without an explicit retention value. It is the
// single source of truth for that policy and must stay in sync
// with the `gorm:"default:30"` column default on
// Webhook.RetentionDays below; a struct tag cannot reference a
// constant, so a test asserts the two agree.
DefaultRetentionDays = 30 DefaultRetentionDays = 30
// RetentionForeverDays is the sentinel RetentionDays value meaning
// "retain events forever". Users express that intent as 0, which
// Webhook.BeforeSave rewrites to this value: the column default
// substitutes DefaultRetentionDays for a zero value at insert
// time, so a zero can never survive a round trip to the database.
// Nothing outside this file may hardcode the number.
RetentionForeverDays = 365 * 1000
// MaxFiniteRetentionDays is the largest finite retention period the // MaxFiniteRetentionDays is the largest finite retention period the
// reaper's cutoff arithmetic can represent, and therefore the // reaper's cutoff arithmetic can represent, and therefore the
// largest one a caller may request. It is derived from that // largest one a caller may request. It is derived from that
@@ -24,7 +38,9 @@ const (
// turns the cutoff into a timestamp in the far future that matches // turns the cutoff into a timestamp in the far future that matches
// every row in the webhook's database. That is why this bound is // every row in the webhook's database. That is why this bound is
// enforced on input and why retentionCutoff saturates underneath // enforced on input and why retentionCutoff saturates underneath
// it. // it. Note that RetentionForeverDays deliberately sits above this
// ceiling: such webhooks are skipped before any cutoff is
// computed, and never reach the arithmetic at all.
MaxFiniteRetentionDays = int( MaxFiniteRetentionDays = int(
math.MaxInt64 / int64(time.Hour) / hoursPerDay, math.MaxInt64 / int64(time.Hour) / hoursPerDay,
) )
@@ -32,10 +48,12 @@ const (
// Webhook represents a webhook processing unit that groups entrypoints and targets // Webhook represents a webhook processing unit that groups entrypoints and targets
// //
// Every method below takes a pointer receiver, so handlers put a // Every method below takes a pointer receiver. BeforeSave has to,
// *Webhook into template data: html/template cannot call a pointer // because it mutates the record and GORM only invokes hooks declared
// method on a value held in a map, because a map element is not // that way; the display helpers follow suit so the receiver kinds do
// addressable. // not mix. Handlers therefore put a *Webhook into template data:
// html/template cannot call a pointer method on a value held in a map,
// because a map element is not addressable.
type Webhook struct { type Webhook struct {
BaseModel BaseModel
@@ -43,9 +61,10 @@ type Webhook struct {
Name string `gorm:"not null" json:"name"` Name string `gorm:"not null" json:"name"`
Description string `json:"description"` Description string `json:"description"`
// RetentionDays is the number of days to retain events, and 0 // RetentionDays is the number of days to retain events. A value of
// means retain them forever. // RetentionForeverDays means retain forever. The column default
RetentionDays int `json:"retentionDays"` // must equal DefaultRetentionDays.
RetentionDays int `gorm:"default:30" json:"retentionDays"`
// Relations. No model marshals the record it belongs to: // Relations. No model marshals the record it belongs to:
// User.Webhooks leads back here, and the JSON could loop. // User.Webhooks leads back here, and the JSON could loop.
@@ -54,17 +73,36 @@ type Webhook struct {
Targets []Target `json:"targets,omitempty"` Targets []Target `json:"targets,omitempty"`
} }
// retainsForever reports whether a RetentionDays value means "keep // BeforeSave normalises RetentionDays on every insert and update. A
// events indefinitely", which 0 does. It is the single definition of // non-positive value is the user's way of asking for "retain forever",
// that question, shared by Webhook.RetainsForever and by the reaper's // which is stored as the RetentionForeverDays sentinel.
//
// This has to happen in a hook rather than at the call sites. GORM
// substitutes the column default (DefaultRetentionDays) for a zero
// value while building the insert statement, which runs after
// BeforeSave; rewriting any later than this loses that race and the
// row lands at 30 days. Living on the model also means a future call
// site — a REST API, a fixture, a migration — cannot bypass it.
func (w *Webhook) BeforeSave(_ *gorm.DB) error {
if w.RetentionDays <= 0 {
w.RetentionDays = RetentionForeverDays
}
return nil
}
// retainsForever reports whether a stored RetentionDays value means
// "keep events indefinitely". It is the single definition of that
// question, shared by Webhook.RetainsForever and by the reaper's
// cutoff computation so the two cannot disagree about which webhooks // cutoff computation so the two cannot disagree about which webhooks
// are exempt from reaping. // are exempt from reaping.
// //
// A negative value, which the forms refuse, is read the same way: as a // It accepts the RetentionForeverDays sentinel written by BeforeSave
// day count it would put the cutoff in the future, where it matches // and, defensively, the non-positive values that rows written before
// every event the webhook has. // the sentinel existed may still carry.
func retainsForever(retentionDays int) bool { func retainsForever(retentionDays int) bool {
return retentionDays <= 0 return retentionDays <= 0 ||
retentionDays >= RetentionForeverDays
} }
// RetainsForever reports whether this webhook's events are kept // RetainsForever reports whether this webhook's events are kept
@@ -81,8 +119,7 @@ func (w *Webhook) RetentionCutoff(now time.Time) (time.Time, bool) {
} }
// RetentionLabel returns the webhook's retention policy as display // RetentionLabel returns the webhook's retention policy as display
// text: "forever" rather than "0 days" for a webhook that keeps its // text, so that no template has to know about the sentinel value.
// events.
func (w *Webhook) RetentionLabel() string { func (w *Webhook) RetentionLabel() string {
if w.RetainsForever() { if w.RetainsForever() {
return "forever" return "forever"
+72 -10
View File
@@ -2,6 +2,8 @@ package database_test
import ( import (
"context" "context"
"reflect"
"strconv"
"testing" "testing"
"time" "time"
@@ -45,7 +47,8 @@ func storedRetention(t *testing.T, db *gorm.DB, id string) int {
} }
// newWebhookWithRetention creates a webhook through the ordinary Create // newWebhookWithRetention creates a webhook through the ordinary Create
// path, as the new webhook form does. // path, so the BeforeSave hook and the GORM column default both apply
// exactly as they do in production.
func newWebhookWithRetention( func newWebhookWithRetention(
t *testing.T, t *testing.T,
db *gorm.DB, db *gorm.DB,
@@ -64,7 +67,7 @@ func newWebhookWithRetention(
return wh.ID return wh.ID
} }
func TestWebhookRetentionDays_ZeroIsStoredAsZero(t *testing.T) { func TestWebhookBeforeSave_ZeroBecomesForeverSentinel(t *testing.T) {
t.Parallel() t.Parallel()
db := startedTestDB(t) db := startedTestDB(t)
@@ -72,10 +75,31 @@ func TestWebhookRetentionDays_ZeroIsStoredAsZero(t *testing.T) {
wh := &database.Webhook{RetentionDays: 0} wh := &database.Webhook{RetentionDays: 0}
id := newWebhookWithRetention(t, db, wh) id := newWebhookWithRetention(t, db, wh)
assert.Equal(t, 0, storedRetention(t, db, id)) assert.Equal(
t,
database.RetentionForeverDays,
storedRetention(t, db, id),
"a zero retention must be stored as the sentinel, "+
"not replaced by the column default",
)
} }
func TestWebhookRetentionDays_DayCountIsStored(t *testing.T) { func TestWebhookBeforeSave_NegativeBecomesForeverSentinel(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
wh := &database.Webhook{RetentionDays: -5}
id := newWebhookWithRetention(t, db, wh)
assert.Equal(
t,
database.RetentionForeverDays,
storedRetention(t, db, id),
)
}
func TestWebhookBeforeSave_PositiveIsPreserved(t *testing.T) {
t.Parallel() t.Parallel()
db := startedTestDB(t) db := startedTestDB(t)
@@ -86,9 +110,10 @@ func TestWebhookRetentionDays_DayCountIsStored(t *testing.T) {
assert.Equal(t, 7, storedRetention(t, db, id)) assert.Equal(t, 7, storedRetention(t, db, id))
} }
// TestWebhookRetentionDays_UpdateToZeroIsStoredAsZero changes a day // TestWebhookBeforeSave_UpdateToZeroBecomesSentinel proves the hook
// count to 0 through the same Save call the edit handler makes. // fires on update as well as insert, via the same Save call the edit
func TestWebhookRetentionDays_UpdateToZeroIsStoredAsZero(t *testing.T) { // handler makes.
func TestWebhookBeforeSave_UpdateToZeroBecomesSentinel(t *testing.T) {
t.Parallel() t.Parallel()
db := startedTestDB(t) db := startedTestDB(t)
@@ -100,7 +125,28 @@ func TestWebhookRetentionDays_UpdateToZeroIsStoredAsZero(t *testing.T) {
wh.RetentionDays = 0 wh.RetentionDays = 0
require.NoError(t, db.Omit(clause.Associations).Save(wh).Error) require.NoError(t, db.Omit(clause.Associations).Save(wh).Error)
assert.Equal(t, 0, storedRetention(t, db, id)) assert.Equal(
t,
database.RetentionForeverDays,
storedRetention(t, db, id),
)
}
// TestWebhookRetentionColumnDefaultMatchesConstant guards the one place
// the default lives twice: a struct tag cannot reference a constant, so
// this asserts the tag and DefaultRetentionDays agree.
func TestWebhookRetentionColumnDefaultMatchesConstant(t *testing.T) {
t.Parallel()
field, ok := reflect.TypeFor[database.Webhook]().
FieldByName("RetentionDays")
require.True(t, ok, "Webhook.RetentionDays must exist")
assert.Equal(
t,
"default:"+strconv.Itoa(database.DefaultRetentionDays),
field.Tag.Get("gorm"),
)
} }
// TestMaxFiniteRetentionDaysIsTheOverflowCeiling asserts that the // TestMaxFiniteRetentionDaysIsTheOverflowCeiling asserts that the
@@ -130,6 +176,14 @@ func TestMaxFiniteRetentionDaysIsTheOverflowCeiling(t *testing.T) {
time.Duration(overCeiling*hoursPerDay)*time.Hour, time.Duration(overCeiling*hoursPerDay)*time.Hour,
"one day past the ceiling must overflow", "one day past the ceiling must overflow",
) )
assert.Less(
t,
database.MaxFiniteRetentionDays,
database.RetentionForeverDays,
"the sentinel sits above the ceiling and is only safe "+
"because retain-forever webhooks skip the arithmetic",
)
} }
func TestWebhookRetainsForeverAndLabel(t *testing.T) { func TestWebhookRetainsForeverAndLabel(t *testing.T) {
@@ -141,8 +195,16 @@ func TestWebhookRetainsForeverAndLabel(t *testing.T) {
forever bool forever bool
label string label string
}{ }{
{"zero", 0, true, testForeverLabel}, {
{"negative", -1, true, testForeverLabel}, "sentinel",
database.RetentionForeverDays, true, testForeverLabel,
},
{
"above sentinel",
database.RetentionForeverDays + 1, true, testForeverLabel,
},
{"legacy zero", 0, true, testForeverLabel},
{"legacy negative", -1, true, testForeverLabel},
{"default", database.DefaultRetentionDays, false, "30 days"}, {"default", database.DefaultRetentionDays, false, "30 days"},
{"one day", 1, false, "1 day"}, {"one day", 1, false, "1 day"},
} }
+17
View File
@@ -0,0 +1,17 @@
package database
// Migrate runs database migrations for the main application database.
// Only configuration-tier models are stored in the main database.
// Event-tier models (Event, Delivery, DeliveryResult, EventTotals,
// TargetTotals, EntrypointTotals) live in
// per-webhook dedicated databases managed by WebhookDBManager.
func (d *Database) Migrate() error {
return d.db.AutoMigrate(
&Setting{},
&User{},
&APIKey{},
&Webhook{},
&Entrypoint{},
&Target{},
)
}
+3 -4
View File
@@ -67,10 +67,9 @@ func DefaultPasswordConfig() *PasswordConfig {
// testArgon2Memory is the Argon2id memory cost, in KiB, that a test // testArgon2Memory is the Argon2id memory cost, in KiB, that a test
// binary hashes with: 1 MB instead of the shipped 64 MB. Every test // binary hashes with: 1 MB instead of the shipped 64 MB. Every test
// that starts a database hashes the bootstrap admin password, dozens // that starts a database hashes the bootstrap admin password, dozens
// of them run in parallel, and each 64 MB hash holds at least 64 MB // of them run in parallel, and under the race detector each 64 MB hash
// while it runs (about 150 MB when the suite ran under the race // holds about 150 MB. VerifyPassword reads the cost from the hash it
// detector). VerifyPassword reads the cost from the hash it checks, so // checks, so verification follows.
// verification follows.
const testArgon2Memory = 1024 const testArgon2Memory = 1024
// hashAtShippedCostInTest makes a test binary hash at the shipped // hashAtShippedCostInTest makes a test binary hash at the shipped
+4 -6
View File
@@ -184,9 +184,8 @@ func (r *RetentionReaper) sweep(ctx context.Context) {
wh := webhooks[i] wh := webhooks[i]
// A missing database has nothing to reap. The delivery // A missing database has nothing to reap. Restart recovery
// engine's recovery routine reports a lost one at start (see // reports a lost one (see WebhookDBManager.GetDB).
// WebhookDBManager.GetDB).
if !r.dbManager.DBExists(wh.ID) { if !r.dbManager.DBExists(wh.ID) {
continue continue
} }
@@ -244,8 +243,7 @@ func (r *RetentionReaper) reapWebhook(
// retentionCutoff returns the timestamp before which a webhook's // retentionCutoff returns the timestamp before which a webhook's
// events have expired, and whether any cutoff applies at all. It // events have expired, and whether any cutoff applies at all. It
// reports false for a retention of 0, which means retain forever, so // reports false for a retain-forever policy, so no DELETE is issued.
// no DELETE is issued.
// //
// The day count is clamped to MaxFiniteRetentionDays first. This is // The day count is clamped to MaxFiniteRetentionDays first. This is
// defense in depth rather than decoration: a time.Duration is an int64 // defense in depth rather than decoration: a time.Duration is an int64
@@ -255,7 +253,7 @@ func (r *RetentionReaper) reapWebhook(
// row in the database: the sweep then deletes every event, delivery, // row in the database: the sweep then deletes every event, delivery,
// and delivery result, including ones created seconds ago. Rejecting // and delivery result, including ones created seconds ago. Rejecting
// out-of-range input at the form is the primary guard; saturating here // out-of-range input at the form is the primary guard; saturating here
// means a value that reaches the row by any other path cannot turn a // means an old row, a migration, or a future call site cannot turn a
// too-large retention into total data loss. // too-large retention into total data loss.
func retentionCutoff( func retentionCutoff(
now time.Time, now time.Time,
+65 -19
View File
@@ -85,6 +85,41 @@ func createWebhook(
db.Omit(clause.Associations).Create(wh).Error, db.Omit(clause.Associations).Create(wh).Error,
) )
// Webhook.BeforeSave rewrites a non-positive RetentionDays to the
// retain-forever sentinel, and the column's GORM default would
// otherwise substitute 30. Force the requested value with a
// column-level update so tests can plant legacy rows that predate
// the sentinel and still carry a literal 0 or negative value.
require.NoError(
t,
db.Model(wh).
Update("retention_days", retentionDays).Error,
)
return wh.ID
}
// createWebhookNormally inserts a webhook through the ordinary Create
// path, with no column-level forcing, so Webhook.BeforeSave applies
// exactly as it does in production. Passing 0 therefore yields a row
// holding the RetentionForeverDays sentinel.
func createWebhookNormally(
t *testing.T,
db *gorm.DB,
retentionDays int,
) string {
t.Helper()
wh := &database.Webhook{
UserID: uuid.New().String(),
Name: testWebhookName,
RetentionDays: retentionDays,
}
require.NoError(
t,
db.Omit(clause.Associations).Create(wh).Error,
)
return wh.ID return wh.ID
} }
@@ -246,19 +281,19 @@ func TestRetentionReaper_ReapsExpiredKeepsRecent(t *testing.T) {
assertChainPresent(t, db, recent) assertChainPresent(t, db, recent)
} }
// TestRetentionReaper_SkipsZeroReapsFiniteInSameSweep covers the // TestRetentionReaper_SkipsSentinelReapsFiniteInSameSweep covers the
// end-to-end retain-forever path: a webhook created with a retention // end-to-end retain-forever path: a webhook created the normal way with
// of 0 reads back 0, and the reaper leaves its ancient events alone // a requested retention of 0 lands on the RetentionForeverDays
// while still reaping a finite-retention webhook in the very same // sentinel, and the reaper leaves its ancient events alone while still
// sweep. // reaping a finite-retention webhook in the very same sweep.
func TestRetentionReaper_SkipsZeroReapsFiniteInSameSweep( func TestRetentionReaper_SkipsSentinelReapsFiniteInSameSweep(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
env := setupRetentionTest(t) env := setupRetentionTest(t)
foreverID := createWebhook(t, env.mainDB.DB(), 0) foreverID := createWebhookNormally(t, env.mainDB.DB(), 0)
var stored database.Webhook var stored database.Webhook
@@ -267,12 +302,14 @@ func TestRetentionReaper_SkipsZeroReapsFiniteInSameSweep(
env.mainDB.DB().Where("id = ?", foreverID). env.mainDB.DB().Where("id = ?", foreverID).
First(&stored).Error, First(&stored).Error,
) )
require.Zero( require.Equal(
t, stored.RetentionDays, t,
"a retention of 0 must be stored as 0", database.RetentionForeverDays,
stored.RetentionDays,
"a requested retention of 0 must persist as the sentinel",
) )
finiteID := createWebhook(t, env.mainDB.DB(), 30) finiteID := createWebhookNormally(t, env.mainDB.DB(), 30)
foreverDB, err := env.mgr.GetDB(foreverID) foreverDB, err := env.mgr.GetDB(foreverID)
require.NoError(t, err) require.NoError(t, err)
@@ -298,10 +335,14 @@ func TestRetentionReaper_SkipsZeroReapsFiniteInSameSweep(
// negative, so subtracting it moves the cutoff into the far future, // negative, so subtracting it moves the cutoff into the far future,
// where "created_at < cutoff" matches every row: an event created a // where "created_at < cutoff" matches every row: an event created a
// moment ago, and its delivery and delivery result, were all deleted // moment ago, and its delivery and delivery result, were all deleted
// on the first sweep. 200000 is inside that band. // on the first sweep. 200000 is inside that band and below the
// retain-forever sentinel, so it is treated as a finite policy and
// really does reach the arithmetic.
// //
// The form refuses such a value; the point of the test is that a row // The row is planted at the column level because such a value can no
// written by any other path still cannot trigger the wipe. // longer be submitted through the form; the point of the test is that
// a row from an older version, or a future call site, still cannot
// trigger the wipe.
func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents( func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
t *testing.T, t *testing.T,
) { ) {
@@ -317,6 +358,12 @@ func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
database.MaxFiniteRetentionDays, database.MaxFiniteRetentionDays,
"the test value must exceed what the cutoff can represent", "the test value must exceed what the cutoff can represent",
) )
require.Less(
t,
overflowingRetentionDays,
database.RetentionForeverDays,
"the test value must not be treated as retain-forever",
)
webhookID := createWebhook( webhookID := createWebhook(
t, env.mainDB.DB(), overflowingRetentionDays, t, env.mainDB.DB(), overflowingRetentionDays,
@@ -332,15 +379,14 @@ func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
assertChainPresent(t, db, fresh) assertChainPresent(t, db, fresh)
} }
// TestRetentionReaper_RetainsForeverWhenNegative checks that a negative func TestRetentionReaper_RetainsForeverWhenNonPositive(t *testing.T) {
// retention, which the forms refuse, keeps every event rather than
// putting the cutoff in the future, where it would match them all.
func TestRetentionReaper_RetainsForeverWhenNegative(t *testing.T) {
t.Parallel() t.Parallel()
env := setupRetentionTest(t) env := setupRetentionTest(t)
webhookID := createWebhook(t, env.mainDB.DB(), -1) // A legacy row written before the sentinel existed still carries a
// literal 0; the <= 0 guard must keep honouring it.
webhookID := createWebhook(t, env.mainDB.DB(), 0)
db, err := env.mgr.GetDB(webhookID) db, err := env.mgr.GetDB(webhookID)
require.NoError(t, err) require.NoError(t, err)
-173
View File
@@ -1,173 +0,0 @@
package database_test
import (
"log/slog"
"net/http"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/database/databasetest"
)
// withFixedNow returns db with GORM's clock stopped at a time in UTC,
// so that the created and updated times it writes come back from
// SQLite equal to the row written, which holds them in UTC.
func withFixedNow(db *gorm.DB) (*gorm.DB, time.Time) {
at := time.Date(2026, 10, 13, 12, 30, 45, 123456789, time.UTC)
return db.Session(&gorm.Session{
NowFunc: func() time.Time { return at },
}), at
}
// newBaseModel returns a BaseModel with a new ID.
func newBaseModel() database.BaseModel {
return database.BaseModel{ID: uuid.New().String()}
}
// assertRoundTrip writes row, which must be the only row of its
// table, and reads back the same row.
func assertRoundTrip[T any](t *testing.T, db *gorm.DB, row *T) {
t.Helper()
require.NoError(t, db.Create(row).Error)
var got T
require.NoError(t, db.First(&got).Error)
assert.Equal(t, *row, got)
}
// TestMainSchemaHoldsEveryModel writes a row of each main database
// model, every field set, into a database made from its SQL files.
func TestMainSchemaHoldsEveryModel(t *testing.T) {
t.Parallel()
mainDB, err := database.Open(
t.TempDir(), slog.New(slog.DiscardHandler),
)
require.NoError(t, err)
t.Cleanup(func() { _ = mainDB.Close() })
db, at := withFixedNow(mainDB.DB())
assertRoundTrip(t, db, &database.Setting{
Key: "session_key", Value: "value",
})
assertRoundTrip(t, db, &database.User{
BaseModel: newBaseModel(),
Username: "user",
Password: "password hash",
})
assertRoundTrip(t, db, &database.APIKey{
BaseModel: newBaseModel(),
UserID: uuid.New().String(),
Key: "key",
Description: "API key description",
LastUsedAt: &at,
})
assertRoundTrip(t, db, &database.Webhook{
BaseModel: newBaseModel(),
UserID: uuid.New().String(),
Name: "webhook",
Description: "webhook description",
RetentionDays: 7,
})
assertRoundTrip(t, db, &database.Entrypoint{
BaseModel: newBaseModel(),
WebhookID: uuid.New().String(),
Path: uuid.New().String(),
Description: "entrypoint description",
Active: true,
})
assertRoundTrip(t, db, &database.Target{
BaseModel: newBaseModel(),
WebhookID: uuid.New().String(),
Name: "target",
Type: database.TargetTypeHTTP,
Active: true,
URL: "https://example.com/",
Headers: map[string]string{"X-Api-Key": "key"},
TimeoutSeconds: 30,
ForwardQuery: true,
ArchiveExpiry: 720 * time.Hour,
ArchiveRotation: "daily",
MaxAttempts: 3,
})
}
// TestEventSchemaHoldsEveryModel does the same for a webhook's event
// database, which the manager makes from its SQL files.
func TestEventSchemaHoldsEveryModel(t *testing.T) {
t.Parallel()
mgr := databasetest.NewWebhookDBManager(t, t.TempDir())
t.Cleanup(func() { _ = mgr.CloseAll() })
events, err := mgr.GetDB(uuid.New().String())
require.NoError(t, err)
db, at := withFixedNow(events)
source := uuid.New().String()
assertRoundTrip(t, db, &database.Event{
BaseModel: newBaseModel(),
WebhookID: uuid.New().String(),
EntrypointID: uuid.New().String(),
Method: http.MethodPost,
RawQuery: "a=1",
Headers: `{"X":"Y"}`,
Body: "body",
ContentType: "text/plain",
BodyBytes: 4,
ResubmittedFromID: &source,
})
assertRoundTrip(t, db, &database.Delivery{
BaseModel: newBaseModel(),
EventID: uuid.New().String(),
TargetID: uuid.New().String(),
Status: database.DeliveryStatusDelivered,
FinishedAt: &at,
Replay: true,
})
assertRoundTrip(t, db, &database.DeliveryResult{
BaseModel: newBaseModel(),
DeliveryID: uuid.New().String(),
AttemptNum: 2,
Success: true,
StatusCode: 200,
ResponseBody: "ok",
Error: "error",
Duration: 15,
})
assertRoundTrip(t, db, &database.TargetTotals{
TargetID: uuid.New().String(),
Deliveries: 5,
Delivered: 3,
Failed: 1,
DeliveriesRemoved: 2,
FailedRemoved: 1,
})
assertRoundTrip(t, db, &database.EntrypointTotals{
EntrypointID: uuid.New().String(), LastEventAt: at,
})
// A new event database has its one row of event totals already.
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
Events: 3, EventsRemoved: 1, LastEventAt: &at,
}))
var totals []database.EventTotals
require.NoError(t, db.Find(&totals).Error)
assert.Equal(t, []database.EventTotals{{
ID: 1, Events: 3, EventsRemoved: 1, LastEventAt: &at,
}}, totals)
}
+1 -1
View File
@@ -137,7 +137,7 @@ func TestPerWebhookEventDatabaseFilesAreOwnerOnly(t *testing.T) {
} }
// TestArchiveDatabaseFilesAreOwnerOnly covers the archive-*.db tier. // TestArchiveDatabaseFilesAreOwnerOnly covers the archive-*.db tier.
// internal/archive builds that path and opens it through OpenSQLite, // internal/delivery builds that path and opens it through OpenSQLite,
// the same single open path exercised here, so the mode is settled for // the same single open path exercised here, so the mode is settled for
// all three tiers in one place. // all three tiers in one place.
func TestArchiveDatabaseFilesAreOwnerOnly(t *testing.T) { func TestArchiveDatabaseFilesAreOwnerOnly(t *testing.T) {
+4 -15
View File
@@ -118,8 +118,9 @@ const SQLiteFilePerm fs.FileMode = 0o600
// SQLite still reports the absence rather than this function // SQLite still reports the absence rather than this function
// materializing an empty database the caller asked not to create. // materializing an empty database the caller asked not to create.
// //
// The mode is set on every open, whoever created the file, so a // Chmod of a file that already exists is what tightens a data
// database or sidecar found at a wider mode is tightened too. // directory an earlier build left at 0644 — including a developer's
// own scratch directory — without any migration machinery.
func reserveSQLiteFile(path string, create bool) error { func reserveSQLiteFile(path string, create bool) error {
if create { if create {
// gosec G304: the path is the database file the caller asked // gosec G304: the path is the database file the caller asked
@@ -241,19 +242,7 @@ func SQLiteDSN(path, mode string) string {
) )
q.Add("_pragma", "journal_mode(WAL)") q.Add("_pragma", "journal_mode(WAL)")
// SQLite reads a file: URI's path only up to the first ? or #, and return "file:" + path + "?" + q.Encode()
// decodes %XX escapes in it, so url.URL escapes the path. OmitHost
// keeps a relative path as "file:data/x.db"; written
// "file://data/x.db", SQLite would reject "data" as the URI's
// authority.
dsn := url.URL{
Scheme: "file",
Path: path,
OmitHost: true,
RawQuery: q.Encode(),
}
return dsn.String()
} }
// OpenSQLite opens the SQLite file at path with the service's // OpenSQLite opens the SQLite file at path with the service's
-75
View File
@@ -2,8 +2,6 @@ package database_test
import ( import (
"context" "context"
"database/sql"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
@@ -114,79 +112,6 @@ func TestMainDBAppliesPragmasOnALiveHandle(t *testing.T) {
assert.Equal(t, "10000", busy) assert.Equal(t, "10000", busy)
} }
// TestOpenSQLiteOpensThePathAskedFor uses a data directory whose name
// holds ?, # and %. SQLite reads a file: URI's path only up to a ? or
// #, and decodes %41 to A, so an unescaped path sent every database in
// that directory to one file outside it.
func TestOpenSQLiteOpensThePathAskedFor(t *testing.T) {
t.Parallel()
dir := filepath.Join(t.TempDir(), "hooks#1?x%41")
require.NoError(t, os.Mkdir(dir, 0o700))
for _, name := range []string{
database.MainDBFileName,
"events-" + uuid.New().String() + ".db",
} {
path := filepath.Join(dir, name)
sqlDB, err := database.OpenSQLite(
path, database.SQLiteModeCreate,
)
require.NoError(t, err)
t.Cleanup(func() { assert.NoError(t, sqlDB.Close()) })
assertOpenedFile(t, sqlDB, path)
}
}
// TestOpenSQLiteOpensARelativePath opens a database at a path relative
// to the working directory, which is what a relative DATA_DIR such as
// the README's DATA_DIR=../webhooker-data gives OpenSQLite.
//
//nolint:paralleltest // t.Chdir moves the whole process.
func TestOpenSQLiteOpensARelativePath(t *testing.T) {
t.Chdir(t.TempDir())
require.NoError(t, os.Mkdir("data", 0o700))
path := filepath.Join("data", database.MainDBFileName)
sqlDB, err := database.OpenSQLite(path, database.SQLiteModeCreate)
require.NoError(t, err)
defer func() { require.NoError(t, sqlDB.Close()) }()
assertOpenedFile(t, sqlDB, path)
}
// assertOpenedFile checks that sqlDB is the SQLite file at path.
// database_list names the file SQLite actually opened, as an absolute
// path with symlinks resolved, so the files are compared, not the names.
func assertOpenedFile(t *testing.T, sqlDB *sql.DB, path string) {
t.Helper()
var (
seq int
schema, file string
)
require.NoError(t, sqlDB.
QueryRowContext(t.Context(), "pragma database_list").
Scan(&seq, &schema, &file))
asked, err := os.Stat(path)
require.NoError(t, err)
opened, err := os.Stat(file)
require.NoError(t, err)
assert.True(
t, os.SameFile(asked, opened),
"asked for %s, SQLite opened %s", path, file,
)
}
// TestConcurrentReaderDoesNotBlockWrites is the unit-scale form of the // TestConcurrentReaderDoesNotBlockWrites is the unit-scale form of the
// reproduction in // reproduction in
// https://git.eeqj.de/sneak/webhooker/issues/256: an operator's // https://git.eeqj.de/sneak/webhooker/issues/256: an operator's
+16 -7
View File
@@ -14,7 +14,6 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/datadir" "sneak.berlin/go/webhooker/internal/datadir"
"sneak.berlin/go/webhooker/internal/db/migrations"
"sneak.berlin/go/webhooker/internal/gormlog" "sneak.berlin/go/webhooker/internal/gormlog"
) )
@@ -371,7 +370,18 @@ func (m *WebhookDBManager) openDB(
) )
} }
err = migrations.Apply(db, migrations.Events) err = purgeTargetRows(db, m.log, webhookID)
if err != nil {
_ = sqlDB.Close()
return nil, err
}
// Run migrations for event-tier models only
err = db.AutoMigrate(
&Event{}, &Delivery{}, &DeliveryResult{},
&EventTotals{}, &TargetTotals{}, &EntrypointTotals{},
)
if err != nil { if err != nil {
_ = sqlDB.Close() _ = sqlDB.Close()
@@ -381,15 +391,14 @@ func (m *WebhookDBManager) openDB(
) )
} }
err = parseModels( // A new database gets its row of event totals, all zero. Target
db, &Event{}, &Delivery{}, &DeliveryResult{}, // totals rows are created by the first delivery to each target.
&EventTotals{}, &TargetTotals{}, &EntrypointTotals{}, err = db.FirstOrCreate(&EventTotals{}).Error
)
if err != nil { if err != nil {
_ = sqlDB.Close() _ = sqlDB.Close()
return nil, fmt.Errorf( return nil, fmt.Errorf(
"connecting to webhook database %s: %w", "creating event totals for webhook database %s: %w",
webhookID, err, webhookID, err,
) )
} }
@@ -1,9 +0,0 @@
-- Migration 000: the table recording which migrations this file has
-- had. Applied before the others; it records itself.
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);
@@ -1,18 +0,0 @@
-- Migration 001: the schema of a database target's archive file. The
-- archivedEvent model in internal/archive must agree with it.
CREATE TABLE archived_events (
id integer PRIMARY KEY AUTOINCREMENT,
event_id text,
webhook_id text,
entrypoint_id text,
method text,
raw_query text,
headers text,
body text,
content_type text,
archived_at datetime
);
CREATE INDEX idx_archived_events_event_id ON archived_events (event_id);
CREATE INDEX idx_archived_events_archived_at ON archived_events (archived_at);
@@ -1,9 +0,0 @@
-- Migration 000: the table recording which migrations this file has
-- had. Applied before the others; it records itself.
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);
@@ -1,98 +0,0 @@
-- Migration 001: the schema of a webhook's event database. The models
-- in internal/database must agree with it. The webhook, entrypoint and
-- target ids here name rows in the main database, which has those
-- tables; this file does not.
CREATE TABLE events (
id uuid,
created_at datetime,
updated_at datetime,
deleted_at datetime,
webhook_id uuid NOT NULL,
entrypoint_id uuid NOT NULL,
method text NOT NULL,
raw_query text,
headers text,
body text,
content_type text,
body_bytes integer NOT NULL,
resubmitted_from_id uuid,
PRIMARY KEY (id)
);
CREATE INDEX idx_events_created_at ON events (created_at);
CREATE INDEX idx_events_deleted_at ON events (deleted_at);
CREATE INDEX idx_events_deleted_at_created_at
ON events (deleted_at, created_at);
CREATE INDEX idx_events_resubmitted_from_id
ON events (resubmitted_from_id, deleted_at);
CREATE INDEX idx_events_entrypoint_id
ON events (entrypoint_id, deleted_at, resubmitted_from_id, created_at);
CREATE TABLE deliveries (
id uuid,
created_at datetime,
updated_at datetime,
deleted_at datetime,
event_id uuid NOT NULL,
target_id uuid NOT NULL,
status text NOT NULL DEFAULT 'pending',
finished_at datetime,
replay numeric NOT NULL DEFAULT false,
PRIMARY KEY (id),
CONSTRAINT fk_events_deliveries
FOREIGN KEY (event_id) REFERENCES events (id)
);
CREATE INDEX idx_deliveries_deleted_at ON deliveries (deleted_at);
CREATE INDEX idx_deliveries_event_id ON deliveries (event_id, deleted_at);
CREATE INDEX idx_deliveries_status
ON deliveries (status, deleted_at, finished_at, target_id);
CREATE TABLE delivery_results (
id uuid,
created_at datetime,
updated_at datetime,
deleted_at datetime,
delivery_id uuid NOT NULL,
attempt_num integer NOT NULL,
success numeric,
status_code integer,
response_body text,
error text,
duration integer,
PRIMARY KEY (id),
CONSTRAINT fk_deliveries_delivery_results
FOREIGN KEY (delivery_id) REFERENCES deliveries (id)
);
CREATE INDEX idx_delivery_results_deleted_at
ON delivery_results (deleted_at);
CREATE INDEX idx_delivery_results_delivery_id
ON delivery_results (delivery_id, deleted_at);
CREATE TABLE event_totals (
id integer PRIMARY KEY AUTOINCREMENT,
events integer NOT NULL,
events_removed integer NOT NULL,
last_event_at datetime
);
-- The one row of event totals, all zero.
INSERT INTO event_totals (events, events_removed) VALUES (0, 0);
CREATE TABLE target_totals (
target_id uuid,
deliveries integer NOT NULL,
delivered integer NOT NULL,
failed integer NOT NULL,
deliveries_removed integer NOT NULL,
failed_removed integer NOT NULL,
PRIMARY KEY (target_id)
);
CREATE TABLE entrypoint_totals (
entrypoint_id uuid,
last_event_at datetime NOT NULL,
PRIMARY KEY (entrypoint_id)
);
@@ -1,9 +0,0 @@
-- Migration 000: the table recording which migrations this file has
-- had. Applied before the others; it records itself.
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);
@@ -1,95 +0,0 @@
-- Migration 001: the main database's schema. The models in
-- internal/database must agree with it.
CREATE TABLE settings (
"key" text,
value text NOT NULL,
PRIMARY KEY ("key")
);
CREATE TABLE users (
id uuid,
created_at datetime,
updated_at datetime,
deleted_at datetime,
username text NOT NULL,
password text NOT NULL,
PRIMARY KEY (id),
CONSTRAINT chk_users_username
CHECK (length(CAST(username AS BLOB)) <= 1024)
);
CREATE INDEX idx_users_deleted_at ON users (deleted_at);
CREATE UNIQUE INDEX idx_users_username ON users (username);
CREATE TABLE api_keys (
id uuid,
created_at datetime,
updated_at datetime,
deleted_at datetime,
user_id uuid NOT NULL,
"key" text NOT NULL,
description text,
last_used_at datetime,
PRIMARY KEY (id),
CONSTRAINT fk_users_api_keys FOREIGN KEY (user_id) REFERENCES users (id)
);
CREATE INDEX idx_api_keys_deleted_at ON api_keys (deleted_at);
CREATE UNIQUE INDEX idx_api_keys_key ON api_keys ("key");
CREATE TABLE webhooks (
id uuid,
created_at datetime,
updated_at datetime,
deleted_at datetime,
user_id uuid NOT NULL,
name text NOT NULL,
description text,
retention_days integer,
PRIMARY KEY (id),
CONSTRAINT fk_users_webhooks FOREIGN KEY (user_id) REFERENCES users (id)
);
CREATE INDEX idx_webhooks_deleted_at ON webhooks (deleted_at);
CREATE TABLE entrypoints (
id uuid,
created_at datetime,
updated_at datetime,
deleted_at datetime,
webhook_id uuid NOT NULL,
path text NOT NULL,
description text,
active numeric DEFAULT true,
PRIMARY KEY (id),
CONSTRAINT fk_webhooks_entrypoints
FOREIGN KEY (webhook_id) REFERENCES webhooks (id)
);
CREATE INDEX idx_entrypoints_deleted_at ON entrypoints (deleted_at);
CREATE UNIQUE INDEX idx_entrypoints_path ON entrypoints (path);
CREATE TABLE targets (
id uuid,
created_at datetime,
updated_at datetime,
deleted_at datetime,
webhook_id uuid NOT NULL,
name text NOT NULL,
type text NOT NULL,
active numeric DEFAULT true,
url text,
headers text,
timeout_seconds integer,
forward_query numeric,
archive_expiry integer,
archive_rotation text,
max_attempts integer NOT NULL,
PRIMARY KEY (id),
CONSTRAINT fk_webhooks_targets
FOREIGN KEY (webhook_id) REFERENCES webhooks (id),
CONSTRAINT chk_targets_max_attempts CHECK (max_attempts >= 1)
);
CREATE INDEX idx_targets_deleted_at ON targets (deleted_at);
-135
View File
@@ -1,135 +0,0 @@
// Package migrations holds the schema of each kind of SQLite file
// webhooker writes, as numbered SQL files embedded in the binary, and
// applies it when a file is opened.
//
// Each kind of file has its own directory. In each, 000_migration.sql
// creates only the schema_migrations table, and 001_schema.sql holds
// the whole schema. Before 1.0 a schema change is an edit to
// 001_schema.sql, never a new file.
package migrations
import (
"embed"
"fmt"
"io/fs"
"path"
"strconv"
"strings"
"gorm.io/gorm"
)
// The directories, one for each kind of database file.
const (
// Main is the main database, webhooker.db.
Main = "main"
// Events is a webhook's event database, events-{uuid}.db.
Events = "events"
// Archive is a database target's archive file.
Archive = "archive"
)
//go:embed main/*.sql events/*.sql archive/*.sql
var files embed.FS
// Apply runs each SQL file in dir, which is Main, Events or Archive,
// that the database has not had yet, in the order of the number its
// name starts with, and records that number in schema_migrations.
// 000_migration.sql creates that table and records itself, so it runs
// first, and only when the table is missing.
//
// It all runs in one transaction, so a failure leaves the file as it
// was.
func Apply(db *gorm.DB, dir string) error {
return db.Transaction(func(tx *gorm.DB) error {
var tables int
// Row().Scan, not (*gorm.DB).Scan: see internal/gormlog.
err := tx.Raw(
"SELECT count(*) FROM sqlite_master "+
"WHERE type = 'table' AND name = ?",
"schema_migrations",
).Row().Scan(&tables)
if err != nil {
return fmt.Errorf("looking for schema_migrations: %w", err)
}
if tables == 0 {
err = run(tx, path.Join(dir, "000_migration.sql"))
if err != nil {
return err
}
}
// fs.Glob lists the files in name order, which is number order.
names, err := fs.Glob(files, path.Join(dir, "*.sql"))
if err != nil {
return fmt.Errorf("listing migrations: %w", err)
}
for _, name := range names {
err = applyOnce(tx, name)
if err != nil {
return err
}
}
return nil
})
}
// applyOnce runs the migration file name and records its number,
// unless schema_migrations already has that number.
func applyOnce(tx *gorm.DB, name string) error {
number, _, _ := strings.Cut(path.Base(name), "_")
version, err := strconv.Atoi(number)
if err != nil {
return fmt.Errorf("numbering migration %s: %w", name, err)
}
var applied int
err = tx.Raw(
"SELECT count(*) FROM schema_migrations WHERE version = ?",
version,
).Row().Scan(&applied)
if err != nil {
return fmt.Errorf("checking migration %s: %w", name, err)
}
if applied > 0 {
return nil
}
err = run(tx, name)
if err != nil {
return err
}
err = tx.Exec(
"INSERT INTO schema_migrations (version) VALUES (?)", version,
).Error
if err != nil {
return fmt.Errorf("recording migration %s: %w", name, err)
}
return nil
}
// run executes every statement in the migration file name.
func run(tx *gorm.DB, name string) error {
script, err := files.ReadFile(name)
if err != nil {
return fmt.Errorf("reading migration %s: %w", name, err)
}
err = tx.Exec(string(script)).Error
if err != nil {
return fmt.Errorf("applying migration %s: %w", name, err)
}
return nil
}
-57
View File
@@ -1,57 +0,0 @@
package migrations_test
import (
"database/sql"
"log/slog"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
_ "modernc.org/sqlite"
"sneak.berlin/go/webhooker/internal/db/migrations"
"sneak.berlin/go/webhooker/internal/gormlog"
)
// TestApplyRunsEachFileOnce applies each kind of database file's
// migrations to a new file twice. The first time records every file.
// The second finds them all recorded and runs none, which is the only
// way it can succeed: 001_schema.sql creates tables that then exist.
func TestApplyRunsEachFileOnce(t *testing.T) {
t.Parallel()
for _, dir := range []string{
migrations.Main, migrations.Events, migrations.Archive,
} {
t.Run(dir, func(t *testing.T) {
t.Parallel()
sqlDB, err := sql.Open(
"sqlite", filepath.Join(t.TempDir(), "test.db"),
)
require.NoError(t, err)
t.Cleanup(func() { _ = sqlDB.Close() })
db, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB},
&gorm.Config{
Logger: gormlog.New(slog.New(slog.DiscardHandler)),
},
)
require.NoError(t, err)
require.NoError(t, migrations.Apply(db, dir))
require.NoError(t, migrations.Apply(db, dir))
var versions string
require.NoError(t, sqlDB.QueryRowContext(t.Context(),
"SELECT group_concat(version) FROM schema_migrations",
).Scan(&versions))
assert.Equal(t, "0,1", versions)
})
}
}
@@ -1,4 +1,4 @@
package archive package delivery
import ( import (
"context" "context"
@@ -15,18 +15,18 @@ import (
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
) )
// SweeperParams holds the fx dependencies for the // ArchiveSweeperParams holds the fx dependencies for the
// Sweeper. // ArchiveSweeper.
type SweeperParams struct { type ArchiveSweeperParams struct {
fx.In fx.In
Config *config.Config Config *config.Config
Database *database.Database Database *database.Database
Writers *Writers Engine *Engine
Logger *logger.Logger Logger *logger.Logger
} }
// Sweeper periodically prunes expired rows from the // ArchiveSweeper periodically prunes expired rows from the
// archive databases of database targets that carry a positive // archive databases of database targets that carry a positive
// expiry. // expiry.
// //
@@ -40,9 +40,9 @@ type SweeperParams struct {
// It reuses Config.RetentionSweepInterval rather than // It reuses Config.RetentionSweepInterval rather than
// introducing a second interval: this is a retention sweep with // introducing a second interval: this is a retention sweep with
// the same semantics as the event retention reaper. // the same semantics as the event retention reaper.
type Sweeper struct { type ArchiveSweeper struct {
db *database.Database db *database.Database
writers *Writers eng *Engine
log *slog.Logger log *slog.Logger
interval time.Duration interval time.Duration
@@ -54,16 +54,16 @@ type Sweeper struct {
wg sync.WaitGroup wg sync.WaitGroup
} }
// NewSweeper creates the archive sweeper and registers // NewArchiveSweeper creates the archive sweeper and registers
// its fx lifecycle hooks. The background sweep loop starts on // its fx lifecycle hooks. The background sweep loop starts on
// OnStart and stops cleanly on OnStop via context cancellation. // OnStart and stops cleanly on OnStop via context cancellation.
func NewSweeper( func NewArchiveSweeper(
lc fx.Lifecycle, lc fx.Lifecycle,
params SweeperParams, params ArchiveSweeperParams,
) *Sweeper { ) *ArchiveSweeper {
s := &Sweeper{ s := &ArchiveSweeper{
db: params.Database, db: params.Database,
writers: params.Writers, eng: params.Engine,
log: params.Logger.Get(), log: params.Logger.Get(),
interval: params.Config.RetentionSweepInterval, interval: params.Config.RetentionSweepInterval,
} }
@@ -77,7 +77,7 @@ func NewSweeper(
// lifecycle. The start hook's context is deliberately ignored // lifecycle. The start hook's context is deliberately ignored
// (see start for why the background loop must not inherit it); // (see start for why the background loop must not inherit it);
// the stop hook's context is honoured (see stop). // the stop hook's context is honoured (see stop).
func (s *Sweeper) registerHooks(lc fx.Lifecycle) { func (s *ArchiveSweeper) registerHooks(lc fx.Lifecycle) {
lc.Append(fx.Hook{ lc.Append(fx.Hook{
//nolint:contextcheck // Not passing the hook context is //nolint:contextcheck // Not passing the hook context is
// the point: see start. // the point: see start.
@@ -103,7 +103,7 @@ func (s *Sweeper) registerHooks(lc fx.Lifecycle) {
// goroutine must outlive the startup phase, so its lifetime is // goroutine must outlive the startup phase, so its lifetime is
// bounded by OnStop instead: stop cancels this context and waits // bounded by OnStop instead: stop cancels this context and waits
// on the WaitGroup. // on the WaitGroup.
func (s *Sweeper) start() { func (s *ArchiveSweeper) start() {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
s.cancel = cancel s.cancel = cancel
@@ -121,7 +121,7 @@ func (s *Sweeper) start() {
// exit, bounded by the stop hook's context: a prune wedged on a // exit, bounded by the stop hook's context: a prune wedged on a
// locked archive must not hang the process past fx's stop // locked archive must not hang the process past fx's stop
// timeout. // timeout.
func (s *Sweeper) stop(ctx context.Context) error { func (s *ArchiveSweeper) stop(ctx context.Context) error {
s.log.Info("archive sweeper stopping") s.log.Info("archive sweeper stopping")
if s.cancel != nil { if s.cancel != nil {
@@ -140,7 +140,7 @@ func (s *Sweeper) stop(ctx context.Context) error {
return nil return nil
} }
func (s *Sweeper) run(ctx context.Context) { func (s *ArchiveSweeper) run(ctx context.Context) {
defer s.wg.Done() defer s.wg.Done()
ticker := time.NewTicker(s.interval) ticker := time.NewTicker(s.interval)
@@ -164,7 +164,7 @@ func (s *Sweeper) run(ctx context.Context) {
// A failure for one target is logged and the sweep continues, // A failure for one target is logged and the sweep continues,
// matching how the write path already treats a prune error as // matching how the write path already treats a prune error as
// non-fatal. // non-fatal.
func (s *Sweeper) sweep(ctx context.Context) { func (s *ArchiveSweeper) sweep(ctx context.Context) {
var targets []database.Target var targets []database.Target
err := s.db.DB(). err := s.db.DB().
@@ -199,20 +199,32 @@ func (s *Sweeper) sweep(ctx context.Context) {
} }
} }
// sweepTarget prunes the archive of a single database target. A // sweepTarget prunes the archive of a single database target.
// target with an ArchiveExpiry of 0 keeps its archive forever and is // A missing, empty, or "never" expiry parses as a zero duration
// skipped entirely, so that archive is never pruned. // and is skipped entirely, so those archives keep exactly the
func (s *Sweeper) sweepTarget(target *database.Target) { // behaviour they had before the sweep existed.
expiry := target.ArchiveExpiry func (s *ArchiveSweeper) sweepTarget(target *database.Target) {
expiry, err := parseArchiveExpiry(target.Config)
if err != nil {
s.log.Error(
"archive sweep: invalid database target config",
"webhook_id", target.WebhookID,
"target_id", target.ID,
"error", err,
)
return
}
if expiry <= 0 { if expiry <= 0 {
return return
} }
if s.writers == nil { if s.eng == nil || s.eng.dbTarget == nil {
return return
} }
err := s.writers.sweepArchive(target.ID, expiry) err = s.eng.dbTarget.sweepArchive(target.ID, expiry)
if err == nil { if err == nil {
return return
} }
@@ -1,4 +1,4 @@
package archive_test package delivery_test
import ( import (
"bytes" "bytes"
@@ -16,15 +16,13 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
_ "modernc.org/sqlite" // Pure Go SQLite driver. _ "modernc.org/sqlite" // Pure Go SQLite driver.
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/database/databasetest" "sneak.berlin/go/webhooker/internal/database/databasetest"
"sneak.berlin/go/webhooker/internal/db/migrations" "sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/gormlog" "sneak.berlin/go/webhooker/internal/gormlog"
) )
@@ -38,18 +36,6 @@ const (
// sweepConcurrentWrites is how many deliveries the // sweepConcurrentWrites is how many deliveries the
// concurrent write-plus-sweep test races against the sweep. // concurrent write-plus-sweep test races against the sweep.
sweepConcurrentWrites = 20 sweepConcurrentWrites = 20
// hookStopTimeout bounds how long a lifecycle test waits for
// the sweeper's OnStop hook to return before declaring the
// shutdown hung.
hookStopTimeout = 10 * time.Second
// wedgeStopTimeout is the stop timeout a wedged-shutdown test
// hands OnStop, standing in for fx's StopTimeout. The test
// asserts only that the hook returns at all, and allows it
// hookStopTimeout — forty times this budget — to do so, so no
// assertion here races the wall clock.
wedgeStopTimeout = 250 * time.Millisecond
) )
// archiveTestWebhookName is the name of every webhook // archiveTestWebhookName is the name of every webhook
@@ -58,12 +44,12 @@ const (
const archiveTestWebhookName = "Sweep Test!" const archiveTestWebhookName = "Sweep Test!"
// archiveEnv bundles the pieces an archive test drives: a main // archiveEnv bundles the pieces an archive test drives: a main
// configuration database holding webhooks and targets, the archive // configuration database holding webhooks and targets, a delivery
// writers, the archive sweeper, and the data directory the archive // engine owning the archive writer registry, the archive sweeper,
// files live in. // and the data directory the archive files live in.
type archiveEnv struct { type archiveEnv struct {
sweeper *archive.Sweeper sweeper *delivery.ArchiveSweeper
writers *archive.Writers eng *delivery.Engine
mainDB *database.Database mainDB *database.Database
dataDir string dataDir string
} }
@@ -79,37 +65,39 @@ func setupArchiveTest(t *testing.T) *archiveEnv {
t.Cleanup(func() { _ = mainDB.Close() }) t.Cleanup(func() { _ = mainDB.Close() })
writers := archive.NewWriters(archive.WritersParams{ eng := delivery.NewTestEngineWithDB(
Database: mainDB, mainDB,
DBManager: databasetest.NewWebhookDBManager(t, dataDir), databasetest.NewWebhookDBManager(t, dataDir),
Logger: log, log,
}) &http.Client{Timeout: 5 * time.Second},
1,
)
return &archiveEnv{ return &archiveEnv{
sweeper: archive.NewTestSweeper( sweeper: delivery.NewTestArchiveSweeper(
mainDB, writers, log, mainDB, eng, log,
), ),
writers: writers, eng: eng,
mainDB: mainDB, mainDB: mainDB,
dataDir: dataDir, dataDir: dataDir,
} }
} }
// archivePath returns where the writers keep a database target's // archivePath returns where the engine keeps a database target's
// archive file, for the names seedDatabaseTarget gave it. // archive file, for the names seedDatabaseTarget gave it.
func (env *archiveEnv) archivePath(tgt *database.Target) string { func (env *archiveEnv) archivePath(tgt *database.Target) string {
return filepath.Join( return filepath.Join(
env.dataDir, env.dataDir,
archive.FileName( delivery.ArchiveFileName(
archiveTestWebhookName, tgt.Name, tgt.ID, archiveTestWebhookName, tgt.Name, tgt.ID,
), ),
) )
} }
// seedDatabaseTarget creates a webhook with one database target with // seedDatabaseTarget creates a webhook with one database target
// the given archive expiry and rotation, and returns the target. // carrying the given target config JSON, and returns the target.
func (env *archiveEnv) seedDatabaseTarget( func (env *archiveEnv) seedDatabaseTarget(
t *testing.T, expiry time.Duration, rotation string, t *testing.T, configJSON string,
) *database.Target { ) *database.Target {
t.Helper() t.Helper()
@@ -124,16 +112,13 @@ func (env *archiveEnv) seedDatabaseTarget(
Create(wh).Error, Create(wh).Error,
) )
return env.addDatabaseTarget(t, wh.ID, expiry, rotation) return env.addDatabaseTarget(t, wh.ID, configJSON)
} }
// addDatabaseTarget creates one more database target on an // addDatabaseTarget creates one more database target on an
// existing webhook and returns it. // existing webhook and returns it.
func (env *archiveEnv) addDatabaseTarget( func (env *archiveEnv) addDatabaseTarget(
t *testing.T, t *testing.T, webhookID, configJSON string,
webhookID string,
expiry time.Duration,
rotation string,
) *database.Target { ) *database.Target {
t.Helper() t.Helper()
@@ -142,9 +127,7 @@ func (env *archiveEnv) addDatabaseTarget(
Name: "Archive", Name: "Archive",
Type: database.TargetTypeDatabase, Type: database.TargetTypeDatabase,
Active: true, Active: true,
ArchiveExpiry: expiry, Config: configJSON,
ArchiveRotation: rotation,
MaxAttempts: 1,
} }
require.NoError( require.NoError(
t, t,
@@ -189,10 +172,12 @@ func seedArchiveFile(
) )
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, migrations.Apply(gdb, migrations.Archive)) require.NoError(
t, gdb.AutoMigrate(&delivery.ExportArchivedEvent{}),
)
for i, at := range archivedAt { for i, at := range archivedAt {
row := archive.ExportArchivedEvent{ row := delivery.ExportArchivedEvent{
EventID: fmt.Sprintf("ev-%d", i), EventID: fmt.Sprintf("ev-%d", i),
WebhookID: webhookID, WebhookID: webhookID,
Method: http.MethodPost, Method: http.MethodPost,
@@ -212,7 +197,7 @@ func archivedEventIDs(
) []string { ) []string {
t.Helper() t.Helper()
var rows []archive.ExportArchivedEvent var rows []delivery.ExportArchivedEvent
rdb := openArchiveDBForRead(t, path) rdb := openArchiveDBForRead(t, path)
require.NoError(t, rdb.Order("event_id").Find(&rows).Error) require.NoError(t, rdb.Order("event_id").Find(&rows).Error)
@@ -249,7 +234,7 @@ func countArchivedRows(path string) (int64, error) {
var count int64 var count int64
err = gdb.Model(&archive.ExportArchivedEvent{}). err = gdb.Model(&delivery.ExportArchivedEvent{}).
Count(&count).Error Count(&count).Error
if err != nil { if err != nil {
return 0, err return 0, err
@@ -258,55 +243,6 @@ func countArchivedRows(path string) (int64, error) {
return count, nil return count, nil
} }
// recordingLifecycle is a minimal fx.Lifecycle that records the
// hooks a component registers, so a test can invoke the real
// OnStart/OnStop functions with a context of its choosing.
type recordingLifecycle struct {
hooks []fx.Hook
}
func (l *recordingLifecycle) Append(h fx.Hook) {
l.hooks = append(l.hooks, h)
}
// requireStopHookExpires drives hook.OnStop with a stop context
// that expires while a wedged goroutine is still running, and
// requires the hook to return the deadline error naming
// component instead of blocking on the WaitGroup forever.
func requireStopHookExpires(
t *testing.T, hook fx.Hook, component string,
) {
t.Helper()
stopCtx, cancel := context.WithTimeout(
context.Background(), wedgeStopTimeout,
)
defer cancel()
var stopErr error
stopped := make(chan struct{})
go func() {
defer close(stopped)
stopErr = hook.OnStop(stopCtx)
}()
select {
case <-stopped:
case <-time.After(hookStopTimeout):
t.Fatal(
"OnStop did not return: it discarded the stop " +
"context and is waiting on a wedged goroutine " +
"that will never observe cancellation",
)
}
require.ErrorIs(t, stopErr, context.DeadlineExceeded)
require.ErrorContains(t, stopErr, component)
}
// TestArchiveSweeper_LoopOutlivesStartHookContext is the // TestArchiveSweeper_LoopOutlivesStartHookContext is the
// regression test for a sweeper that never swept. fx calls // regression test for a sweeper that never swept. fx calls
// OnStart with a context carrying the application's start // OnStart with a context carrying the application's start
@@ -327,7 +263,7 @@ func TestArchiveSweeper_LoopOutlivesStartHookContext(
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, time.Hour, "") tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
now := time.Now() now := time.Now()
path := env.seedArchiveRows( path := env.seedArchiveRows(
@@ -381,7 +317,7 @@ func TestArchiveSweep_DoesNotResurrectEvictedWriter(
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, time.Hour, "") tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows( env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour), t, tgt, time.Now().Add(-48*time.Hour),
) )
@@ -390,16 +326,16 @@ func TestArchiveSweep_DoesNotResurrectEvictedWriter(
// the deletion path does. The target row is deliberately left // the deletion path does. The target row is deliberately left
// in place: this is the tick that listed the target before // in place: this is the tick that listed the target before
// the deletion committed. // the deletion committed.
_, err := env.writers.ExportEnsureArchiveWriter(tgt.ID) _, err := env.eng.ExportEnsureArchiveWriter(tgt.ID)
require.NoError(t, err) require.NoError(t, err)
env.writers.EvictTarget(tgt.ID) env.eng.EvictTarget(tgt.ID)
require.False(t, env.writers.ExportHasArchiveWriter(tgt.ID)) require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID))
env.sweeper.ExportSweep(context.Background()) env.sweeper.ExportSweep(context.Background())
assert.False( assert.False(
t, env.writers.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"a sweep must never re-register a writer for a target "+ "a sweep must never re-register a writer for a target "+
"whose registry entry has already been released", "whose registry entry has already been released",
) )
@@ -415,14 +351,14 @@ func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) {
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, time.Hour, "") tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.seedArchiveRows( path := env.seedArchiveRows(
t, tgt, t, tgt,
time.Now().Add(-48*time.Hour), time.Now().Add(-48*time.Hour),
time.Now().Add(-time.Minute), time.Now().Add(-time.Minute),
) )
require.False(t, env.writers.ExportHasArchiveWriter(tgt.ID)) require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID))
env.sweeper.ExportSweep(context.Background()) env.sweeper.ExportSweep(context.Background())
@@ -431,7 +367,7 @@ func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) {
"the sweep must still prune an idle archive", "the sweep must still prune an idle archive",
) )
assert.False( assert.False(
t, env.writers.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"the sweep must release the registry entry it created", "the sweep must release the registry entry it created",
) )
} }
@@ -448,27 +384,29 @@ func TestArchiveSweep_KeepsWriterAdoptedByDelivery(
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, time.Hour, "") tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows( env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour), t, tgt, time.Now().Add(-48*time.Hour),
) )
d := databaseTargetDelivery(newEvent(`{"n":1}`), tgt) webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
env.sweeper.ExportSweep(context.Background()) env.sweeper.ExportSweep(context.Background())
require.False(t, env.writers.ExportHasArchiveWriter(tgt.ID)) require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID))
require.NoError(t, env.writers.Write(d)) env.eng.ExportDeliverDatabase(webhookDB, d)
assert.True( assert.True(
t, env.writers.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"a delivery's writer must stay registered", "a delivery's writer must stay registered",
) )
env.sweeper.ExportSweep(context.Background()) env.sweeper.ExportSweep(context.Background())
assert.True( assert.True(
t, env.writers.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"a sweep must not drop a writer a delivery owns", "a sweep must not drop a writer a delivery owns",
) )
} }
@@ -502,12 +440,12 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, time.Hour, "") tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows( env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour), t, tgt, time.Now().Add(-48*time.Hour),
) )
sweepWriter, created, err := env.writers.ExportSweepWriterFor( sweepWriter, created, err := env.eng.ExportSweepWriterFor(
tgt.ID, tgt.ID,
) )
require.NoError(t, err) require.NoError(t, err)
@@ -517,34 +455,36 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
) )
// The delivery lands mid-sweep and adopts the entry. // The delivery lands mid-sweep and adopts the entry.
d := databaseTargetDelivery(newEvent(`{"n":1}`), tgt) webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
require.NoError(t, env.writers.Write(d)) env.eng.ExportDeliverDatabase(webhookDB, d)
adopted := env.writers.ExportArchiveWriterFor(tgt.ID) adopted := env.eng.ExportArchiveWriterFor(tgt.ID)
require.NotNil(t, adopted) require.NotNil(t, adopted)
require.True( require.True(
t, sweepWriter.Same(adopted), t, sweepWriter.Same(adopted),
"the delivery must have adopted the sweep's writer", "the delivery must have adopted the sweep's writer",
) )
require.True( require.True(
t, env.writers.ExportArchiveHandleOpen(tgt.ID), t, env.eng.ExportArchiveHandleOpen(tgt.ID),
"the delivery leaves the archive handle open", "the delivery leaves the archive handle open",
) )
// The sweep finishes. // The sweep finishes.
env.writers.ExportReleaseSweepWriter(tgt.ID, sweepWriter) env.eng.ExportReleaseSweepWriter(tgt.ID, sweepWriter)
require.True( require.True(
t, env.writers.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"a writer adopted by a delivery during a sweep must "+ "a writer adopted by a delivery during a sweep must "+
"stay registered, or its open handle is unreachable", "stay registered, or its open handle is unreachable",
) )
env.writers.EvictTarget(tgt.ID) env.eng.EvictTarget(tgt.ID)
assert.False( assert.False(
t, env.writers.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"the adopted writer must still be evictable", "the adopted writer must still be evictable",
) )
assert.False( assert.False(
@@ -555,8 +495,8 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
// TestArchiveSweep_ContinuesAfterPerTargetFailure proves a // TestArchiveSweep_ContinuesAfterPerTargetFailure proves a
// failure for one target does not abort the sweep for the // failure for one target does not abort the sweep for the
// others: an unreadable archive has to be logged and stepped // others: an unparseable expiry and an unreadable archive both
// over. // have to be logged and stepped over.
func TestArchiveSweep_ContinuesAfterPerTargetFailure( func TestArchiveSweep_ContinuesAfterPerTargetFailure(
t *testing.T, t *testing.T,
) { ) {
@@ -564,16 +504,21 @@ func TestArchiveSweep_ContinuesAfterPerTargetFailure(
env := setupArchiveTest(t) env := setupArchiveTest(t)
// Seeded first so the sweep reaches it before the healthy // Seeded first so the sweep reaches them before the healthy
// target: targets come back in insertion order. // target: targets come back in insertion order.
corrupt := env.seedDatabaseTarget(t, time.Hour, "") badConfig := env.seedDatabaseTarget(t, `{"expiry":"!!!"}`)
env.seedArchiveRows(
t, badConfig, time.Now().Add(-48*time.Hour),
)
corrupt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
require.NoError(t, os.WriteFile( require.NoError(t, os.WriteFile(
env.archivePath(corrupt), env.archivePath(corrupt),
[]byte("this is not a sqlite database"), []byte("this is not a sqlite database"),
0o600, 0o600,
)) ))
healthy := env.seedDatabaseTarget(t, time.Hour, "") healthy := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
healthyPath := env.seedArchiveRows( healthyPath := env.seedArchiveRows(
t, healthy, t, healthy,
time.Now().Add(-48*time.Hour), time.Now().Add(-48*time.Hour),
@@ -603,7 +548,7 @@ func TestArchiveSweep_OpenExistingDoesNotCreateFile(
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "archive-absent.db") path := filepath.Join(dir, "archive-absent.db")
w := archive.NewExportArchiveWriter( w := delivery.NewExportArchiveWriter(
path, archiveTestLogger(), 0, path, archiveTestLogger(), 0,
) )
@@ -630,7 +575,7 @@ func TestArchiveSweep_PrunesIdleArchive(t *testing.T) {
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, time.Hour, "") tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
now := time.Now() now := time.Now()
path := env.seedArchiveRows( path := env.seedArchiveRows(
@@ -669,12 +614,12 @@ func TestArchiveSweep_LeavesArchiveClosed(t *testing.T) {
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, time.Hour, "") tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.seedArchiveRows( path := env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour), t, tgt, time.Now().Add(-48*time.Hour),
) )
w := archive.NewExportArchiveWriter( w := delivery.NewExportArchiveWriter(
path, archiveTestLogger(), 0, path, archiveTestLogger(), 0,
) )
@@ -709,28 +654,30 @@ func TestArchiveSweep_ClosesHandleOfRegisteredWriter(
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, time.Hour, "") tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows( env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour), t, tgt, time.Now().Add(-48*time.Hour),
) )
d := databaseTargetDelivery(newEvent(`{"n":1}`), tgt) webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
require.NoError(t, env.writers.Write(d)) env.eng.ExportDeliverDatabase(webhookDB, d)
require.True( require.True(
t, env.writers.ExportArchiveHandleOpen(tgt.ID), t, env.eng.ExportArchiveHandleOpen(tgt.ID),
"the delivery must leave the archive handle open", "the delivery must leave the archive handle open",
) )
env.sweeper.ExportSweep(context.Background()) env.sweeper.ExportSweep(context.Background())
require.True( require.True(
t, env.writers.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"the delivery's registry entry must survive the sweep", "the delivery's registry entry must survive the sweep",
) )
assert.False( assert.False(
t, env.writers.ExportArchiveHandleOpen(tgt.ID), t, env.eng.ExportArchiveHandleOpen(tgt.ID),
"the sweep must leave the archive closed", "the sweep must leave the archive closed",
) )
} }
@@ -746,7 +693,7 @@ func TestArchiveSweep_ClosesHandleBeforeReopening(t *testing.T) {
path := filepath.Join(t.TempDir(), "archive.db") path := filepath.Join(t.TempDir(), "archive.db")
w := archive.NewExportArchiveWriter( w := delivery.NewExportArchiveWriter(
path, archiveTestLogger(), 0, path, archiveTestLogger(), 0,
) )
@@ -774,8 +721,8 @@ func TestArchiveSweep_CancelledSweepLogsNoError(t *testing.T) {
var errorLines bytes.Buffer var errorLines bytes.Buffer
sweeper := archive.NewTestSweeper( sweeper := delivery.NewTestArchiveSweeper(
env.mainDB, env.writers, env.mainDB, env.eng,
slog.New(slog.NewTextHandler( slog.New(slog.NewTextHandler(
&errorLines, &errorLines,
&slog.HandlerOptions{Level: slog.LevelError}, &slog.HandlerOptions{Level: slog.LevelError},
@@ -794,14 +741,19 @@ func TestArchiveSweep_CancelledSweepLogsNoError(t *testing.T) {
} }
// TestArchiveSweep_NeverExpiryUntouched proves the sweep is a // TestArchiveSweep_NeverExpiryUntouched proves the sweep is a
// no-op for the default retention policy, so an archive with an // no-op for the default retention policy, so archives with no
// expiry of 0, which keeps it forever, is left alone. // expiry (or the literal "never") behave exactly as before.
func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) { func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) {
t.Parallel() t.Parallel()
for _, configJSON := range []string{
`{"expiry":"never"}`,
`{"expiry":""}`,
"",
} {
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, "") tgt := env.seedDatabaseTarget(t, configJSON)
path := env.seedArchiveRows( path := env.seedArchiveRows(
t, tgt, t, tgt,
time.Now().Add(-10000*time.Hour), time.Now().Add(-10000*time.Hour),
@@ -811,13 +763,15 @@ func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) {
assert.Equal( assert.Equal(
t, []string{sweepRowOld}, archivedEventIDs(t, path), t, []string{sweepRowOld}, archivedEventIDs(t, path),
"an expiry of 0 must keep rows forever", "config %q must keep rows forever", configJSON,
) )
assert.False( assert.False(
t, env.writers.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"an expiry of 0 must leave no registry entry behind", "config %q must leave no registry entry behind",
configJSON,
) )
} }
}
// TestArchiveSweep_NeverExpirySkipsBeforeOpening pins the // TestArchiveSweep_NeverExpirySkipsBeforeOpening pins the
// expiry <= 0 boundary in sweepTarget, which the row assertions // expiry <= 0 boundary in sweepTarget, which the row assertions
@@ -827,9 +781,9 @@ func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) {
// //
// The spec is stronger than that — a "never" archive is skipped // The spec is stronger than that — a "never" archive is skipped
// before any file is touched — so the archive here exists but has // before any file is touched — so the archive here exists but has
// never been migrated. Opening it at all would apply the archive // never been migrated. Opening it at all would run AutoMigrate
// schema and create the archive table, which is exactly what must // and create the archive table, which is exactly what must not
// not happen. // happen.
func TestArchiveSweep_NeverExpirySkipsBeforeOpening( func TestArchiveSweep_NeverExpirySkipsBeforeOpening(
t *testing.T, t *testing.T,
) { ) {
@@ -837,7 +791,7 @@ func TestArchiveSweep_NeverExpirySkipsBeforeOpening(
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, "") tgt := env.seedDatabaseTarget(t, `{"expiry":"never"}`)
path := env.archivePath(tgt) path := env.archivePath(tgt)
seedUnmigratedArchive(t, path) seedUnmigratedArchive(t, path)
@@ -853,7 +807,7 @@ func TestArchiveSweep_NeverExpirySkipsBeforeOpening(
// seedUnmigratedArchive creates an archive file that exists but // seedUnmigratedArchive creates an archive file that exists but
// carries no archive schema, so any open of it is observable: the // carries no archive schema, so any open of it is observable: the
// archive table appears only if something applied the schema. // archive table appears only if something ran AutoMigrate.
func seedUnmigratedArchive(t *testing.T, path string) { func seedUnmigratedArchive(t *testing.T, path string) {
t.Helper() t.Helper()
@@ -877,7 +831,7 @@ func archiveTableExists(t *testing.T, path string) bool {
return openArchiveDBForRead(t, path). return openArchiveDBForRead(t, path).
Migrator(). Migrator().
HasTable(&archive.ExportArchivedEvent{}) HasTable(&delivery.ExportArchivedEvent{})
} }
// TestArchiveSweep_DoesNotCreateArchiveFile proves the sweep // TestArchiveSweep_DoesNotCreateArchiveFile proves the sweep
@@ -889,7 +843,7 @@ func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) {
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, time.Hour, "") tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.archivePath(tgt) path := env.archivePath(tgt)
require.NoFileExists(t, path) require.NoFileExists(t, path)
@@ -904,7 +858,7 @@ func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) {
} }
assert.False( assert.False(
t, env.writers.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"the sweep must leave no registry entry behind", "the sweep must leave no registry entry behind",
) )
} }
@@ -920,9 +874,9 @@ func TestArchiveSweep_DoesNotCreateAfterWriterExists(
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, time.Hour, "") tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path, err := env.writers.ExportEnsureArchiveWriter(tgt.ID) path, err := env.eng.ExportEnsureArchiveWriter(tgt.ID)
require.NoError(t, err) require.NoError(t, err)
require.NoFileExists(t, path) require.NoFileExists(t, path)
@@ -939,7 +893,7 @@ func TestArchiveSweep_SkipsDeletedWebhookTargets(t *testing.T) {
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, time.Hour, "") tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.seedArchiveRows( path := env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour), t, tgt, time.Now().Add(-48*time.Hour),
) )
@@ -959,26 +913,31 @@ func TestArchiveSweep_SkipsDeletedWebhookTargets(t *testing.T) {
) )
} }
// TestArchiveSweep_ConcurrentWrites runs the sweep while writes to // TestArchiveSweep_ConcurrentWrites proves the sweep serialises
// the same target are under way. The sweep serialises against them // against writes through the target's writer mutex. Run under
// through the target's writer mutex; the suite runs without Go's race // -race, an unsynchronised sweep would be caught here.
// detector, so a sweep that did not would fail this test only by
// panicking, hanging or leaving no archive file.
func TestArchiveSweep_ConcurrentWrites(t *testing.T) { func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, time.Hour, "") tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookDB := testWebhookDB(t)
// The deliveries are seeded up front, on the test's own
// goroutine: the seed helpers assert, and testify assertions
// must not run off the test goroutine.
deliveries := make( deliveries := make(
[]*database.Delivery, 0, sweepConcurrentWrites, []*database.Delivery, 0, sweepConcurrentWrites,
) )
for range sweepConcurrentWrites { for range sweepConcurrentWrites {
event := seedEvent(t, webhookDB, `{"n":1}`)
deliveries = append( deliveries = append(
deliveries, deliveries,
databaseTargetDelivery(newEvent(`{"n":1}`), tgt), seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
) )
} }
@@ -990,7 +949,7 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
defer wg.Done() defer wg.Done()
for _, d := range deliveries { for _, d := range deliveries {
_ = env.writers.Write(d) env.eng.ExportDeliverDatabase(webhookDB, d)
} }
}() }()
@@ -1014,7 +973,7 @@ func TestArchiveSweeper_StopsCleanly(t *testing.T) {
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, time.Hour, "") tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows( env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour), t, tgt, time.Now().Add(-48*time.Hour),
) )
+19 -3
View File
@@ -46,7 +46,12 @@ func TestClientForRequest_TimeoutKeepsSSRFGuard(t *testing.T) {
t.Run(target, func(t *testing.T) { t.Run(target, func(t *testing.T) {
t.Parallel() t.Parallel()
client := engine.ExportClientForRequest(5, nil) cfg := &delivery.HTTPTargetConfig{
URL: target,
Timeout: 5,
}
client := engine.ExportClientForRequest(cfg, nil)
require.NotSame(t, engine.ExportClient(), client, require.NotSame(t, engine.ExportClient(), client,
"a per-target timeout must yield a "+ "a per-target timeout must yield a "+
@@ -96,7 +101,11 @@ func TestClientForRequest_NoTimeoutUnchanged(t *testing.T) {
engine := newSSRFTestEngine() engine := newSSRFTestEngine()
client := engine.ExportClientForRequest(0, nil) cfg := &delivery.HTTPTargetConfig{
URL: "https://example.com/hook",
}
client := engine.ExportClientForRequest(cfg, nil)
assert.Same(t, engine.ExportClient(), client, assert.Same(t, engine.ExportClient(), client,
"without a per-target timeout the shared client "+ "without a per-target timeout the shared client "+
@@ -114,8 +123,15 @@ func TestClientForRequest_HeadersKeepSSRFGuard(t *testing.T) {
engine := newSSRFTestEngine() engine := newSSRFTestEngine()
cfg := &delivery.HTTPTargetConfig{
URL: "https://example.com/with-headers",
Headers: map[string]string{
"X-Api-Key": "configured",
},
}
client := engine.ExportClientForRequest( client := engine.ExportClientForRequest(
0, []string{probeHeaderName}, cfg, []string{"X-Api-Key"},
) )
require.NotNil(t, client.CheckRedirect, require.NotNil(t, client.CheckRedirect,
+2 -13
View File
@@ -32,9 +32,8 @@ func targetTotals(
// TestUpdateDeliveryStatus_FinishTimeAndTargetTotals pins what a status // TestUpdateDeliveryStatus_FinishTimeAndTargetTotals pins what a status
// write records for the webhook page's statistics: the time a delivery // write records for the webhook page's statistics: the time a delivery
// finished, set only when it becomes delivered or failed and stored in // finished, set only when it becomes delivered or failed, and one more
// the same form as created_at, and one more on its target's delivered // on its target's delivered or failed total.
// or failed total.
func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) { func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) {
t.Parallel() t.Parallel()
@@ -76,16 +75,6 @@ func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) {
if tt.finished { if tt.finished {
require.NotNil(t, stored.FinishedAt) require.NotNil(t, stored.FinishedAt)
assert.False(t, stored.FinishedAt.Before(before)) assert.False(t, stored.FinishedAt.Before(before))
var createdAt, finishedAt string
require.NoError(t, db.Raw(
`SELECT CAST(created_at AS TEXT),
CAST(finished_at AS TEXT)
FROM deliveries WHERE id = ?`, d.ID,
).Row().Scan(&createdAt, &finishedAt))
assertStoredTime(t, createdAt)
assertStoredTime(t, finishedAt)
} else { } else {
assert.Nil(t, stored.FinishedAt) assert.Nil(t, stored.FinishedAt)
} }
File diff suppressed because it is too large Load Diff
+172 -105
View File
@@ -3,6 +3,7 @@ package delivery_test
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
@@ -96,6 +97,17 @@ func iSeedWebhookDB(
return db return db
} }
func iHTTPConfig(url string) string {
cfg := delivery.HTTPTargetConfig{URL: url}
data, err := json.Marshal(cfg)
if err != nil {
panic("failed to marshal HTTPTargetConfig")
}
return string(data)
}
func iEngine( func iEngine(
t *testing.T, workers int, t *testing.T, workers int,
) *delivery.Engine { ) *delivery.Engine {
@@ -157,8 +169,8 @@ func iSeedDelivery(
func iTask( func iTask(
d database.Delivery, d database.Delivery,
event database.Event, event database.Event,
webhookID, targetID, name, targetURL string, webhookID, targetID, name, config string,
maxAttempts, attemptNum int, maxRetries, attemptNum int,
body *string, body *string,
) delivery.Task { ) delivery.Task {
return delivery.Task{ return delivery.Task{
@@ -168,8 +180,8 @@ func iTask(
TargetID: targetID, TargetID: targetID,
TargetName: name, TargetName: name,
TargetType: database.TargetTypeHTTP, TargetType: database.TargetTypeHTTP,
TargetURL: targetURL, TargetConfig: config,
MaxAttempts: maxAttempts, MaxRetries: maxRetries,
Method: event.Method, Method: event.Method,
Headers: event.Headers, Headers: event.Headers,
ContentType: event.ContentType, ContentType: event.ContentType,
@@ -227,9 +239,10 @@ func TestProcessNewTask_InlineBody(t *testing.T) {
) )
bodyStr := event.Body bodyStr := event.Body
cfg := iHTTPConfig(ts.URL)
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"test-target", ts.URL, 1, 1, &bodyStr, "test-target", cfg, 0, 1, &bodyStr,
) )
s.Engine.ExportProcessNewTask( s.Engine.ExportProcessNewTask(
@@ -277,9 +290,10 @@ func TestProcessNewTask_LargeBody_FetchFromDB(
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
cfg := iHTTPConfig(ts.URL)
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"test-large", ts.URL, 1, 1, nil, "test-large", cfg, 0, 1, nil,
) )
s.Engine.ExportProcessNewTask( s.Engine.ExportProcessNewTask(
@@ -305,8 +319,8 @@ func TestProcessNewTask_InvalidWebhookID(t *testing.T) {
TargetID: uuid.New().String(), TargetID: uuid.New().String(),
TargetName: "test", TargetName: "test",
TargetType: database.TargetTypeHTTP, TargetType: database.TargetTypeHTTP,
TargetURL: "http://localhost:9999", TargetConfig: iHTTPConfig("http://localhost:9999"),
MaxAttempts: 1, MaxRetries: 0,
Body: nil, Body: nil,
AttemptNum: 1, AttemptNum: 1,
} }
@@ -349,6 +363,7 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) {
) )
bodyStr := event.Body bodyStr := event.Body
cfg := iHTTPConfig(ts.URL)
// The target row exists because the engine confirms a scheduled // The target row exists because the engine confirms a scheduled
// retry's target has not been deleted before it runs it. A retry // retry's target has not been deleted before it runs it. A retry
@@ -357,12 +372,12 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) {
// task. See https://git.eeqj.de/sneak/webhooker/issues/107. // task. See https://git.eeqj.de/sneak/webhooker/issues/107.
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "retry-target", t, s.MainDB, targetID, s.WebhookID, "retry-target",
database.TargetTypeHTTP, ts.URL, 5, database.TargetTypeHTTP, cfg, 5,
) )
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"retry-target", ts.URL, 5, 2, &bodyStr, "retry-target", cfg, 5, 2, &bodyStr,
) )
s.Engine.ExportProcessRetryTask( s.Engine.ExportProcessRetryTask(
@@ -395,9 +410,10 @@ func TestProcessRetryTask_SkipsNonRetryingDelivery(
) )
bodyStr := event.Body bodyStr := event.Body
cfg := iHTTPConfig("http://localhost:1")
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"skip-target", "http://localhost:1", 5, 2, &bodyStr, "skip-target", cfg, 5, 2, &bodyStr,
) )
s.Engine.ExportProcessRetryTask( s.Engine.ExportProcessRetryTask(
@@ -444,14 +460,16 @@ func TestProcessRetryTask_LargeBody_FetchFromDB(
database.DeliveryStatusRetrying, database.DeliveryStatusRetrying,
) )
cfg := iHTTPConfig(ts.URL)
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "retry-large", t, s.MainDB, targetID, s.WebhookID, "retry-large",
database.TargetTypeHTTP, ts.URL, 5, database.TargetTypeHTTP, cfg, 5,
) )
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"retry-large", ts.URL, 5, 2, nil, "retry-large", cfg, 5, 2, nil,
) )
s.Engine.ExportProcessRetryTask( s.Engine.ExportProcessRetryTask(
@@ -488,11 +506,11 @@ func TestWorkerLifecycle_StartStop(t *testing.T) {
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"lifecycle-test", "", "lifecycle-test", "",
1, 1, &bodyStr, 0, 1, &bodyStr,
) )
task.TargetType = database.TargetTypeLog task.TargetType = database.TargetTypeLog
s.Engine.ExportNotify([]delivery.Task{task}) s.Engine.Notify([]delivery.Task{task})
iWaitForDelivered(t, s.WebhookDB, d.ID) iWaitForDelivered(t, s.WebhookDB, d.ID)
@@ -552,15 +570,16 @@ func TestWorkerLifecycle_ProcessesRetryChannel(
s.Engine.ExportStart() s.Engine.ExportStart()
bodyStr := event.Body bodyStr := event.Body
cfg := iHTTPConfig(ts.URL)
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "retry-chan-test", t, s.MainDB, targetID, s.WebhookID, "retry-chan-test",
database.TargetTypeHTTP, ts.URL, 5, database.TargetTypeHTTP, cfg, 5,
) )
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"retry-chan-test", ts.URL, 5, 2, &bodyStr, "retry-chan-test", cfg, 5, 2, &bodyStr,
) )
s.Engine.ExportRetryCh() <- task s.Engine.ExportRetryCh() <- task
@@ -626,7 +645,7 @@ func TestRecoverPendingDeliveries(t *testing.T) {
iCreateTarget(t, s.MainDB, targetID, iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, "recovery-target", s.WebhookID, "recovery-target",
database.TargetTypeLog, "", 1, database.TargetTypeLog, "", 0,
) )
iSeedPendingDeliveries( iSeedPendingDeliveries(
@@ -640,7 +659,7 @@ func TestRecoverPendingDeliveries(t *testing.T) {
s.Engine.ExportRecoverPendingDeliveries( s.Engine.ExportRecoverPendingDeliveries(
context.Background(), s.WebhookDB, context.Background(), s.WebhookDB,
s.WebhookID, atStart, s.WebhookID,
) )
for i := range 3 { for i := range 3 {
@@ -666,8 +685,8 @@ func iCreateTarget(
mainDB *gorm.DB, mainDB *gorm.DB,
targetID, webhookID, name string, targetID, webhookID, name string,
targetType database.TargetType, targetType database.TargetType,
targetURL string, config string,
maxAttempts int, maxRetries int,
) { ) {
t.Helper() t.Helper()
@@ -676,8 +695,8 @@ func iCreateTarget(
Name: name, Name: name,
Type: targetType, Type: targetType,
Active: true, Active: true,
URL: targetURL, Config: config,
MaxAttempts: maxAttempts, MaxRetries: maxRetries,
} }
target.ID = targetID target.ID = targetID
@@ -717,7 +736,7 @@ func TestRecoverWebhookDeliveries_RetryingDeliveries(
iCreateTarget(t, s.MainDB, targetID, iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, "retry-recovery", s.WebhookID, "retry-recovery",
database.TargetTypeHTTP, database.TargetTypeHTTP,
"http://example.com/hook", 5, iHTTPConfig("http://example.com/hook"), 5,
) )
event := iSeedEvent( event := iSeedEvent(
@@ -737,7 +756,7 @@ func TestRecoverWebhookDeliveries_RetryingDeliveries(
) )
s.Engine.ExportRecoverWebhookDeliveries( s.Engine.ExportRecoverWebhookDeliveries(
context.Background(), s.WebhookID, atStart, context.Background(), s.WebhookID,
) )
select { select {
@@ -777,7 +796,7 @@ func iSeedRetryingWithType(
iCreateTarget(t, s.MainDB, targetID, iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, "mutated-target", targetType, s.WebhookID, "mutated-target", targetType,
"http://example.com/hook", 5, iHTTPConfig("http://example.com/hook"), 5,
) )
event := iSeedEvent( event := iSeedEvent(
@@ -861,7 +880,7 @@ func TestRecoverSingleRetry_TypeNoLongerRetries(
) )
s.Engine.ExportRecoverWebhookDeliveries( s.Engine.ExportRecoverWebhookDeliveries(
context.Background(), s.WebhookID, atStart, context.Background(), s.WebhookID,
) )
iAssertTerminallyFailed( iAssertTerminallyFailed(
@@ -869,7 +888,7 @@ func TestRecoverSingleRetry_TypeNoLongerRetries(
) )
} }
func TestRecoverSingleRetry_OnTick_TypeNoLongerRetries( func TestSweepSingleRetry_TypeNoLongerRetries(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
@@ -884,8 +903,8 @@ func TestRecoverSingleRetry_OnTick_TypeNoLongerRetries(
t, s, database.TargetTypeDatabase, t, s, database.TargetTypeDatabase,
) )
s.Engine.ExportRecoverWebhookDeliveries( s.Engine.ExportSweepWebhookRetries(
context.Background(), s.WebhookID, onTick, context.Background(), s.WebhookID,
) )
iAssertTerminallyFailed( iAssertTerminallyFailed(
@@ -915,14 +934,14 @@ func TestFailUnretryableRetry_WritesNoTargetRow(
targetID := uuid.New().String() targetID := uuid.New().String()
// A Slack incoming-webhook URL: the target's URL IS the // A Slack incoming-webhook URL: the target config IS the
// credential, which is what makes a leaked target row a // credential, which is what makes a leaked target row a
// disclosure rather than a curiosity. // disclosure rather than a curiosity.
hookURL := "https://hooks.slack.com/services/T00/B00/x" hookURL := "https://hooks.slack.com/services/T00/B00/x"
iCreateTarget(t, s.MainDB, targetID, iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, "credential-bearing", s.WebhookID, "credential-bearing",
database.TargetTypeLog, hookURL, 5, database.TargetTypeLog, iHTTPConfig(hookURL), 5,
) )
event := iSeedEvent( event := iSeedEvent(
@@ -936,20 +955,37 @@ func TestFailUnretryableRetry_WritesNoTargetRow(
iSeedFailedResult(t, s.WebhookDB, d.ID) iSeedFailedResult(t, s.WebhookDB, d.ID)
s.Engine.ExportRecoverWebhookDeliveries( s.Engine.ExportSweepWebhookRetries(
context.Background(), s.WebhookID, onTick, context.Background(), s.WebhookID,
) )
iAssertStatus(t, s.WebhookDB, d.ID, iAssertStatus(t, s.WebhookDB, d.ID,
database.DeliveryStatusFailed, database.DeliveryStatusFailed,
) )
// The per-webhook database has no targets table, so writing a // The table exists in the per-webhook database because GORM
// target row there would have failed the status write above. // migrates the Delivery relation's model alongside it. It must
assert.False(t, s.WebhookDB.Migrator().HasTable("targets"), // stay empty.
var targetRows int64
require.NoError(t, s.WebhookDB.
Table("targets").
Count(&targetRows).Error)
assert.Zero(t, targetRows,
"orphaned-retry terminal failure wrote a target row "+ "orphaned-retry terminal failure wrote a target row "+
"into the per-webhook event database", "into the per-webhook event database",
) )
var configs []string
require.NoError(t, s.WebhookDB.
Table("targets").
Pluck("config", &configs).Error)
assert.NotContains(
t, strings.Join(configs, " "), hookURL,
)
} }
func TestRecoverSingleRetry_UnknownTargetType( func TestRecoverSingleRetry_UnknownTargetType(
@@ -968,13 +1004,13 @@ func TestRecoverSingleRetry_UnknownTargetType(
deliveryID := iSeedRetryingWithType(t, s, unknown) deliveryID := iSeedRetryingWithType(t, s, unknown)
s.Engine.ExportRecoverWebhookDeliveries( s.Engine.ExportRecoverWebhookDeliveries(
context.Background(), s.WebhookID, atStart, context.Background(), s.WebhookID,
) )
iAssertTerminallyFailed(t, s, deliveryID, unknown) iAssertTerminallyFailed(t, s, deliveryID, unknown)
} }
func TestRecoverSingleRetry_OnTick_UnknownTargetType( func TestSweepSingleRetry_UnknownTargetType(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
@@ -989,8 +1025,8 @@ func TestRecoverSingleRetry_OnTick_UnknownTargetType(
deliveryID := iSeedRetryingWithType(t, s, unknown) deliveryID := iSeedRetryingWithType(t, s, unknown)
s.Engine.ExportRecoverWebhookDeliveries( s.Engine.ExportSweepWebhookRetries(
context.Background(), s.WebhookID, onTick, context.Background(), s.WebhookID,
) )
iAssertTerminallyFailed(t, s, deliveryID, unknown) iAssertTerminallyFailed(t, s, deliveryID, unknown)
@@ -1032,19 +1068,19 @@ func iCreateWebhook(
require.NoError(t, mainDB.Create(&webhook).Error) require.NoError(t, mainDB.Create(&webhook).Error)
} }
// --- recoverDeliveries Tests --- // --- recoverInFlight Tests ---
func TestRecoverDeliveries_NoWebhooks(t *testing.T) { func TestRecoverInFlight_NoWebhooks(t *testing.T) {
t.Parallel() t.Parallel()
s := newISetup(t) s := newISetup(t)
s.Engine.ExportRecoverDeliveries( s.Engine.ExportRecoverInFlight(
context.Background(), atStart, context.Background(),
) )
} }
func TestRecoverDeliveries_WithPendingDeliveries( func TestRecoverInFlight_WithPendingDeliveries(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
@@ -1058,7 +1094,7 @@ func TestRecoverDeliveries_WithPendingDeliveries(
iCreateTarget(t, s.MainDB, targetID, iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, "recover-target", s.WebhookID, "recover-target",
database.TargetTypeLog, "", 1, database.TargetTypeLog, "", 0,
) )
event := iSeedEvent( event := iSeedEvent(
@@ -1071,8 +1107,8 @@ func TestRecoverDeliveries_WithPendingDeliveries(
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
s.Engine.ExportRecoverDeliveries( s.Engine.ExportRecoverInFlight(
context.Background(), atStart, context.Background(),
) )
select { select {
@@ -1083,16 +1119,16 @@ func TestRecoverDeliveries_WithPendingDeliveries(
database.TargetTypeLog, task.TargetType, database.TargetTypeLog, task.TargetType,
) )
case <-time.After(2 * time.Second): case <-time.After(2 * time.Second):
t.Fatal("expected task from recovery at start") t.Fatal("expected task from recoverInFlight")
} }
} }
// TestRecoverDeliveries_ReportsAMissingWebhookDatabase covers a webhook // TestRecoverInFlight_ReportsAMissingWebhookDatabase covers a webhook
// whose database file is gone, after a partial restore say. Recovery at // whose database file is gone, after a partial restore say. Restart
// start opens every webhook's database, so the empty one made in its // recovery opens every webhook's database, so the empty one made in its
// place is reported at start, naming the file // place is reported at start, naming the file
// (https://git.eeqj.de/sneak/webhooker/issues/290). // (https://git.eeqj.de/sneak/webhooker/issues/290).
func TestRecoverDeliveries_ReportsAMissingWebhookDatabase(t *testing.T) { func TestRecoverInFlight_ReportsAMissingWebhookDatabase(t *testing.T) {
t.Parallel() t.Parallel()
mainDB := iMainDB(t) mainDB := iMainDB(t)
@@ -1112,7 +1148,7 @@ func TestRecoverDeliveries_ReportsAMissingWebhookDatabase(t *testing.T) {
&http.Client{Timeout: 5 * time.Second}, 1, &http.Client{Timeout: 5 * time.Second}, 1,
) )
engine.ExportRecoverDeliveries(context.Background(), atStart) engine.ExportRecoverInFlight(context.Background())
assert.Contains( assert.Contains(
t, logs.String(), t, logs.String(),
@@ -1121,11 +1157,10 @@ func TestRecoverDeliveries_ReportsAMissingWebhookDatabase(t *testing.T) {
) )
} }
// TestRecoverDeliveries_SkipsAWebhookDeletedAfterTheListIsRead covers a // TestRecoverInFlight_SkipsAWebhookDeletedAfterTheListIsRead covers a
// webhook deleted from the web interface while the recovery routine // webhook deleted from the web interface while restart recovery runs.
// runs. Its database file is gone, and recovery must not create it // Its database file is gone, and recovery must not create it again.
// again. func TestRecoverInFlight_SkipsAWebhookDeletedAfterTheListIsRead(
func TestRecoverDeliveries_SkipsAWebhookDeletedAfterTheListIsRead(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
@@ -1161,13 +1196,13 @@ func TestRecoverDeliveries_SkipsAWebhookDeletedAfterTheListIsRead(
&http.Client{Timeout: 5 * time.Second}, 1, &http.Client{Timeout: 5 * time.Second}, 1,
) )
engine.ExportRecoverDeliveries(context.Background(), atStart) engine.ExportRecoverInFlight(context.Background())
require.True(t, deleted) require.True(t, deleted)
assert.False(t, dbMgr.DBExists(webhookID)) assert.False(t, dbMgr.DBExists(webhookID))
} }
// --- HTTP target with custom headers --- // --- HTTP Config with custom headers ---
func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) { func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1189,6 +1224,16 @@ func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
) )
defer ts.Close() defer ts.Close()
cfg := delivery.HTTPTargetConfig{
URL: ts.URL,
Headers: map[string]string{
"Authorization": "Bearer secret-token",
},
}
cfgJSON, err := json.Marshal(cfg)
require.NoError(t, err)
event := iSeedEvent( event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, t, s.WebhookDB, s.WebhookID,
`{"auth":"test"}`, `{"auth":"test"}`,
@@ -1203,12 +1248,9 @@ func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
bodyStr := event.Body bodyStr := event.Body
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"auth-target", ts.URL, "auth-target", string(cfgJSON),
1, 1, &bodyStr, 0, 1, &bodyStr,
) )
task.TargetHeaders = map[string]string{
headerAuthorization: "Bearer secret-token",
}
s.Engine.ExportProcessNewTask( s.Engine.ExportProcessNewTask(
context.TODO(), &task, context.TODO(), &task,
@@ -1221,14 +1263,11 @@ func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
// --- HTTP delivery with custom timeout --- // --- HTTP delivery with custom timeout ---
// TestDeliverHTTP_TargetTimeout proves a task's TimeoutSeconds reaches
// the delivery through the engine. The destination answers after two
// seconds, inside the engine's own five-second client timeout, so only
// the task's one second can fail the delivery.
func TestDeliverHTTP_TargetTimeout(t *testing.T) { func TestDeliverHTTP_TargetTimeout(t *testing.T) {
t.Parallel() t.Parallel()
s := newISetup(t) db := testWebhookDB(t)
e := iEngine(t, 1)
ts := httptest.NewServer( ts := httptest.NewServer(
http.HandlerFunc( http.HandlerFunc(
@@ -1240,30 +1279,31 @@ func TestDeliverHTTP_TargetTimeout(t *testing.T) {
) )
defer ts.Close() defer ts.Close()
event := iSeedEvent( cfg := delivery.HTTPTargetConfig{
t, s.WebhookDB, s.WebhookID, `{"timeout":"test"}`, URL: ts.URL,
) Timeout: 1,
targetID := uuid.New().String() }
d := iSeedDelivery( cfgJSON, err := json.Marshal(cfg)
t, s.WebhookDB, event.ID, targetID, require.NoError(t, err)
database.DeliveryStatusPending,
event, del := iSeedEventAndDelivery(
t, db, `{"timeout":"test"}`,
string(cfgJSON),
) )
bodyStr := event.Body task, d := iHTTPTaskAndDelivery(
task := iTask( event, del, "timeout-target",
d, event, s.WebhookID, targetID, string(cfgJSON), 0, 1,
"timeout-target", ts.URL, 1, 1, &bodyStr,
) )
task.TimeoutSeconds = 1
s.Engine.ExportProcessNewTask(context.TODO(), &task) e.ExportDeliverHTTP(context.TODO(), db, d, task)
iAssertStatus(t, s.WebhookDB, d.ID, iAssertStatus(t, db, del.ID,
database.DeliveryStatusFailed, database.DeliveryStatusFailed,
) )
iAssertResultFailed(t, s.WebhookDB, d.ID) iAssertResultFailed(t, db, del.ID)
} }
// TestDeliverHTTP_CutsStoredResponseAtMaxBodyLog pins the size // TestDeliverHTTP_CutsStoredResponseAtMaxBodyLog pins the size
@@ -1301,6 +1341,8 @@ func TestDeliverHTTP_CutsStoredResponseAtMaxBodyLog(
)) ))
defer ts.Close() defer ts.Close()
cfgJSON := iHTTPConfig(ts.URL)
event := iSeedEvent( event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"cut":"test"}`, t, s.WebhookDB, s.WebhookID, `{"cut":"test"}`,
) )
@@ -1314,7 +1356,7 @@ func TestDeliverHTTP_CutsStoredResponseAtMaxBodyLog(
bodyStr := event.Body bodyStr := event.Body
task := iTask( task := iTask(
del, event, s.WebhookID, targetID, del, event, s.WebhookID, targetID,
"cut-target", ts.URL, 1, 1, &bodyStr, "cut-target", cfgJSON, 0, 1, &bodyStr,
) )
s.Engine.ExportProcessNewTask(context.TODO(), &task) s.Engine.ExportProcessNewTask(context.TODO(), &task)
@@ -1346,7 +1388,7 @@ func TestDeliverHTTP_CutsStoredResponseAtMaxBodyLog(
func iSeedEventAndDelivery( func iSeedEventAndDelivery(
t *testing.T, t *testing.T,
db *gorm.DB, db *gorm.DB,
body string, body, _ string,
) (database.Event, database.Delivery) { ) (database.Event, database.Delivery) {
t.Helper() t.Helper()
@@ -1377,8 +1419,8 @@ func iSeedEventAndDelivery(
func iHTTPTaskAndDelivery( func iHTTPTaskAndDelivery(
event database.Event, event database.Event,
del database.Delivery, del database.Delivery,
name, targetURL string, name, config string,
maxAttempts, attemptNum int, maxRetries, attemptNum int,
) (*delivery.Task, *database.Delivery) { ) (*delivery.Task, *database.Delivery) {
task := &delivery.Task{ task := &delivery.Task{
DeliveryID: del.ID, DeliveryID: del.ID,
@@ -1387,8 +1429,8 @@ func iHTTPTaskAndDelivery(
TargetID: del.TargetID, TargetID: del.TargetID,
TargetName: name, TargetName: name,
TargetType: database.TargetTypeHTTP, TargetType: database.TargetTypeHTTP,
TargetURL: targetURL, TargetConfig: config,
MaxAttempts: maxAttempts, MaxRetries: maxRetries,
AttemptNum: attemptNum, AttemptNum: attemptNum,
} }
@@ -1400,8 +1442,7 @@ func iHTTPTaskAndDelivery(
Target: database.Target{ Target: database.Target{
Name: name, Name: name,
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
URL: targetURL, Config: config,
MaxAttempts: maxAttempts,
}, },
} }
d.ID = del.ID d.ID = del.ID
@@ -1429,30 +1470,56 @@ func iAssertResultFailed(
assert.NotEmpty(t, result.Error) assert.NotEmpty(t, result.Error)
} }
// --- HTTP request with no URL --- // --- HTTP request with invalid config ---
// TestDeliverHTTP_NoURL proves an http target without a URL, which func TestDeliverHTTP_InvalidConfig(t *testing.T) {
// the forms never store, fails its delivery and records why.
func TestDeliverHTTP_NoURL(t *testing.T) {
t.Parallel() t.Parallel()
db := testWebhookDB(t) db := testWebhookDB(t)
e := iEngine(t, 1) e := iEngine(t, 1)
event, del := iSeedEventAndDelivery(t, db, `{"url":"none"}`) event, del := iSeedEventAndDelivery(
t, db, `{"config":"invalid"}`, "",
)
task, d := iHTTPTaskAndDelivery(event, del, "no-url", "", 1, 1) task, d := iHTTPTaskAndDelivery(
event, del, "bad-config", `not-json`, 0, 1,
)
e.ExportDeliverHTTP(context.TODO(), db, d, task) e.ExportDeliverHTTP(context.TODO(), db, d, task)
iAssertStatus(t, db, del.ID, iAssertStatus(t, db, del.ID,
database.DeliveryStatusFailed, database.DeliveryStatusFailed,
) )
iAssertResultFailed(t, db, del.ID)
} }
// --- notify batching --- // TestDeliverHTTP_InvalidConfigUnrecordedStaysPending: a delivery is
// failed for an invalid config only once the reason is recorded.
// Unrecorded, it stays pending, where the sweep finds it again.
func TestDeliverHTTP_InvalidConfigUnrecordedStaysPending(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e := testEngine(t, 1)
event, del := iSeedEventAndDelivery(
t, db, `{"config":"invalid"}`, "",
)
task, d := iHTTPTaskAndDelivery(
event, del, "bad-config", `not-json`, 0, 1,
)
require.NoError(t, db.Exec("drop table delivery_results").Error)
e.ExportDeliverHTTP(context.TODO(), db, d, task)
iAssertStatus(t, db, del.ID,
database.DeliveryStatusPending,
)
}
// --- Notify batching ---
func TestNotify_MultipleTasks(t *testing.T) { func TestNotify_MultipleTasks(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1467,7 +1534,7 @@ func TestNotify_MultipleTasks(t *testing.T) {
} }
} }
e.ExportNotify(tasks) e.Notify(tasks)
for i := range 5 { for i := range 5 {
select { select {
+18 -174
View File
@@ -2,16 +2,11 @@ package delivery_test
import ( import (
"context" "context"
"io"
"net/http"
"net/http/httptest"
"path/filepath" "path/filepath"
"sync"
"testing" "testing"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -29,7 +24,7 @@ const (
// OnStart before the caller may enqueue work. A worker pool // OnStart before the caller may enqueue work. A worker pool
// wrongly rooted in the already-done hook context has nothing // wrongly rooted in the already-done hook context has nothing
// but ctx.Done() ready in its select, so it is deterministically // but ctx.Done() ready in its select, so it is deterministically
// gone by the end of this window. Without the wait, notify would // gone by the end of this window. Without the wait, Notify would
// race the pool's very first select, in which a ready ctx.Done() // race the pool's very first select, in which a ready ctx.Done()
// and a ready deliveryCh are chosen between at random and a // and a ready deliveryCh are chosen between at random and a
// doomed pool still delivers. // doomed pool still delivers.
@@ -99,7 +94,7 @@ func requireStopHookExpires(
// the caller can drive OnStop too. // the caller can drive OnStop too.
// //
// Callers must not seed pending or retrying deliveries before // Callers must not seed pending or retrying deliveries before
// calling this: the recovery routine's run at start enqueues those, // calling this: restart recovery enqueues those during startup,
// which would put work in the queue while the pool is still // which would put work in the queue while the pool is still
// racing its first select. // racing its first select.
func startEngineViaHook( func startEngineViaHook(
@@ -149,7 +144,7 @@ func seedLogTask(
bodyStr := event.Body bodyStr := event.Body
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"hook-context-test", "", 1, 1, &bodyStr, "hook-context-test", "", 0, 1, &bodyStr,
) )
task.TargetType = database.TargetTypeLog task.TargetType = database.TargetTypeLog
@@ -178,11 +173,11 @@ func TestEngine_WorkersOutliveStartHookContext(t *testing.T) {
_ = lc.hooks[0].OnStop(context.Background()) _ = lc.hooks[0].OnStop(context.Background())
}) })
// Seeded only after the pool has settled, so the recovery // Seeded only after the pool has settled, so restart recovery
// routine's run at start cannot enqueue it. // cannot enqueue it during startup.
deliveryID, task := seedLogTask(t, s) deliveryID, task := seedLogTask(t, s)
s.Engine.ExportNotify([]delivery.Task{task}) s.Engine.Notify([]delivery.Task{task})
iWaitForDelivered(t, s.WebhookDB, deliveryID) iWaitForDelivered(t, s.WebhookDB, deliveryID)
} }
@@ -202,7 +197,7 @@ func TestEngine_StopHookStopsWorkers(t *testing.T) {
// Let the pool prove it is running before stopping it, so a // Let the pool prove it is running before stopping it, so a
// fast OnStop cannot pass by stopping something already dead. // fast OnStop cannot pass by stopping something already dead.
firstID, firstTask := seedLogTask(t, s) firstID, firstTask := seedLogTask(t, s)
s.Engine.ExportNotify([]delivery.Task{firstTask}) s.Engine.Notify([]delivery.Task{firstTask})
iWaitForDelivered(t, s.WebhookDB, firstID) iWaitForDelivered(t, s.WebhookDB, firstID)
var stopErr error var stopErr error
@@ -232,7 +227,7 @@ func TestEngine_StopHookStopsWorkers(t *testing.T) {
// With every worker gone, a freshly notified task must sit // With every worker gone, a freshly notified task must sit
// untouched in the queue rather than being delivered. // untouched in the queue rather than being delivered.
secondID, secondTask := seedLogTask(t, s) secondID, secondTask := seedLogTask(t, s)
s.Engine.ExportNotify([]delivery.Task{secondTask}) s.Engine.Notify([]delivery.Task{secondTask})
time.Sleep(200 * time.Millisecond) time.Sleep(200 * time.Millisecond)
@@ -277,159 +272,11 @@ func TestEngine_StopHookHonoursStopTimeout(t *testing.T) {
requireStopHookExpires(t, lc.hooks[0], "delivery engine") requireStopHookExpires(t, lc.hooks[0], "delivery engine")
} }
// seedHeldDelivery gives the setup's webhook an http target with
// maxAttempts, pointed at a receiver that keeps every request open until
// the client gives up on it, and seeds one delivery to that target at
// status. It returns the task that sends attempt attemptNum of the
// delivery, and a channel that is closed when a request reaches the
// receiver.
func seedHeldDelivery(
t *testing.T,
s iSetup,
maxAttempts int,
status database.DeliveryStatus,
attemptNum int,
) (delivery.Task, <-chan struct{}) {
t.Helper()
arrived := make(chan struct{})
var once sync.Once
ts := httptest.NewServer(http.HandlerFunc(
func(_ http.ResponseWriter, r *http.Request) {
// The server notices the client hanging up only once the
// body has been read; until then r.Context() never ends.
_, _ = io.Copy(io.Discard, r.Body)
once.Do(func() { close(arrived) })
<-r.Context().Done()
},
))
t.Cleanup(ts.Close)
targetID := uuid.New().String()
iCreateWebhook(t, s.MainDB, s.WebhookID, "hook")
iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "held",
database.TargetTypeHTTP, ts.URL, maxAttempts,
)
event := iSeedEvent(t, s.WebhookDB, s.WebhookID, `{"held":true}`)
d := iSeedDelivery(t, s.WebhookDB, event.ID, targetID, status)
bodyStr := event.Body
return iTask(
d, event, s.WebhookID, targetID, "held", ts.URL,
maxAttempts, attemptNum, &bodyStr,
), arrived
}
// stopDuringAttempt waits until the receiver holds an attempt open, then
// stops the engine through its real stop hook.
func stopDuringAttempt(
t *testing.T, lc *recordingLifecycle, arrived <-chan struct{},
) {
t.Helper()
select {
case <-arrived:
case <-time.After(5 * time.Second):
t.Fatal("the delivery attempt never reached the receiver")
}
require.NoError(t, lc.hooks[0].OnStop(context.Background()))
}
// requireRequeued runs the recovery routine as at start on the stopped
// engine and requires it to queue task's attempt again on ch.
func requireRequeued(
t *testing.T, s iSetup, ch <-chan delivery.Task, task delivery.Task,
) {
t.Helper()
s.Engine.ExportRecoverWebhookDeliveries(
context.Background(), s.WebhookID, atStart,
)
select {
case got := <-ch:
assert.Equal(t, task.DeliveryID, got.DeliveryID)
assert.Equal(t, task.AttemptNum, got.AttemptNum)
case <-time.After(5 * time.Second):
t.Fatal("recovery at start did not queue the delivery again")
}
}
// TestEngine_StopLeavesFireAndForgetAttemptPending is the regression
// test for a stop that turned an attempt in flight into a terminal
// failure. Stopping the engine cancels the request; for a target with
// no retries that was the delivery's only attempt, recorded as failed
// with "context canceled", and nothing ever sent it again. The
// delivery must instead stay pending, with no result and no count in
// the attempt metrics, for the recovery routine to send at the next
// start.
func TestEngine_StopLeavesFireAndForgetAttemptPending(t *testing.T) {
t.Parallel()
s := newISetup(t)
reg := mIsolate(t, s)
lc := startEngineViaHook(t, s.Engine)
task, arrived := seedHeldDelivery(
t, s, 1, database.DeliveryStatusPending, 1,
)
s.Engine.ExportNotify([]delivery.Task{task})
stopDuringAttempt(t, lc, arrived)
iAssertStatus(
t, s.WebhookDB, task.DeliveryID, database.DeliveryStatusPending,
)
assert.Empty(t, iResults(t, s.WebhookDB, task.DeliveryID))
assert.Zero(t, mCounter(t, reg, mAttempts, mTypeHTTP))
assert.Zero(t, mHTTPDurations(t, reg))
requireRequeued(t, s, s.Engine.ExportDeliveryCh(), task)
}
// TestEngine_StopLeavesLastRetryAttemptRetrying is the same regression
// for a target with retries, on its last attempt: the cancelled attempt
// used to exhaust the retries and end the delivery failed. It must stay
// retrying with only its earlier attempt recorded, and the recovery
// routine at the next start must send that last attempt again.
func TestEngine_StopLeavesLastRetryAttemptRetrying(t *testing.T) {
t.Parallel()
const maxAttempts = 2
s := newISetup(t)
lc := startEngineViaHook(t, s.Engine)
task, arrived := seedHeldDelivery(
t, s, maxAttempts, database.DeliveryStatusRetrying, maxAttempts,
)
iSeedFailedResult(t, s.WebhookDB, task.DeliveryID)
s.Engine.ExportScheduleRetry(task, 0)
stopDuringAttempt(t, lc, arrived)
iAssertStatus(
t, s.WebhookDB, task.DeliveryID, database.DeliveryStatusRetrying,
)
assert.Len(t, iResults(t, s.WebhookDB, task.DeliveryID), 1)
requireRequeued(t, s, s.Engine.ExportRetryCh(), task)
}
// deliverToArchive gives the setup's webhook a database target, // deliverToArchive gives the setup's webhook a database target,
// runs one delivery to it through the running engine, and returns // runs one delivery to it through the running engine, and returns
// the target's archive file path. The archive writer holds the file // the target's ID and archive file path. The archive writer holds
// open afterwards. // the file open afterwards.
func deliverToArchive(t *testing.T, s iSetup) string { func deliverToArchive(t *testing.T, s iSetup) (string, string) {
t.Helper() t.Helper()
iCreateWebhook(t, s.MainDB, s.WebhookID, "hook") iCreateWebhook(t, s.MainDB, s.WebhookID, "hook")
@@ -438,7 +285,6 @@ func deliverToArchive(t *testing.T, s iSetup) string {
WebhookID: s.WebhookID, WebhookID: s.WebhookID,
Name: "archive", Name: "archive",
Type: database.TargetTypeDatabase, Type: database.TargetTypeDatabase,
MaxAttempts: 1,
} }
require.NoError( require.NoError(
t, s.MainDB.Omit(clause.Associations).Create(tgt).Error, t, s.MainDB.Omit(clause.Associations).Create(tgt).Error,
@@ -448,11 +294,11 @@ func deliverToArchive(t *testing.T, s iSetup) string {
task.TargetID = tgt.ID task.TargetID = tgt.ID
task.TargetType = database.TargetTypeDatabase task.TargetType = database.TargetTypeDatabase
s.Engine.ExportNotify([]delivery.Task{task}) s.Engine.Notify([]delivery.Task{task})
iWaitForDelivered(t, s.WebhookDB, deliveryID) iWaitForDelivered(t, s.WebhookDB, deliveryID)
return filepath.Join( return tgt.ID, filepath.Join(
filepath.Dir(s.DBMgr.DBPath(s.WebhookID)), filepath.Dir(s.DBMgr.DBPath(s.WebhookID)),
"archive-hook-archive-"+tgt.ID+".db", "archive-hook-archive-"+tgt.ID+".db",
) )
@@ -471,7 +317,7 @@ func TestEngine_StopHookClosesArchives(t *testing.T) {
lc := startEngineViaHook(t, s.Engine) lc := startEngineViaHook(t, s.Engine)
path := deliverToArchive(t, s) _, path := deliverToArchive(t, s)
require.FileExists( require.FileExists(
t, path+"-wal", t, path+"-wal",
"an open archive should have a -wal for the stop to remove", "an open archive should have a -wal for the stop to remove",
@@ -505,23 +351,21 @@ func TestEngine_StopHookTimeoutLeavesArchivesOpen(t *testing.T) {
lc := startEngineViaHook(t, s.Engine) lc := startEngineViaHook(t, s.Engine)
path := deliverToArchive(t, s) targetID, _ := deliverToArchive(t, s)
release := make(chan struct{}) release := make(chan struct{})
t.Cleanup(func() { t.Cleanup(func() {
close(release) close(release)
s.Engine.ExportArchives().EvictWebhook(s.WebhookID) s.Engine.EvictWebhook(s.WebhookID)
}) })
s.Engine.ExportWedgeWorker(release) s.Engine.ExportWedgeWorker(release)
requireStopHookExpires(t, lc.hooks[0], "delivery engine") requireStopHookExpires(t, lc.hooks[0], "delivery engine")
// Closing the last handle on an archive removes its -wal, so a require.True(
// -wal still there is a writer still open. t, s.Engine.ExportArchiveHandleOpen(targetID),
require.FileExists(
t, path+"-wal",
"a stop that timed out must not close archive writers", "a stop that timed out must not close archive writers",
) )
} }
+232 -163
View File
@@ -10,6 +10,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -21,11 +22,12 @@ import (
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/database/databasetest"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/gormlog"
"sneak.berlin/go/webhooker/internal/metrics" "sneak.berlin/go/webhooker/internal/metrics"
) )
@@ -35,18 +37,35 @@ const testContentType = "application/json"
func testWebhookDB(t *testing.T) *gorm.DB { func testWebhookDB(t *testing.T) *gorm.DB {
t.Helper() t.Helper()
// Opened by the manager the service uses, so these tests cannot dbPath := filepath.Join(
// pass against a schema, journal and locking settings or write t.TempDir(), "events-test.db",
// guard production does not use.
mgr := databasetest.NewWebhookDBManagerWithLogger(
t, t.TempDir(), slog.New(slog.DiscardHandler),
) )
t.Cleanup(func() { _ = mgr.CloseAll() }) // Opened the way the service opens a per-webhook database, so
// these tests cannot pass against journal and locking settings
db, err := mgr.GetDB(uuid.New().String()) // production does not use.
sqlDB, err := database.OpenSQLite(
dbPath, database.SQLiteModeCreate,
)
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { _ = sqlDB.Close() })
db, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
)
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&database.Event{},
&database.Delivery{},
&database.DeliveryResult{},
&database.EventTotals{},
&database.TargetTotals{},
))
require.NoError(t, db.Create(&database.EventTotals{}).Error)
return db return db
} }
@@ -65,6 +84,20 @@ func testEngine(
) )
} }
func newHTTPTargetConfig(url string) string {
cfg := delivery.HTTPTargetConfig{URL: url}
data, err := json.Marshal(cfg)
if err != nil {
panic(
"failed to marshal HTTPTargetConfig: " +
err.Error(),
)
}
return string(data)
}
func seedEvent( func seedEvent(
t *testing.T, db *gorm.DB, body string, t *testing.T, db *gorm.DB, body string,
) database.Event { ) database.Event {
@@ -103,18 +136,6 @@ func seedDelivery(
return d return d
} }
// assertStoredTime asserts that text, a timestamp as SQLite holds it,
// is in the form GORM stores created_at in: the layout of
// time.Time.String, with no " m=+…" monotonic clock reading after it.
func assertStoredTime(t *testing.T, text string) {
t.Helper()
_, err := time.Parse(
"2006-01-02 15:04:05.999999999 -0700 MST", text,
)
assert.NoError(t, err, "stored as %q", text)
}
// httpDeliveryFixture holds the task and delivery needed // httpDeliveryFixture holds the task and delivery needed
// for HTTP delivery tests. // for HTTP delivery tests.
type httpDeliveryFixture struct { type httpDeliveryFixture struct {
@@ -127,8 +148,8 @@ type httpDeliveryFixture struct {
func buildHTTPFixture( func buildHTTPFixture(
dlv database.Delivery, dlv database.Delivery,
event database.Event, event database.Event,
targetID, name, targetURL string, targetID, name, config string,
maxAttempts, attemptNum int, maxRetries, attemptNum int,
) httpDeliveryFixture { ) httpDeliveryFixture {
task := &delivery.Task{ task := &delivery.Task{
DeliveryID: dlv.ID, DeliveryID: dlv.ID,
@@ -137,8 +158,8 @@ func buildHTTPFixture(
TargetID: targetID, TargetID: targetID,
TargetName: name, TargetName: name,
TargetType: database.TargetTypeHTTP, TargetType: database.TargetTypeHTTP,
TargetURL: targetURL, TargetConfig: config,
MaxAttempts: maxAttempts, MaxRetries: maxRetries,
AttemptNum: attemptNum, AttemptNum: attemptNum,
} }
@@ -150,8 +171,8 @@ func buildHTTPFixture(
Target: database.Target{ Target: database.Target{
Name: name, Name: name,
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
URL: targetURL, Config: config,
MaxAttempts: maxAttempts, MaxRetries: maxRetries,
}, },
} }
d.ID = dlv.ID d.ID = dlv.ID
@@ -218,7 +239,7 @@ func TestNotify_NonBlocking(t *testing.T) {
done := make(chan struct{}) done := make(chan struct{})
go func() { go func() {
e.ExportNotify([]delivery.Task{ e.Notify([]delivery.Task{
{DeliveryID: "overflow-1"}, {DeliveryID: "overflow-1"},
{DeliveryID: "overflow-2"}, {DeliveryID: "overflow-2"},
}) })
@@ -229,7 +250,7 @@ func TestNotify_NonBlocking(t *testing.T) {
case <-done: case <-done:
case <-time.After(2 * time.Second): case <-time.After(2 * time.Second):
t.Fatal( t.Fatal(
"notify blocked when delivery channel was full", "Notify blocked when delivery channel was full",
) )
} }
} }
@@ -261,8 +282,9 @@ func TestDeliverHTTP_Success(t *testing.T) {
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
cfg := newHTTPTargetConfig(ts.URL)
fix := buildHTTPFixture( fix := buildHTTPFixture(
dlv, event, targetID, "test-http", ts.URL, 1, 1, dlv, event, targetID, "test-http", cfg, 0, 1,
) )
e.ExportDeliverHTTP( e.ExportDeliverHTTP(
@@ -306,9 +328,10 @@ func TestDeliverHTTP_Failure(t *testing.T) {
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
cfg := newHTTPTargetConfig(ts.URL)
fix := buildHTTPFixture( fix := buildHTTPFixture(
dlv, event, targetID, dlv, event, targetID,
"test-http-fail", ts.URL, 1, 1, "test-http-fail", cfg, 0, 1,
) )
e.ExportDeliverHTTP( e.ExportDeliverHTTP(
@@ -335,7 +358,7 @@ func TestDeliverDatabase_ImmediateSuccess(
// The database target archives for real, so the engine needs // The database target archives for real, so the engine needs
// the target in the main database and a data directory. // the target in the main database and a data directory.
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t) tgt := env.seedDatabaseTarget(t, "")
event := seedEvent(t, db, `{"db":"target"}`) event := seedEvent(t, db, `{"db":"target"}`)
d := seedDatabaseTargetDelivery(t, db, event, tgt) d := seedDatabaseTargetDelivery(t, db, event, tgt)
@@ -436,9 +459,10 @@ func TestDeliverHTTP_WithRetries_Success(t *testing.T) {
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
cfg := newHTTPTargetConfig(ts.URL)
fix := buildHTTPFixture( fix := buildHTTPFixture(
dlv, event, targetID, dlv, event, targetID,
"test-http-retry", ts.URL, 5, 1, "test-http-retry", cfg, 5, 1,
) )
e.ExportDeliverHTTP( e.ExportDeliverHTTP(
@@ -456,23 +480,14 @@ func TestDeliverHTTP_WithRetries_Success(t *testing.T) {
) )
} }
// TestDeliverHTTP_MaxAttemptsExhausted proves a target with three func TestDeliverHTTP_MaxRetriesExhausted(t *testing.T) {
// delivery attempts makes at most three: the first two failures are
// retried, the third is final, and each attempt goes through the
// target's circuit breaker.
func TestDeliverHTTP_MaxAttemptsExhausted(t *testing.T) {
t.Parallel() t.Parallel()
const maxAttempts = 3
db := testWebhookDB(t) db := testWebhookDB(t)
var requests atomic.Int32
ts := httptest.NewServer( ts := httptest.NewServer(
http.HandlerFunc( http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) { func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusBadGateway) w.WriteHeader(http.StatusBadGateway)
}, },
), ),
@@ -481,101 +496,26 @@ func TestDeliverHTTP_MaxAttemptsExhausted(t *testing.T) {
e := testEngine(t, 1) e := testEngine(t, 1)
targetID := uuid.New().String() targetID := uuid.New().String()
// It opens on the last failure only if every attempt reached it.
cb := delivery.NewTestCircuitBreaker(maxAttempts, time.Minute)
e.ExportSetCircuitBreaker(targetID, cb)
event := seedEvent(t, db, `{"retry":"exhaust"}`) event := seedEvent(t, db, `{"retry":"exhaust"}`)
dlv := seedDelivery( dlv := seedDelivery(
t, db, event.ID, targetID, t, db, event.ID, targetID,
database.DeliveryStatusPending, database.DeliveryStatusRetrying,
) )
sched := &recordingScheduler{} cfg := newHTTPTargetConfig(ts.URL)
for attempt := 1; attempt <= maxAttempts; attempt++ {
fix := buildHTTPFixture( fix := buildHTTPFixture(
dlv, event, targetID, dlv, event, targetID,
"test-http-exhaust", ts.URL, maxAttempts, attempt, "test-http-exhaust", cfg, 3, 3,
) )
e.ExportDeliverHTTPWithScheduler( e.ExportDeliverHTTP(
context.TODO(), db, fix.Delivery, fix.Task, sched, context.TODO(), db, fix.Delivery, fix.Task,
) )
}
assert.Equal(t, int32(maxAttempts), requests.Load())
assert.Len(t, sched.delays, maxAttempts-1,
"every failure but the last should be retried",
)
assertDeliveryStatus(t, db, dlv.ID, assertDeliveryStatus(t, db, dlv.ID,
database.DeliveryStatusFailed, database.DeliveryStatusFailed,
) )
assert.Equal(t, delivery.CircuitOpen, cb.State(),
"every attempt should be counted by the circuit breaker",
)
}
// TestDeliverHTTP_SingleAttemptSkipsCircuitBreaker proves a target
// with one delivery attempt makes exactly that attempt, even while
// its circuit breaker is open, and that a failure is final.
func TestDeliverHTTP_SingleAttemptSkipsCircuitBreaker(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
var requests atomic.Int32
ts := httptest.NewServer(
http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusBadGateway)
},
),
)
defer ts.Close()
e := testEngine(t, 1)
targetID := uuid.New().String()
cb := e.ExportGetCircuitBreaker(targetID)
for range delivery.ExportDefaultFailureThreshold {
cb.RecordFailure()
}
require.Equal(t, delivery.CircuitOpen, cb.State())
event := seedEvent(t, db, `{"retry":"none"}`)
dlv := seedDelivery(
t, db, event.ID, targetID,
database.DeliveryStatusPending,
)
fix := buildHTTPFixture(
dlv, event, targetID,
"test-http-single", ts.URL, 1, 1,
)
sched := &recordingScheduler{}
e.ExportDeliverHTTPWithScheduler(
context.TODO(), db, fix.Delivery, fix.Task, sched,
)
assert.Equal(t, int32(1), requests.Load(),
"the open circuit breaker should not hold the attempt back",
)
assert.Empty(t, sched.delays, "no retry should be scheduled")
assertDeliveryStatus(t, db, dlv.ID,
database.DeliveryStatusFailed,
)
assertDeliveryResult(
t, db, dlv.ID, false, http.StatusBadGateway,
)
} }
func TestDeliverHTTP_SchedulesRetryOnFailure( func TestDeliverHTTP_SchedulesRetryOnFailure(
@@ -605,9 +545,10 @@ func TestDeliverHTTP_SchedulesRetryOnFailure(
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
cfg := newHTTPTargetConfig(ts.URL)
fix := buildHTTPFixture( fix := buildHTTPFixture(
dlv, event, targetID, dlv, event, targetID,
"test-http-schedule", ts.URL, 5, 1, "test-http-schedule", cfg, 5, 1,
) )
e.ExportDeliverHTTP( e.ExportDeliverHTTP(
@@ -769,7 +710,7 @@ func seedConcurrencyTasks(
t *testing.T, t *testing.T,
db *gorm.DB, db *gorm.DB,
n int, n int,
targetURL string, targetCfg string,
) ([]database.Delivery, []delivery.Task) { ) ([]database.Delivery, []delivery.Task) {
t.Helper() t.Helper()
@@ -795,8 +736,7 @@ func seedConcurrencyTasks(
Target: database.Target{ Target: database.Target{
Name: fmt.Sprintf("task-%d", i), Name: fmt.Sprintf("task-%d", i),
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
URL: targetURL, Config: targetCfg,
MaxAttempts: 1,
}, },
} }
tasks[i].ID = dlv.ID tasks[i].ID = dlv.ID
@@ -807,8 +747,8 @@ func seedConcurrencyTasks(
TargetID: tasks[i].TargetID, TargetID: tasks[i].TargetID,
TargetName: tasks[i].Target.Name, TargetName: tasks[i].Target.Name,
TargetType: tasks[i].Target.Type, TargetType: tasks[i].Target.Type,
TargetURL: tasks[i].Target.URL, TargetConfig: tasks[i].Target.Config,
MaxAttempts: 1, MaxRetries: 0,
AttemptNum: 1, AttemptNum: 1,
} }
} }
@@ -845,8 +785,9 @@ func TestWorkerPool_BoundedConcurrency(t *testing.T) {
defer ts.Close() defer ts.Close()
e := testEngine(t, numWorkers) e := testEngine(t, numWorkers)
cfg := newHTTPTargetConfig(ts.URL)
tasks, dtasks := seedConcurrencyTasks( tasks, dtasks := seedConcurrencyTasks(
t, db, numTasks, ts.URL, t, db, numTasks, cfg,
) )
runConcurrencyWorkers( runConcurrencyWorkers(
@@ -927,9 +868,12 @@ func TestDeliverHTTP_CircuitBreakerBlocks(t *testing.T) {
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
cfg := newHTTPTargetConfig(
"http://will-not-be-called.invalid",
)
fix := buildHTTPFixture( fix := buildHTTPFixture(
dlv, event, targetID, dlv, event, targetID,
"test-cb-block", "http://will-not-be-called.invalid", 5, 1, "test-cb-block", cfg, 5, 1,
) )
e.ExportDeliverHTTP( e.ExportDeliverHTTP(
@@ -992,8 +936,9 @@ func TestDeliverHTTP_HalfOpenBreakerDelaysQueuedTasks(t *testing.T) {
require.True(t, cb.Allow(), "the probe delivery should go through") require.True(t, cb.Allow(), "the probe delivery should go through")
require.Equal(t, delivery.CircuitHalfOpen, cb.State()) require.Equal(t, delivery.CircuitHalfOpen, cb.State())
const targetURL = "http://will-not-be-called.invalid" cfg := newHTTPTargetConfig(
"http://will-not-be-called.invalid",
)
sched := &recordingScheduler{} sched := &recordingScheduler{}
const queued, passes = 3, 4 const queued, passes = 3, 4
@@ -1015,7 +960,7 @@ func TestDeliverHTTP_HalfOpenBreakerDelaysQueuedTasks(t *testing.T) {
fix := buildHTTPFixture( fix := buildHTTPFixture(
row, event, targetID, row, event, targetID,
"test-cb-half-open", targetURL, 5, 1, "test-cb-half-open", cfg, 5, 1,
) )
e.ExportDeliverHTTPWithScheduler( e.ExportDeliverHTTPWithScheduler(
@@ -1130,6 +1075,62 @@ func TestStateAndCooldown_ReadsHTTPAndSlackBreakers(t *testing.T) {
assert.Zero(t, cooldown, "half-open") assert.Zero(t, cooldown, "half-open")
} }
func TestParseHTTPConfig_Valid(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
cfg, err := e.ExportParseHTTPConfig(
`{"url":"https://example.com/hook",` +
`"headers":{"X-Token":"secret"}}`,
)
require.NoError(t, err)
assert.Equal(t, "https://example.com/hook", cfg.URL)
assert.Equal(t, "secret", cfg.Headers["X-Token"])
}
func TestParseHTTPConfig_Empty(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
_, err := e.ExportParseHTTPConfig("")
assert.Error(t, err,
"empty config should return error",
)
}
func TestParseHTTPConfig_MissingURL(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
_, err := e.ExportParseHTTPConfig(
`{"headers":{"X-Token":"secret"}}`,
)
assert.Error(t, err,
"config without URL should return error",
)
}
func TestParseHTTPConfig_Undecodable(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
_, err := e.ExportParseHTTPConfig(
`{"url":"https://example.com/hook","timeout":"soon"}`,
)
assert.Error(t, err,
"config that does not decode should return error, "+
"even when the part that did names a URL",
)
}
func TestScheduleRetry_SendsToRetryChannel( func TestScheduleRetry_SendsToRetryChannel(
t *testing.T, t *testing.T,
) { ) {
@@ -1277,7 +1278,7 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
e := testEngine(t, 1) e := testEngine(t, 1)
target := &database.Target{ cfg := &delivery.HTTPTargetConfig{
URL: ts.URL, URL: ts.URL,
Headers: map[string]string{ Headers: map[string]string{
"X-Target-Auth": "bearer xyz", "X-Target-Auth": "bearer xyz",
@@ -1292,7 +1293,7 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
} }
statusCode, _, _, err := e.ExportDoHTTPRequest( statusCode, _, _, err := e.ExportDoHTTPRequest(
context.TODO(), target, event, context.TODO(), cfg, event,
) )
require.NoError(t, err) require.NoError(t, err)
@@ -1334,7 +1335,7 @@ func TestDoHTTPRequest_CutShortResponseIsAnError(t *testing.T) {
_, body, _, err := e.ExportDoHTTPRequest( _, body, _, err := e.ExportDoHTTPRequest(
context.TODO(), context.TODO(),
&database.Target{URL: ts.URL}, &delivery.HTTPTargetConfig{URL: ts.URL},
&database.Event{}, &database.Event{},
) )
@@ -1386,9 +1387,9 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) {
}) })
require.NoError(t, err) require.NoError(t, err)
var configured map[string]string cfg := &delivery.HTTPTargetConfig{}
if tc.configured != "" { if tc.configured != "" {
configured = map[string]string{ cfg.Headers = map[string]string{
headerContentType: tc.configured, headerContentType: tc.configured,
} }
} }
@@ -1407,7 +1408,7 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) {
Headers: string(inbound), Headers: string(inbound),
ContentType: tc.event, ContentType: tc.event,
}, },
configured, cfg,
"webhooker/dev", "webhooker/dev",
) )
@@ -1438,7 +1439,7 @@ func TestApplyRequestHeaders_UndecodableInboundForwardsNothing(
&database.Event{ &database.Event{
Headers: `{"X-Custom":["value1"],"X-Broken":"not a list"}`, Headers: `{"X-Custom":["value1"],"X-Broken":"not a list"}`,
}, },
nil, &delivery.HTTPTargetConfig{},
"webhooker/dev", "webhooker/dev",
) )
@@ -1456,7 +1457,7 @@ func TestProcessDelivery_RoutesToCorrectHandler(
// The database target archives for real, so the engine needs // The database target archives for real, so the engine needs
// the target in the main database and a data directory. // the target in the main database and a data directory.
env := setupArchiveTest(t) env := setupArchiveTest(t)
archive := env.seedDatabaseTarget(t) archive := env.seedDatabaseTarget(t, "")
tests := []struct { tests := []struct {
name string name string
@@ -1541,6 +1542,51 @@ func TestMaxInlineBodySize_Constant(t *testing.T) {
) )
} }
func TestParseSlackConfig_Valid(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
cfg, err := e.ExportParseSlackConfig(
`{"webhookUrl":"https://hooks.slack.com/services/T00/B00/xxx"}`,
)
require.NoError(t, err)
assert.Equal(t,
"https://hooks.slack.com/services/T00/B00/xxx",
cfg.WebhookURL,
)
}
func TestParseSlackConfig_Empty(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
_, err := e.ExportParseSlackConfig("")
assert.Error(t, err,
"empty config should return error",
)
}
func TestParseSlackConfig_MissingWebhookURL(
t *testing.T,
) {
t.Parallel()
e := testEngine(t, 1)
_, err := e.ExportParseSlackConfig(
`{"other":"field"}`,
)
assert.Error(t, err,
"config without webhook_url should return error",
)
}
func TestFormatSlackMessage_JSONBody(t *testing.T) { func TestFormatSlackMessage_JSONBody(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1637,11 +1683,11 @@ func TestFormatSlackMessage_LargeJSONTruncated(
} }
// buildSlackDelivery creates a database.Delivery // buildSlackDelivery creates a database.Delivery
// to a Slack target with the given webhook URL for testing. // with a Slack target config for testing.
func buildSlackDelivery( func buildSlackDelivery(
dlv database.Delivery, dlv database.Delivery,
event database.Event, event database.Event,
targetID, name, webhookURL string, targetID, name, slackCfg string,
) *database.Delivery { ) *database.Delivery {
d := &database.Delivery{ d := &database.Delivery{
EventID: event.ID, EventID: event.ID,
@@ -1651,8 +1697,7 @@ func buildSlackDelivery(
Target: database.Target{ Target: database.Target{
Name: name, Name: name,
Type: database.TargetTypeSlack, Type: database.TargetTypeSlack,
URL: webhookURL, Config: slackCfg,
MaxAttempts: 1,
}, },
} }
d.ID = dlv.ID d.ID = dlv.ID
@@ -1689,10 +1734,11 @@ func TestDeliverSlack_Success(t *testing.T) {
) )
defer ts.Close() defer ts.Close()
e, targetID, event, dlv := setupSlackTest(t, db) e, targetID, slackCfg, event, dlv :=
setupSlackTest(t, db, ts.URL)
d := buildSlackDelivery( d := buildSlackDelivery(
dlv, event, targetID, "test-slack", ts.URL, dlv, event, targetID, "test-slack", slackCfg,
) )
e.ExportDeliverSlack(context.TODO(), db, d) e.ExportDeliverSlack(context.TODO(), db, d)
@@ -1711,8 +1757,9 @@ func TestDeliverSlack_Success(t *testing.T) {
func setupSlackTest( func setupSlackTest(
t *testing.T, t *testing.T,
db *gorm.DB, db *gorm.DB,
serverURL string,
) ( ) (
*delivery.Engine, string, *delivery.Engine, string, string,
database.Event, database.Delivery, database.Event, database.Delivery,
) { ) {
t.Helper() t.Helper()
@@ -1720,6 +1767,13 @@ func setupSlackTest(
e := testEngine(t, 1) e := testEngine(t, 1)
targetID := uuid.New().String() targetID := uuid.New().String()
cfgBytes, err := json.Marshal(
delivery.SlackTargetConfig{
WebhookURL: serverURL,
},
)
require.NoError(t, err)
event := seedEvent( event := seedEvent(
t, db, `{"action":"test","data":"value"}`, t, db, `{"action":"test","data":"value"}`,
) )
@@ -1729,7 +1783,7 @@ func setupSlackTest(
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
return e, targetID, event, dlv return e, targetID, string(cfgBytes), event, dlv
} }
func assertSlackPayload( func assertSlackPayload(
@@ -1778,6 +1832,13 @@ func TestDeliverSlack_Failure(t *testing.T) {
e := testEngine(t, 1) e := testEngine(t, 1)
targetID := uuid.New().String() targetID := uuid.New().String()
slackCfg, err := json.Marshal(
delivery.SlackTargetConfig{
WebhookURL: ts.URL,
},
)
require.NoError(t, err)
event := seedEvent(t, db, `{"test":true}`) event := seedEvent(t, db, `{"test":true}`)
dlv := seedDelivery( dlv := seedDelivery(
@@ -1787,7 +1848,7 @@ func TestDeliverSlack_Failure(t *testing.T) {
d := buildSlackDelivery( d := buildSlackDelivery(
dlv, event, targetID, dlv, event, targetID,
"test-slack-fail", ts.URL, "test-slack-fail", string(slackCfg),
) )
e.ExportDeliverSlack(context.TODO(), db, d) e.ExportDeliverSlack(context.TODO(), db, d)
@@ -1801,10 +1862,7 @@ func TestDeliverSlack_Failure(t *testing.T) {
) )
} }
// TestDeliverSlack_NoURL proves a Slack target without a URL, which func TestDeliverSlack_InvalidConfig(t *testing.T) {
// the forms never store, fails its delivery rather than reporting it
// delivered.
func TestDeliverSlack_NoURL(t *testing.T) {
t.Parallel() t.Parallel()
db := testWebhookDB(t) db := testWebhookDB(t)
@@ -1825,7 +1883,7 @@ func TestDeliverSlack_NoURL(t *testing.T) {
Target: database.Target{ Target: database.Target{
Name: "test-slack-bad", Name: "test-slack-bad",
Type: database.TargetTypeSlack, Type: database.TargetTypeSlack,
MaxAttempts: 1, Config: `{"not_webhook_url":"missing"}`,
}, },
} }
d.ID = dlv.ID d.ID = dlv.ID
@@ -1862,6 +1920,13 @@ func TestProcessDelivery_RoutesToSlack(t *testing.T) {
e := testEngine(t, 1) e := testEngine(t, 1)
slackCfg, err := json.Marshal(
delivery.SlackTargetConfig{
WebhookURL: ts.URL,
},
)
require.NoError(t, err)
event := seedEvent(t, db, `{"route":"slack"}`) event := seedEvent(t, db, `{"route":"slack"}`)
dlv := seedDelivery( dlv := seedDelivery(
@@ -1871,13 +1936,12 @@ func TestProcessDelivery_RoutesToSlack(t *testing.T) {
d := buildSlackDelivery( d := buildSlackDelivery(
dlv, event, dlv.TargetID, dlv, event, dlv.TargetID,
"test-slack-route", ts.URL, "test-slack-route", string(slackCfg),
) )
task := &delivery.Task{ task := &delivery.Task{
DeliveryID: dlv.ID, DeliveryID: dlv.ID,
TargetType: database.TargetTypeSlack, TargetType: database.TargetTypeSlack,
MaxAttempts: 1,
} }
e.ExportProcessDelivery( e.ExportProcessDelivery(
@@ -1986,7 +2050,7 @@ func TestDeliverLog_LogsFullContent(t *testing.T) {
func buildSlackRetryDelivery( func buildSlackRetryDelivery(
dlv database.Delivery, dlv database.Delivery,
event database.Event, event database.Event,
targetID, webhookURL string, targetID, cfg string,
) *database.Delivery { ) *database.Delivery {
d := &database.Delivery{ d := &database.Delivery{
EventID: event.ID, EventID: event.ID,
@@ -1996,8 +2060,8 @@ func buildSlackRetryDelivery(
Target: database.Target{ Target: database.Target{
Name: "test-slack-retry", Name: "test-slack-retry",
Type: database.TargetTypeSlack, Type: database.TargetTypeSlack,
URL: webhookURL, Config: cfg,
MaxAttempts: 5, MaxRetries: 5,
}, },
} }
d.ID = dlv.ID d.ID = dlv.ID
@@ -2015,6 +2079,11 @@ func TestDeliverSlack_WithRetries_SchedulesRetry(
e := testEngine(t, 1) e := testEngine(t, 1)
targetID := uuid.New().String() targetID := uuid.New().String()
slackCfg, err := json.Marshal(
delivery.SlackTargetConfig{WebhookURL: ts.URL},
)
require.NoError(t, err)
event := seedEvent(t, db, `{"slack":"retry"}`) event := seedEvent(t, db, `{"slack":"retry"}`)
dlv := seedDelivery( dlv := seedDelivery(
@@ -2023,14 +2092,14 @@ func TestDeliverSlack_WithRetries_SchedulesRetry(
) )
d := buildSlackRetryDelivery( d := buildSlackRetryDelivery(
dlv, event, targetID, ts.URL, dlv, event, targetID, string(slackCfg),
) )
task := &delivery.Task{ task := &delivery.Task{
DeliveryID: dlv.ID, DeliveryID: dlv.ID,
TargetID: targetID, TargetID: targetID,
TargetType: database.TargetTypeSlack, TargetType: database.TargetTypeSlack,
MaxAttempts: 5, MaxRetries: 5,
AttemptNum: 1, AttemptNum: 1,
} }
+6 -4
View File
@@ -69,6 +69,7 @@ func TestEventDBHoldsNoTargetRows(t *testing.T) {
)) ))
defer ts.Close() defer ts.Close()
cfg := iHTTPConfig(ts.URL)
targetID := uuid.New().String() targetID := uuid.New().String()
dbPath := s.DBMgr.DBPath(s.WebhookID) dbPath := s.DBMgr.DBPath(s.WebhookID)
@@ -84,7 +85,7 @@ func TestEventDBHoldsNoTargetRows(t *testing.T) {
) )
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"leaky-target", ts.URL, 5, 1, &body, "leaky-target", cfg, 5, 1, &body,
) )
s.Engine.ExportProcessNewTask(context.TODO(), &task) s.Engine.ExportProcessNewTask(context.TODO(), &task)
@@ -101,7 +102,7 @@ func TestEventDBHoldsNoTargetRows(t *testing.T) {
// https://git.eeqj.de/sneak/webhooker/issues/107. // https://git.eeqj.de/sneak/webhooker/issues/107.
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "leaky-target", t, s.MainDB, targetID, s.WebhookID, "leaky-target",
database.TargetTypeHTTP, ts.URL, 5, database.TargetTypeHTTP, cfg, 5,
) )
rd := iSeedDelivery( rd := iSeedDelivery(
@@ -110,7 +111,7 @@ func TestEventDBHoldsNoTargetRows(t *testing.T) {
) )
rTask := iTask( rTask := iTask(
rd, event, s.WebhookID, targetID, rd, event, s.WebhookID, targetID,
"leaky-target", ts.URL, 5, 2, &body, "leaky-target", cfg, 5, 2, &body,
) )
s.Engine.ExportProcessRetryTask(context.TODO(), &rTask) s.Engine.ExportProcessRetryTask(context.TODO(), &rTask)
@@ -137,6 +138,7 @@ func TestEventDBHoldsNoTargetRowsOnFailedDelivery(t *testing.T) {
)) ))
defer ts.Close() defer ts.Close()
cfg := iHTTPConfig(ts.URL)
targetID := uuid.New().String() targetID := uuid.New().String()
event := iSeedEvent( event := iSeedEvent(
@@ -150,7 +152,7 @@ func TestEventDBHoldsNoTargetRowsOnFailedDelivery(t *testing.T) {
) )
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"leaky-target", ts.URL, 1, 1, &body, "leaky-target", cfg, 0, 1, &body,
) )
s.Engine.ExportProcessNewTask(context.TODO(), &task) s.Engine.ExportProcessNewTask(context.TODO(), &task)
+21 -8
View File
@@ -93,6 +93,17 @@ func (s *tsSink) text(t *testing.T) string {
} }
} }
func tsSlackConfig(t *testing.T, url string) string {
t.Helper()
data, err := json.Marshal(
delivery.SlackTargetConfig{WebhookURL: url},
)
require.NoError(t, err)
return string(data)
}
// tsSeedEvent writes an event whose CreatedAt is tsEventCreatedAt // tsSeedEvent writes an event whose CreatedAt is tsEventCreatedAt
// rather than the write time, so an assertion on the rendered // rather than the write time, so an assertion on the rendered
// timestamp cannot pass by accident against "roughly now". // timestamp cannot pass by accident against "roughly now".
@@ -131,7 +142,7 @@ func tsSeedEvent(
// tsSeedTarget writes the slack target row into the main database. // tsSeedTarget writes the slack target row into the main database.
// The retry path confirms the target still exists before sending. // The retry path confirms the target still exists before sending.
func tsSeedTarget( func tsSeedTarget(
t *testing.T, mainDB *gorm.DB, webhookID, webhookURL string, t *testing.T, mainDB *gorm.DB, webhookID, config string,
) database.Target { ) database.Target {
t.Helper() t.Helper()
@@ -139,9 +150,8 @@ func tsSeedTarget(
WebhookID: webhookID, WebhookID: webhookID,
Name: "slack-sink", Name: "slack-sink",
Type: database.TargetTypeSlack, Type: database.TargetTypeSlack,
URL: webhookURL, Config: config,
Active: true, Active: true,
MaxAttempts: 1,
} }
require.NoError(t, mainDB.Create(&target).Error) require.NoError(t, mainDB.Create(&target).Error)
@@ -165,8 +175,8 @@ func tsTask(
TargetID: target.ID, TargetID: target.ID,
TargetName: target.Name, TargetName: target.Name,
TargetType: database.TargetTypeSlack, TargetType: database.TargetTypeSlack,
TargetURL: target.URL, TargetConfig: target.Config,
MaxAttempts: 1, MaxRetries: 0,
Method: event.Method, Method: event.Method,
Headers: event.Headers, Headers: event.Headers,
ContentType: event.ContentType, ContentType: event.ContentType,
@@ -215,7 +225,8 @@ func (c tsCase) run(t *testing.T) (iSetup, database.Delivery, string) {
s := newISetup(t) s := newISetup(t)
sink := newTSSink(t) sink := newTSSink(t)
target := tsSeedTarget(t, s.MainDB, s.WebhookID, sink.URL) cfg := tsSlackConfig(t, sink.URL)
target := tsSeedTarget(t, s.MainDB, s.WebhookID, cfg)
event := tsSeedEvent(t, s.WebhookDB, s.WebhookID) event := tsSeedEvent(t, s.WebhookDB, s.WebhookID)
d := iSeedDelivery( d := iSeedDelivery(
@@ -326,7 +337,8 @@ func TestFormatSlackMessageOverTaskReconstructedEvent(
s := newISetup(t) s := newISetup(t)
target := tsSeedTarget(t, s.MainDB, s.WebhookID, tsUndeliverableHook) cfg := tsSlackConfig(t, tsUndeliverableHook)
target := tsSeedTarget(t, s.MainDB, s.WebhookID, cfg)
event := tsSeedEvent(t, s.WebhookDB, s.WebhookID) event := tsSeedEvent(t, s.WebhookDB, s.WebhookID)
d := iSeedDelivery( d := iSeedDelivery(
@@ -397,7 +409,8 @@ func TestEventReconstructionSurvivesAReapedRow(t *testing.T) {
s := newISetup(t) s := newISetup(t)
target := tsSeedTarget(t, s.MainDB, s.WebhookID, tsUndeliverableHook) cfg := tsSlackConfig(t, tsUndeliverableHook)
target := tsSeedTarget(t, s.MainDB, s.WebhookID, cfg)
event := tsSeedEvent(t, s.WebhookDB, s.WebhookID) event := tsSeedEvent(t, s.WebhookDB, s.WebhookID)
d := iSeedDelivery( d := iSeedDelivery(
+314 -40
View File
@@ -12,11 +12,16 @@ import (
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"go.uber.org/fx" "go.uber.org/fx"
"gorm.io/gorm" "gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/metrics" "sneak.berlin/go/webhooker/internal/metrics"
) )
// ErrExportArchiveWriterEvicted exposes the sentinel returned by
// an evicted archive writer. It carries the Err prefix rather
// than this file's usual Export one because it is a sentinel
// error.
var ErrExportArchiveWriterEvicted = errArchiveWriterEvicted
// Exported constants for test access. // Exported constants for test access.
const ( const (
ExportDeliveryChannelSize = deliveryChannelSize ExportDeliveryChannelSize = deliveryChannelSize
@@ -77,10 +82,10 @@ func ExportIsForwardableHeader(name string) bool {
func ExportApplyRequestHeaders( func ExportApplyRequestHeaders(
req *http.Request, req *http.Request,
event *database.Event, event *database.Event,
configured map[string]string, cfg *HTTPTargetConfig,
userAgent string, userAgent string,
) []string { ) []string {
return applyRequestHeaders(req, event, configured, userAgent) return applyRequestHeaders(req, event, cfg, userAgent)
} }
// ExportTruncate exposes truncate for testing. // ExportTruncate exposes truncate for testing.
@@ -139,7 +144,6 @@ func (e *Engine) ExportDeliverSlack(
task := &Task{ task := &Task{
DeliveryID: d.ID, DeliveryID: d.ID,
TargetID: d.TargetID, TargetID: d.TargetID,
MaxAttempts: d.Target.MaxAttempts,
AttemptNum: 1, AttemptNum: 1,
} }
@@ -158,11 +162,6 @@ func (e *Engine) ExportUpdateDeliveryStatus(
return e.updateDeliveryStatus(webhookDB, d, "", status) return e.updateDeliveryStatus(webhookDB, d, "", status)
} }
// ExportNotify exposes notify.
func (e *Engine) ExportNotify(tasks []Task) {
e.notify(tasks)
}
// ExportProcessNewTask exposes processNewTask. // ExportProcessNewTask exposes processNewTask.
func (e *Engine) ExportProcessNewTask( func (e *Engine) ExportProcessNewTask(
ctx context.Context, task *Task, ctx context.Context, task *Task,
@@ -221,23 +220,37 @@ func (e *Engine) ExportSetSlackCircuitBreaker(
e.slackTarget.circuitBreakers.Store(targetID, cb) e.slackTarget.circuitBreakers.Store(targetID, cb)
} }
// ExportParseHTTPConfig exposes parseHTTPConfig.
func (e *Engine) ExportParseHTTPConfig(
configJSON string,
) (*HTTPTargetConfig, error) {
return parseHTTPConfig(configJSON)
}
// ExportParseSlackConfig exposes parseSlackConfig.
func (e *Engine) ExportParseSlackConfig(
configJSON string,
) (*SlackTargetConfig, error) {
return parseSlackConfig(configJSON)
}
// ExportDoHTTPRequest exposes the http target's // ExportDoHTTPRequest exposes the http target's
// doHTTPRequest. // doHTTPRequest.
func (e *Engine) ExportDoHTTPRequest( func (e *Engine) ExportDoHTTPRequest(
ctx context.Context, ctx context.Context,
target *database.Target, cfg *HTTPTargetConfig,
event *database.Event, event *database.Event,
) (int, string, int64, error) { ) (int, string, int64, error) {
return e.httpTarget.doHTTPRequest(ctx, target, event) return e.httpTarget.doHTTPRequest(ctx, cfg, event)
} }
// ExportClientForRequest exposes the http target's // ExportClientForRequest exposes the http target's
// clientForRequest. // clientForRequest.
func (e *Engine) ExportClientForRequest( func (e *Engine) ExportClientForRequest(
timeoutSeconds int, cfg *HTTPTargetConfig,
originScoped []string, originScoped []string,
) *http.Client { ) *http.Client {
return e.httpTarget.clientForRequest(timeoutSeconds, originScoped) return e.httpTarget.clientForRequest(cfg, originScoped)
} }
// ErrExportTooManyRedirects exposes the sentinel the redirect // ErrExportTooManyRedirects exposes the sentinel the redirect
@@ -272,27 +285,32 @@ func (e *Engine) ExportRecoverPendingDeliveries(
ctx context.Context, ctx context.Context,
webhookDB *gorm.DB, webhookDB *gorm.DB,
webhookID string, webhookID string,
atStart bool,
) { ) {
e.recoverPendingDeliveries( e.recoverPendingDeliveries(
ctx, webhookDB, webhookID, atStart, ctx, webhookDB, webhookID,
) )
} }
// ExportRecoverWebhookDeliveries exposes // ExportRecoverWebhookDeliveries exposes
// recoverWebhookDeliveries. // recoverWebhookDeliveries.
func (e *Engine) ExportRecoverWebhookDeliveries( func (e *Engine) ExportRecoverWebhookDeliveries(
ctx context.Context, webhookID string, atStart bool, ctx context.Context, webhookID string,
) { ) {
e.recoverWebhookDeliveries(ctx, webhookID, atStart) e.recoverWebhookDeliveries(ctx, webhookID)
} }
// ExportRecoverDeliveries exposes recoverDeliveries, the recovery // ExportRecoverInFlight exposes recoverInFlight.
// routine. func (e *Engine) ExportRecoverInFlight(
func (e *Engine) ExportRecoverDeliveries( ctx context.Context,
ctx context.Context, atStart bool,
) { ) {
e.recoverDeliveries(ctx, atStart) e.recoverInFlight(ctx)
}
// ExportSweepWebhookRetries exposes sweepWebhookRetries.
func (e *Engine) ExportSweepWebhookRetries(
ctx context.Context, webhookID string,
) {
e.sweepWebhookRetries(ctx, webhookID)
} }
// ExportStart exposes start for testing. // ExportStart exposes start for testing.
@@ -339,9 +357,9 @@ func (e *Engine) ExportRetainDelivery(deliveryID string) bool {
// ExportRecoverRetryingDeliveries exposes recoverRetryingDeliveries. // ExportRecoverRetryingDeliveries exposes recoverRetryingDeliveries.
func (e *Engine) ExportRecoverRetryingDeliveries( func (e *Engine) ExportRecoverRetryingDeliveries(
ctx context.Context, webhookDB *gorm.DB, webhookID string, webhookDB *gorm.DB, webhookID string,
) { ) {
e.recoverRetryingDeliveries(ctx, webhookDB, webhookID) e.recoverRetryingDeliveries(webhookDB, webhookID)
} }
// ExportFailMissingTarget exposes failMissingTarget, so a test can hand // ExportFailMissingTarget exposes failMissingTarget, so a test can hand
@@ -380,8 +398,7 @@ func (e *Engine) ExportRetryCh() chan Task {
} }
// NewTestEngine creates an Engine for unit tests without // NewTestEngine creates an Engine for unit tests without
// database dependencies. Its archive writers have no data // database dependencies.
// directory, so a delivery to a database target fails.
func NewTestEngine( func NewTestEngine(
log *slog.Logger, log *slog.Logger,
client *http.Client, client *http.Client,
@@ -393,7 +410,6 @@ func NewTestEngine(
retryCh: make(chan Task, retryChannelSize), retryCh: make(chan Task, retryChannelSize),
workers: workers, workers: workers,
mtr: metrics.New(prometheus.NewRegistry()), mtr: metrics.New(prometheus.NewRegistry()),
archives: archive.NewWriters(archive.WritersParams{Logger: log}),
} }
e.initTargets(client) e.initTargets(client)
@@ -409,7 +425,6 @@ func NewTestEngineSmallRetry(
log: log, log: log,
retryCh: make(chan Task, 1), retryCh: make(chan Task, 1),
mtr: metrics.New(prometheus.NewRegistry()), mtr: metrics.New(prometheus.NewRegistry()),
archives: archive.NewWriters(archive.WritersParams{Logger: log}),
} }
e.initTargets(nil) e.initTargets(nil)
@@ -417,8 +432,7 @@ func NewTestEngineSmallRetry(
} }
// NewTestEngineWithDB creates an Engine with a real // NewTestEngineWithDB creates an Engine with a real
// database and dbManager for integration tests, and archive // database and dbManager for integration tests.
// writers of its own over the same two.
func NewTestEngineWithDB( func NewTestEngineWithDB(
db *database.Database, db *database.Database,
dbMgr *database.WebhookDBManager, dbMgr *database.WebhookDBManager,
@@ -434,22 +448,12 @@ func NewTestEngineWithDB(
retryCh: make(chan Task, retryChannelSize), retryCh: make(chan Task, retryChannelSize),
workers: workers, workers: workers,
mtr: metrics.New(prometheus.NewRegistry()), mtr: metrics.New(prometheus.NewRegistry()),
archives: archive.NewWriters(archive.WritersParams{
Database: db,
DBManager: dbMgr,
Logger: log,
}),
} }
e.initTargets(client) e.initTargets(client)
return e return e
} }
// ExportArchives returns the engine's archive writers.
func (e *Engine) ExportArchives() *archive.Writers {
return e.archives
}
// ExportSetMetrics substitutes the engine's metric set, so a test can // ExportSetMetrics substitutes the engine's metric set, so a test can
// assert on collectors registered on a registry it holds. // assert on collectors registered on a registry it holds.
func (e *Engine) ExportSetMetrics(mtr *metrics.Set) { func (e *Engine) ExportSetMetrics(mtr *metrics.Set) {
@@ -472,3 +476,273 @@ func NewTestCircuitBreaker(
cooldown: cooldown, cooldown: cooldown,
} }
} }
// ExportArchivedEvent aliases the archive row type so black-box
// tests can construct and read archive rows.
type ExportArchivedEvent = archivedEvent
// ExportArchiveWriter wraps an archiveWriter so black-box tests
// can exercise the archive file mechanics.
type ExportArchiveWriter struct {
w *archiveWriter
}
// NewExportArchiveWriter builds an archive writer for tests,
// optionally overriding the reopen debounce (a non-positive
// debounce keeps the production default).
func NewExportArchiveWriter(
path string, log *slog.Logger, debounce time.Duration,
) *ExportArchiveWriter {
w := newArchiveWriter(path, log)
if debounce > 0 {
w.debounce = debounce
}
return &ExportArchiveWriter{w: w}
}
// Write archives a row through the writer, into the file named
// without a period.
func (e *ExportArchiveWriter) Write(
row ExportArchivedEvent, expiry time.Duration,
) error {
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.
func (e *ExportArchiveWriter) Open(expiry time.Duration) error {
return e.w.open(e.w.path, expiry)
}
// Reopen closes and reopens the archive file.
func (e *ExportArchiveWriter) Reopen(
expiry time.Duration,
) error {
return e.w.reopen(e.w.path, expiry)
}
// SetNow replaces the clock the writer measures its reopen
// debounce on.
func (e *ExportArchiveWriter) SetNow(now func() time.Time) {
e.w.now = now
}
// Reopens reports how many times the file has been opened.
func (e *ExportArchiveWriter) Reopens() int {
return e.w.reopens
}
// DB returns the writer's current open handle for row
// inspection in tests.
func (e *ExportArchiveWriter) DB() *gorm.DB {
return e.w.db
}
// Path returns the archive file the writer owns.
func (e *ExportArchiveWriter) Path() string {
return e.w.path
}
// OpenExisting opens the archive without permitting creation,
// the way the idle sweep does.
func (e *ExportArchiveWriter) OpenExisting(
expiry time.Duration,
) error {
return e.w.openMode(e.w.path, archiveModeExisting, expiry)
}
// SweepExpired runs an idle sweep of the archive.
func (e *ExportArchiveWriter) SweepExpired(
expiry time.Duration,
) error {
return e.w.sweepExpired(expiry)
}
// Evict marks the writer evicted and closes its handle, exactly
// as leaving the registry does.
func (e *ExportArchiveWriter) Evict() {
e.w.evict()
}
// Rename gives the archive file a new name in the same directory,
// as a rename of the webhook or target does.
func (e *ExportArchiveWriter) Rename(name string) error {
return e.w.rename(name)
}
// HandleOpen reports whether the writer currently holds an open
// archive handle.
func (e *ExportArchiveWriter) HandleOpen() bool {
e.w.mu.Lock()
defer e.w.mu.Unlock()
return e.w.db != nil
}
// Same reports whether both wrappers refer to the very same
// underlying archive writer, so a test can prove a registry entry
// is the writer it was handed rather than a replacement.
func (e *ExportArchiveWriter) Same(
other *ExportArchiveWriter,
) bool {
return other != nil && e.w == other.w
}
// ExportArchiveWriterFor returns the archive writer the registry
// currently caches for a database target, or nil when none is
// cached. It never creates one, so a test can hold a reference to
// the very writer an eviction is about to detach.
func (e *Engine) ExportArchiveWriterFor(
targetID string,
) *ExportArchiveWriter {
e.dbTarget.mu.Lock()
defer e.dbTarget.mu.Unlock()
w, ok := e.dbTarget.writers[targetID]
if !ok {
return nil
}
return &ExportArchiveWriter{w: w}
}
// ExportHasArchiveWriter reports whether the database target
// type currently caches an archive writer for a target.
func (e *Engine) ExportHasArchiveWriter(
targetID string,
) bool {
e.dbTarget.mu.Lock()
defer e.dbTarget.mu.Unlock()
_, ok := e.dbTarget.writers[targetID]
return ok
}
// ExportArchiveHandleOpen reports whether the cached archive
// writer for a target holds an open database handle. It
// returns false when no writer is cached.
func (e *Engine) ExportArchiveHandleOpen(
targetID string,
) bool {
e.dbTarget.mu.Lock()
w, ok := e.dbTarget.writers[targetID]
e.dbTarget.mu.Unlock()
if !ok {
return false
}
w.mu.Lock()
defer w.mu.Unlock()
return w.db != nil
}
// ExportEnsureArchiveWriter creates (if needed) and returns the
// archive file path of the cached writer for a target, so a
// test can prime the registry the way a delivery would.
func (e *Engine) ExportEnsureArchiveWriter(
targetID string,
) (string, error) {
w, err := e.dbTarget.writerFor(targetID)
if err != nil {
return "", err
}
return w.path, nil
}
// ExportSweepWriterFor takes a target's registry writer exactly
// as the idle sweep does, reporting whether the sweep had to
// create the entry. It lets a test drive the registry through the
// sweep's own entry point instead of choreographing goroutines.
func (e *Engine) ExportSweepWriterFor(
targetID string,
) (*ExportArchiveWriter, bool, error) {
w, created, err := e.dbTarget.sweepWriterFor(targetID)
if err != nil {
return nil, false, err
}
return &ExportArchiveWriter{w: w}, created, nil
}
// ExportReleaseSweepWriter releases a sweep-created registry entry
// exactly as a finished sweep does.
func (e *Engine) ExportReleaseSweepWriter(
targetID string, w *ExportArchiveWriter,
) {
e.dbTarget.releaseSweepWriter(targetID, w.w)
}
// NewTestArchiveSweeper builds an ArchiveSweeper backed by the
// given main database and engine, without the fx lifecycle.
// Intended for tests.
func NewTestArchiveSweeper(
db *database.Database,
eng *Engine,
log *slog.Logger,
) *ArchiveSweeper {
return &ArchiveSweeper{
db: db,
eng: eng,
log: log,
interval: time.Hour,
}
}
// ExportSweep runs a single archive sweep synchronously for
// tests.
func (s *ArchiveSweeper) ExportSweep(ctx context.Context) {
s.sweep(ctx)
}
// ExportStart starts the sweeper's background loop for tests.
func (s *ArchiveSweeper) ExportStart() {
s.start()
}
// ExportRegisterHooks registers the sweeper's real fx lifecycle
// hooks on a lifecycle supplied by a test, so a test can drive
// the exact OnStart/OnStop functions the application runs and
// hand OnStart the kind of context fx actually supplies.
func (s *ArchiveSweeper) ExportRegisterHooks(lc fx.Lifecycle) {
s.registerHooks(lc)
}
// ExportStop stops the sweeper's background loop for tests.
func (s *ArchiveSweeper) ExportStop(ctx context.Context) error {
return s.stop(ctx)
}
// ExportWedgeLoop adds a goroutine to the sweeper's WaitGroup
// that never observes cancellation and returns only when release
// is closed. It stands in for a prune stuck on a locked archive.
func (s *ArchiveSweeper) ExportWedgeLoop(
release <-chan struct{},
) {
s.wg.Go(func() {
<-release
})
}
// ExportSetInterval overrides the sweep interval for tests.
func (s *ArchiveSweeper) ExportSetInterval(d time.Duration) {
s.interval = d
}
// ExportParseArchiveExpiry exposes parseArchiveExpiry.
func ExportParseArchiveExpiry(
configJSON string,
) (time.Duration, error) {
return parseArchiveExpiry(configJSON)
}
+9 -9
View File
@@ -6,10 +6,10 @@ import "sync"
// //
// A delivery is owned from the moment a task for it is handed to a // A delivery is owned from the moment a task for it is handed to a
// channel or to a retry timer until the engine has no further plan for // channel or to a retry timer until the engine has no further plan for
// it in memory. The recovery routine, at start and on every tick of the // it in memory. Restart recovery and both arms of the periodic sweep
// sweep, re-dispatches only deliveries the set does not hold, which is // re-dispatch only deliveries the set does not hold, which is what
// what makes it exact rather than a guess about how long a row has sat // makes them exact rather than a guess about how long a row has sat at
// at pending. // pending.
// //
// This replaces reasoning from timestamps. A delivery's row says // This replaces reasoning from timestamps. A delivery's row says
// pending from creation until its outcome is written, which covers // pending from creation until its outcome is written, which covers
@@ -25,8 +25,8 @@ import "sync"
// In-memory state is sufficient because a data directory admits one // In-memory state is sufficient because a data directory admits one
// process: internal/datadir takes an flock on it at startup and a // process: internal/datadir takes an flock on it at startup and a
// second instance refuses to run. Deliveries owned by a process that // second instance refuses to run. Deliveries owned by a process that
// died are not in any successor's set, and the recovery routine's run // died are not in any successor's set, and restart recovery is what
// at start is what picks those up. // picks those up.
// //
// References are counted rather than held as a plain set because // References are counted rather than held as a plain set because
// ownership outlives the worker that took it. A target that schedules // ownership outlives the worker that took it. A target that schedules
@@ -61,9 +61,9 @@ func (s *inflightSet) retain(deliveryID string) {
// retainIdle takes the first reference on a delivery, and reports // retainIdle takes the first reference on a delivery, and reports
// whether it got it. It fails when the engine already owns the // whether it got it. It fails when the engine already owns the
// delivery, which is what makes two claimants — notify and the // delivery, which is what makes two claimants — restart recovery and
// recovery routine, say — mutually exclusive rather than merely // the sweep run concurrently, or two sweep arms — mutually exclusive
// atomic. // rather than merely atomic.
func (s *inflightSet) retainIdle(deliveryID string) bool { func (s *inflightSet) retainIdle(deliveryID string) bool {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
+58 -43
View File
@@ -2,6 +2,8 @@ package delivery_test
import ( import (
"context" "context"
"path/filepath"
"strings"
"sync" "sync"
"testing" "testing"
"time" "time"
@@ -30,7 +32,7 @@ func fSweepSetup(
iCreateTarget(t, s.MainDB, targetID, iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, name, s.WebhookID, name,
database.TargetTypeLog, "", 1, database.TargetTypeLog, "", 0,
) )
require.NoError(t, s.MainDB.Create(&database.Webhook{ require.NoError(t, s.MainDB.Create(&database.Webhook{
@@ -64,6 +66,33 @@ func fDrain(e *delivery.Engine) []delivery.Task {
} }
} }
// TestArchiveHandleIsWAL closes the last gap in the durability
// evidence: the main and per-webhook tiers each assert their journal
// mode on a live handle, and the archive tier gets its settings from
// the same code path but nothing checked the running file.
func TestArchiveHandleIsWAL(t *testing.T) {
t.Parallel()
w := delivery.NewExportArchiveWriter(
filepath.Join(t.TempDir(), "archive-wal.db"),
archiveTestLogger(), 0,
)
require.NoError(t, w.Open(0))
var mode string
row := w.DB().Raw("pragma journal_mode").Row()
require.NoError(t, row.Scan(&mode))
assert.Equal(t, "wal", strings.ToLower(mode))
var busy string
row = w.DB().Raw("pragma busy_timeout").Row()
require.NoError(t, row.Scan(&busy))
assert.Equal(t, "10000", busy)
}
// TestSweepLeavesAQueuedDeliveryAlone is the case the age bound cannot // TestSweepLeavesAQueuedDeliveryAlone is the case the age bound cannot
// see. The delivery is queued and untouched, so its row is arbitrarily // see. The delivery is queued and untouched, so its row is arbitrarily
// old and still perfectly healthy; only ownership distinguishes it // old and still perfectly healthy; only ownership distinguishes it
@@ -84,9 +113,9 @@ func TestSweepLeavesAQueuedDeliveryAlone(t *testing.T) {
) )
rAgePending(t, s.WebhookDB, d.ID) rAgePending(t, s.WebhookDB, d.ID)
// Queued exactly as StoreEvent queues it, and never dequeued: // Queued exactly as the receiver queues it, and never dequeued:
// no workers are running in this engine. // no workers are running in this engine.
s.Engine.ExportNotify([]delivery.Task{{ s.Engine.Notify([]delivery.Task{{
DeliveryID: d.ID, DeliveryID: d.ID,
EventID: event.ID, EventID: event.ID,
WebhookID: s.WebhookID, WebhookID: s.WebhookID,
@@ -95,8 +124,8 @@ func TestSweepLeavesAQueuedDeliveryAlone(t *testing.T) {
require.Equal(t, 1, s.Engine.ExportInflightHeld()) require.Equal(t, 1, s.Engine.ExportInflightHeld())
s.Engine.ExportRecoverWebhookDeliveries( s.Engine.ExportSweepWebhookRetries(
context.Background(), s.WebhookID, onTick, context.Background(), s.WebhookID,
) )
tasks := fDrain(s.Engine) tasks := fDrain(s.Engine)
@@ -107,11 +136,9 @@ func TestSweepLeavesAQueuedDeliveryAlone(t *testing.T) {
) )
} }
// TestRecoveryAndSweepDoNotDoubleDispatch drives the recovery routine's // TestRecoveryAndSweepDoNotDoubleDispatch drives the two entry points
// run at start and a tick of the sweep at once against one aged pending // the engine starts concurrently against one aged pending row. Before
// row. The engine runs them one after the other, but notify can race // ownership they both dispatched it.
// either, and ownership is what keeps any two claimants from both
// sending. Before ownership the two runs both dispatched the row.
func TestRecoveryAndSweepDoNotDoubleDispatch(t *testing.T) { func TestRecoveryAndSweepDoNotDoubleDispatch(t *testing.T) {
t.Parallel() t.Parallel()
@@ -135,12 +162,12 @@ func TestRecoveryAndSweepDoNotDoubleDispatch(t *testing.T) {
wg.Go(func() { wg.Go(func() {
s.Engine.ExportRecoverPendingDeliveries( s.Engine.ExportRecoverPendingDeliveries(
ctx, s.WebhookDB, s.WebhookID, atStart, ctx, s.WebhookDB, s.WebhookID,
) )
}) })
wg.Go(func() { wg.Go(func() {
s.Engine.ExportRecoverWebhookDeliveries( s.Engine.ExportSweepWebhookRetries(
ctx, s.WebhookID, onTick, ctx, s.WebhookID,
) )
}) })
wg.Wait() wg.Wait()
@@ -202,7 +229,7 @@ func TestOwnershipIsReleasedAfterDelivery(t *testing.T) {
iCreateTarget(t, s.MainDB, targetID, iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, "released", s.WebhookID, "released",
database.TargetTypeLog, "", 1, database.TargetTypeLog, "", 0,
) )
event := iSeedEvent( event := iSeedEvent(
@@ -224,7 +251,7 @@ func TestOwnershipIsReleasedAfterDelivery(t *testing.T) {
body := `{"released":true}` body := `{"released":true}`
s.Engine.ExportNotify([]delivery.Task{{ s.Engine.Notify([]delivery.Task{{
DeliveryID: d.ID, DeliveryID: d.ID,
EventID: event.ID, EventID: event.ID,
WebhookID: s.WebhookID, WebhookID: s.WebhookID,
@@ -248,11 +275,10 @@ func TestOwnershipIsReleasedAfterDelivery(t *testing.T) {
} }
// TestNotifyAfterRecoveryDoesNotSendAgain is the startup race of // TestNotifyAfterRecoveryDoesNotSendAgain is the startup race of
// https://git.eeqj.de/sneak/webhooker/issues/299. StoreEvent has // https://git.eeqj.de/sneak/webhooker/issues/299. The receiver has
// written a delivery, the recovery routine's run at start finds it // written a delivery, restart recovery finds it pending, sends it and
// pending, sends it and releases it, and only then does StoreEvent's // releases it, and only then does the receiver's Notify for it arrive.
// notify for it arrive. Nothing owns the delivery by then, so notify // Nothing owns the delivery by then, so Notify takes it.
// takes it.
func TestNotifyAfterRecoveryDoesNotSendAgain(t *testing.T) { func TestNotifyAfterRecoveryDoesNotSendAgain(t *testing.T) {
t.Parallel() t.Parallel()
@@ -276,8 +302,7 @@ func TestNotifyAfterRecoveryDoesNotSendAgain(t *testing.T) {
) )
}() }()
// The recovery routine's run at start sends the delivery and lets // Restart recovery sends the delivery and lets it go.
// it go.
iWaitForDelivered(t, s.WebhookDB, d.ID) iWaitForDelivered(t, s.WebhookDB, d.ID)
require.Eventually( require.Eventually(
t, t,
@@ -289,7 +314,7 @@ func TestNotifyAfterRecoveryDoesNotSendAgain(t *testing.T) {
body := event.Body body := event.Body
s.Engine.ExportNotify([]delivery.Task{{ s.Engine.Notify([]delivery.Task{{
DeliveryID: d.ID, DeliveryID: d.ID,
EventID: event.ID, EventID: event.ID,
WebhookID: s.WebhookID, WebhookID: s.WebhookID,
@@ -300,7 +325,7 @@ func TestNotifyAfterRecoveryDoesNotSendAgain(t *testing.T) {
EntrypointID: event.EntrypointID, EntrypointID: event.EntrypointID,
}}) }})
// notify took the delivery, and a worker releases it once it has // Notify took the delivery, and a worker releases it once it has
// run the task. // run the task.
require.Eventually( require.Eventually(
t, t,
@@ -338,7 +363,7 @@ func TestRetryingRecoverySkipsASuccessfulResult(t *testing.T) {
rSeedResult(t, s.WebhookDB, d.ID, 2, true) rSeedResult(t, s.WebhookDB, d.ID, 2, true)
s.Engine.ExportRecoverRetryingDeliveries( s.Engine.ExportRecoverRetryingDeliveries(
context.Background(), s.WebhookDB, s.WebhookID, s.WebhookDB, s.WebhookID,
) )
assert.Empty( assert.Empty(
@@ -353,8 +378,8 @@ func TestRetryingRecoverySkipsASuccessfulResult(t *testing.T) {
) )
} }
// TestRetryingSweepSkipsASuccessfulResult is the same rule on a tick of // TestRetryingSweepSkipsASuccessfulResult is the same rule on the
// the sweep. // periodic sweep's retrying arm.
func TestRetryingSweepSkipsASuccessfulResult(t *testing.T) { func TestRetryingSweepSkipsASuccessfulResult(t *testing.T) {
t.Parallel() t.Parallel()
@@ -372,8 +397,8 @@ func TestRetryingSweepSkipsASuccessfulResult(t *testing.T) {
rSeedResult(t, s.WebhookDB, d.ID, 1, false) rSeedResult(t, s.WebhookDB, d.ID, 1, false)
rSeedResult(t, s.WebhookDB, d.ID, 2, true) rSeedResult(t, s.WebhookDB, d.ID, 2, true)
s.Engine.ExportRecoverWebhookDeliveries( s.Engine.ExportSweepWebhookRetries(
context.Background(), s.WebhookID, onTick, context.Background(), s.WebhookID,
) )
assert.Empty(t, fDrain(s.Engine)) assert.Empty(t, fDrain(s.Engine))
@@ -424,8 +449,8 @@ func TestScheduledRetryIsNotSweptDuringBackoff(t *testing.T) {
require.Equal(t, 1, s.Engine.ExportInflightHeld()) require.Equal(t, 1, s.Engine.ExportInflightHeld())
s.Engine.ExportRecoverWebhookDeliveries( s.Engine.ExportSweepWebhookRetries(
context.Background(), s.WebhookID, onTick, context.Background(), s.WebhookID,
) )
assert.Empty( assert.Empty(
@@ -437,8 +462,7 @@ func TestScheduledRetryIsNotSweptDuringBackoff(t *testing.T) {
// TestRedispatchStampsTheRow pins the cadence control: a stranded // TestRedispatchStampsTheRow pins the cadence control: a stranded
// delivery that has just been handed out is not selected again by the // delivery that has just been handed out is not selected again by the
// next tick a minute later. The stamp is stored in the same form as // next tick a minute later.
// created_at.
func TestRedispatchStampsTheRow(t *testing.T) { func TestRedispatchStampsTheRow(t *testing.T) {
t.Parallel() t.Parallel()
@@ -457,7 +481,7 @@ func TestRedispatchStampsTheRow(t *testing.T) {
ctx := context.Background() ctx := context.Background()
s.Engine.ExportRecoverWebhookDeliveries(ctx, s.WebhookID, onTick) s.Engine.ExportSweepWebhookRetries(ctx, s.WebhookID)
require.Len(t, fDrain(s.Engine), 1) require.Len(t, fDrain(s.Engine), 1)
var row database.Delivery var row database.Delivery
@@ -469,13 +493,4 @@ func TestRedispatchStampsTheRow(t *testing.T) {
"a re-dispatched delivery must be stamped so the "+ "a re-dispatched delivery must be stamped so the "+
"next tick does not select it again", "next tick does not select it again",
) )
var createdAt, updatedAt string
require.NoError(t, s.WebhookDB.Raw(
`SELECT CAST(created_at AS TEXT), CAST(updated_at AS TEXT)
FROM deliveries WHERE id = ?`, d.ID,
).Row().Scan(&createdAt, &updatedAt))
assertStoredTime(t, createdAt)
assertStoredTime(t, updatedAt)
} }
+20 -17
View File
@@ -192,7 +192,7 @@ func mDeliverOK(t *testing.T, s iSetup) {
body := event.Body body := event.Body
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"metrics-ok", ts.URL, 3, 1, &body, "metrics-ok", iHTTPConfig(ts.URL), 3, 1, &body,
) )
s.Engine.ExportProcessNewTask(context.TODO(), &task) s.Engine.ExportProcessNewTask(context.TODO(), &task)
@@ -226,17 +226,18 @@ func mExhaustRetries(t *testing.T, s iSetup) {
) )
body := event.Body body := event.Body
cfg := iHTTPConfig(ts.URL)
// The retry below is only run if its target still exists; see // The retry below is only run if its target still exists; see
// https://git.eeqj.de/sneak/webhooker/issues/107. // https://git.eeqj.de/sneak/webhooker/issues/107.
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "metrics-fail", t, s.MainDB, targetID, s.WebhookID, "metrics-fail",
database.TargetTypeHTTP, ts.URL, 2, database.TargetTypeHTTP, cfg, 2,
) )
first := iTask( first := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"metrics-fail", ts.URL, 2, 1, &body, "metrics-fail", cfg, 2, 1, &body,
) )
s.Engine.ExportProcessNewTask(context.TODO(), &first) s.Engine.ExportProcessNewTask(context.TODO(), &first)
@@ -250,7 +251,7 @@ func mExhaustRetries(t *testing.T, s iSetup) {
// deterministic and off the wall clock. // deterministic and off the wall clock.
second := iTask( second := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"metrics-fail", ts.URL, 2, 2, &body, "metrics-fail", cfg, 2, 2, &body,
) )
s.Engine.ExportProcessRetryTask( s.Engine.ExportProcessRetryTask(
@@ -288,21 +289,22 @@ func TestDeliveryMetrics_CircuitBreakerGauge(t *testing.T) {
) )
body := event.Body body := event.Body
cfg := iHTTPConfig(ts.URL)
// An attempt budget above the failure threshold, so the breaker // A retry budget above the failure threshold, so the breaker
// rather than the budget is what stops the delivery. // rather than the budget is what stops the delivery.
maxAttempts := delivery.ExportDefaultFailureThreshold + 5 maxRetries := delivery.ExportDefaultFailureThreshold + 5
// The retries below are only run if their target still exists; // The retries below are only run if their target still exists;
// see https://git.eeqj.de/sneak/webhooker/issues/107. // see https://git.eeqj.de/sneak/webhooker/issues/107.
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "metrics-trip", t, s.MainDB, targetID, s.WebhookID, "metrics-trip",
database.TargetTypeHTTP, ts.URL, maxAttempts, database.TargetTypeHTTP, cfg, maxRetries,
) )
first := iTask( first := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"metrics-trip", ts.URL, maxAttempts, 1, &body, "metrics-trip", cfg, maxRetries, 1, &body,
) )
s.Engine.ExportProcessNewTask(context.TODO(), &first) s.Engine.ExportProcessNewTask(context.TODO(), &first)
@@ -314,7 +316,7 @@ func TestDeliveryMetrics_CircuitBreakerGauge(t *testing.T) {
ExportDefaultFailureThreshold; attempt++ { ExportDefaultFailureThreshold; attempt++ {
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"metrics-trip", ts.URL, maxAttempts, attempt, &body, "metrics-trip", cfg, maxRetries, attempt, &body,
) )
s.Engine.ExportProcessRetryTask( s.Engine.ExportProcessRetryTask(
@@ -361,16 +363,17 @@ func TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt(
) )
body := event.Body body := event.Body
maxAttempts := delivery.ExportDefaultFailureThreshold + 5 cfg := iHTTPConfig(ts.URL)
maxRetries := delivery.ExportDefaultFailureThreshold + 5
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "metrics-blocked", t, s.MainDB, targetID, s.WebhookID, "metrics-blocked",
database.TargetTypeHTTP, ts.URL, maxAttempts, database.TargetTypeHTTP, cfg, maxRetries,
) )
first := iTask( first := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"metrics-blocked", ts.URL, maxAttempts, 1, &body, "metrics-blocked", cfg, maxRetries, 1, &body,
) )
s.Engine.ExportProcessNewTask(context.TODO(), &first) s.Engine.ExportProcessNewTask(context.TODO(), &first)
@@ -379,7 +382,7 @@ func TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt(
ExportDefaultFailureThreshold; attempt++ { ExportDefaultFailureThreshold; attempt++ {
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"metrics-blocked", ts.URL, maxAttempts, attempt, &body, "metrics-blocked", cfg, maxRetries, attempt, &body,
) )
s.Engine.ExportProcessRetryTask(context.TODO(), &task) s.Engine.ExportProcessRetryTask(context.TODO(), &task)
@@ -402,7 +405,7 @@ func TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt(
blocked := iTask( blocked := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"metrics-blocked", ts.URL, maxAttempts, "metrics-blocked", cfg, maxRetries,
delivery.ExportDefaultFailureThreshold+1, &body, delivery.ExportDefaultFailureThreshold+1, &body,
) )
@@ -441,8 +444,8 @@ func TestDeliveryMetrics_OrphanedRetryFailureLabelled(
t, s, database.TargetTypeLog, t, s, database.TargetTypeLog,
) )
s.Engine.ExportRecoverWebhookDeliveries( s.Engine.ExportSweepWebhookRetries(
context.Background(), s.WebhookID, onTick, context.Background(), s.WebhookID,
) )
iAssertStatus(t, s.WebhookDB, deliveryID, iAssertStatus(t, s.WebhookDB, deliveryID,
@@ -471,7 +474,7 @@ func TestDeliveryMetrics_QueueDepthGauges(t *testing.T) {
iCreateTarget(t, s.MainDB, targetID, s.WebhookID, iCreateTarget(t, s.MainDB, targetID, s.WebhookID,
"queue-depth-target", database.TargetTypeHTTP, "queue-depth-target", database.TargetTypeHTTP,
"https://example.com/hook", 3, iHTTPConfig("https://example.com/hook"), 3,
) )
event := iSeedEvent( event := iSeedEvent(
+1 -2
View File
@@ -20,8 +20,7 @@ const queueDepthSampleInterval = 30 * time.Second
// to be seeded correctly at startup from rows written by a previous // to be seeded correctly at startup from rows written by a previous
// process, and would drift permanently on any transition that failed // process, and would drift permanently on any transition that failed
// to persist. Counting is the measurement that cannot go wrong, and // to persist. Counting is the measurement that cannot go wrong, and
// it is the same whole-database walk each tick of the sweep already // it is the same whole-database walk the retry sweep already makes.
// makes.
func (e *Engine) queueDepthSampler(ctx context.Context) { func (e *Engine) queueDepthSampler(ctx context.Context) {
defer e.wg.Done() defer e.wg.Done()
@@ -102,7 +102,7 @@ func TestQueueDepthSample_LogsNoBoundValue(t *testing.T) {
iCreateTarget(t, mainDB.DB(), targetID, webhookID, iCreateTarget(t, mainDB.DB(), targetID, webhookID,
"queue-depth-gormlog-target", database.TargetTypeHTTP, "queue-depth-gormlog-target", database.TargetTypeHTTP,
"https://example.com/hook", 3, iHTTPConfig("https://example.com/hook"), 3,
) )
event := iSeedEvent( event := iSeedEvent(
+22 -23
View File
@@ -66,7 +66,7 @@ func TestRecoverySkipsPendingWithSuccessfulResult(
iCreateTarget(t, s.MainDB, targetID, iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, "already-delivered", s.WebhookID, "already-delivered",
database.TargetTypeLog, "", 1, database.TargetTypeLog, "", 0,
) )
event := iSeedEvent( event := iSeedEvent(
@@ -88,7 +88,7 @@ func TestRecoverySkipsPendingWithSuccessfulResult(
) )
s.Engine.ExportRecoverPendingDeliveries( s.Engine.ExportRecoverPendingDeliveries(
context.Background(), s.WebhookDB, s.WebhookID, atStart, context.Background(), s.WebhookDB, s.WebhookID,
) )
select { select {
@@ -129,7 +129,7 @@ func TestRecoveryContinuesTheAttemptNumbering(t *testing.T) {
iCreateTarget(t, s.MainDB, targetID, iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, "numbering", s.WebhookID, "numbering",
database.TargetTypeLog, "", 1, database.TargetTypeLog, "", 0,
) )
event := iSeedEvent( event := iSeedEvent(
@@ -145,7 +145,7 @@ func TestRecoveryContinuesTheAttemptNumbering(t *testing.T) {
rSeedResult(t, s.WebhookDB, d.ID, 2, false) rSeedResult(t, s.WebhookDB, d.ID, 2, false)
s.Engine.ExportRecoverPendingDeliveries( s.Engine.ExportRecoverPendingDeliveries(
context.Background(), s.WebhookDB, s.WebhookID, atStart, context.Background(), s.WebhookDB, s.WebhookID,
) )
select { select {
@@ -183,8 +183,8 @@ func TestSweepRecoversStrandedPending(t *testing.T) {
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
s.Engine.ExportRecoverWebhookDeliveries( s.Engine.ExportSweepWebhookRetries(
context.Background(), s.WebhookID, onTick, context.Background(), s.WebhookID,
) )
select { select {
@@ -230,7 +230,7 @@ func TestSweepClaimsAStrandedDeliveryOnlyOnce(t *testing.T) {
ctx := context.Background() ctx := context.Background()
s.Engine.ExportRecoverWebhookDeliveries(ctx, s.WebhookID, onTick) s.Engine.ExportSweepWebhookRetries(ctx, s.WebhookID)
select { select {
case task := <-s.Engine.ExportDeliveryCh(): case task := <-s.Engine.ExportDeliveryCh():
@@ -246,7 +246,7 @@ func TestSweepClaimsAStrandedDeliveryOnlyOnce(t *testing.T) {
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
s.Engine.ExportRecoverWebhookDeliveries(ctx, s.WebhookID, onTick) s.Engine.ExportSweepWebhookRetries(ctx, s.WebhookID)
select { select {
case task := <-s.Engine.ExportDeliveryCh(): case task := <-s.Engine.ExportDeliveryCh():
@@ -258,9 +258,9 @@ func TestSweepClaimsAStrandedDeliveryOnlyOnce(t *testing.T) {
} }
} }
// TestSweepSettlesStrandedPendingWithoutResending is the reconcile on a // TestSweepSettlesStrandedPendingWithoutResending is the sweep's own
// tick of the sweep: a stranded delivery holding a successful result is // version of the reconcile: a stranded delivery holding a successful
// settled where it stands, and the receiver hears nothing. // result is settled where it stands, and the receiver hears nothing.
func TestSweepSettlesStrandedPendingWithoutResending( func TestSweepSettlesStrandedPendingWithoutResending(
t *testing.T, t *testing.T,
) { ) {
@@ -280,8 +280,8 @@ func TestSweepSettlesStrandedPendingWithoutResending(
rSeedResult(t, s.WebhookDB, d.ID, 1, true) rSeedResult(t, s.WebhookDB, d.ID, 1, true)
rAgePending(t, s.WebhookDB, d.ID) rAgePending(t, s.WebhookDB, d.ID)
s.Engine.ExportRecoverWebhookDeliveries( s.Engine.ExportSweepWebhookRetries(
context.Background(), s.WebhookID, onTick, context.Background(), s.WebhookID,
) )
select { select {
@@ -312,9 +312,8 @@ func TestSweepSettlesStrandedPendingWithoutResending(
// TestFailedResultWriteLeavesDeliveryRecoverable is the rule the // TestFailedResultWriteLeavesDeliveryRecoverable is the rule the
// targets now follow: a bookkeeping write that fails must not advance // targets now follow: a bookkeeping write that fails must not advance
// the status, because pending and retrying are the states the recovery // the status, because pending and retrying are the states the sweeps
// routine recovers and delivered is a claim the database refused to // recover and delivered is a claim the database refused to record.
// record.
func TestFailedResultWriteLeavesDeliveryRecoverable( func TestFailedResultWriteLeavesDeliveryRecoverable(
t *testing.T, t *testing.T,
) { ) {
@@ -357,15 +356,14 @@ func TestFailedResultWriteLeavesDeliveryRecoverable(
Target: database.Target{ Target: database.Target{
Name: "unwritable", Name: "unwritable",
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
URL: ts.URL, Config: iHTTPConfig(ts.URL),
MaxAttempts: 1,
}, },
} }
full.ID = d.ID full.ID = d.ID
s.Engine.ExportDeliverHTTP( s.Engine.ExportDeliverHTTP(
context.Background(), s.WebhookDB, full, context.Background(), s.WebhookDB, full,
&delivery.Task{DeliveryID: d.ID, MaxAttempts: 1, AttemptNum: 1}, &delivery.Task{DeliveryID: d.ID, AttemptNum: 1},
) )
assert.Equal( assert.Equal(
@@ -448,8 +446,8 @@ func TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable(
Target: database.Target{ Target: database.Target{
Name: "unwritable", Name: "unwritable",
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
URL: ts.URL, Config: iHTTPConfig(ts.URL),
MaxAttempts: 3, MaxRetries: 3,
}, },
} }
full.ID = d.ID full.ID = d.ID
@@ -459,8 +457,9 @@ func TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable(
s.Engine.ExportDeliverHTTPWithScheduler( s.Engine.ExportDeliverHTTPWithScheduler(
context.Background(), s.WebhookDB, full, context.Background(), s.WebhookDB, full,
&delivery.Task{ &delivery.Task{
DeliveryID: d.ID, TargetID: targetID, DeliveryID: d.ID,
MaxAttempts: 3, AttemptNum: 1, TargetID: targetID,
AttemptNum: 1,
}, },
sched, sched,
) )
-389
View File
@@ -1,389 +0,0 @@
package delivery_test
import (
"context"
"log/slog"
"net/http"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/database/databasetest"
"sneak.berlin/go/webhooker/internal/delivery"
)
// These tests cover the recovery routine, which the engine runs once
// when it starts and then on every tick of the sweep
// (https://git.eeqj.de/sneak/webhooker/issues/542). Every case runs it
// both ways, each on a webhook of its own.
// atStart and onTick are the two values of the recovery routine's
// atStart parameter: its run when the engine starts, and a tick of the
// sweep.
const (
atStart = true
onTick = false
)
// recoveryRun names one of the two runs of the recovery routine.
type recoveryRun struct {
name string
atStart bool
}
// recoveryRuns returns the routine's run at start and a tick.
func recoveryRuns() []recoveryRun {
return []recoveryRun{
{"at start", atStart},
{"on a tick", onTick},
}
}
// recoverySetup gives a fresh webhook its main-database row and an http
// target with retries, and returns the setup and the target's ID. No
// worker runs, so whatever the routine queues stays in the channels for
// the test to read, and nothing is sent to the target.
func recoverySetup(t *testing.T) (iSetup, string) {
t.Helper()
s := newISetup(t)
targetID := uuid.New().String()
iCreateWebhook(t, s.MainDB, s.WebhookID, "recovery")
iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "recovery",
database.TargetTypeHTTP,
"http://example.com/hook", 20,
)
return s, targetID
}
// seedRecoveryDelivery seeds a delivery at status with the given
// recorded attempts, failed but for the last when lastSucceeded is set,
// all recorded now.
func seedRecoveryDelivery(
t *testing.T,
s iSetup,
targetID string,
status database.DeliveryStatus,
attempts int,
lastSucceeded bool,
) string {
t.Helper()
event := iSeedEvent(t, s.WebhookDB, s.WebhookID, `{"recovery":true}`)
d := iSeedDelivery(t, s.WebhookDB, event.ID, targetID, status)
for n := 1; n <= attempts; n++ {
rSeedResult(
t, s.WebhookDB, d.ID, n, lastSucceeded && n == attempts,
)
}
return d.ID
}
// backdateAttempts moves every recorded attempt of a delivery an hour
// into the past, long past the backoff after its first attempt.
func backdateAttempts(t *testing.T, s iSetup, deliveryID string) {
t.Helper()
require.NoError(t, s.WebhookDB.Model(&database.DeliveryResult{}).
Where("delivery_id = ?", deliveryID).
UpdateColumn("created_at", s.WebhookDB.NowFunc().Add(-time.Hour)).
Error)
}
// requireQueued waits for a task on ch. A retrying delivery reaches the
// retry channel through a timer, even one due now, so it is waited for.
func requireQueued(
t *testing.T, ch <-chan delivery.Task,
) delivery.Task {
t.Helper()
select {
case task := <-ch:
return task
case <-time.After(5 * time.Second):
t.Fatal("the recovery routine queued nothing")
return delivery.Task{}
}
}
// assertNothingQueued asserts the engine queues nothing, giving a retry
// timer that is due now the moment it needs to fire.
func assertNothingQueued(t *testing.T, e *delivery.Engine) {
t.Helper()
select {
case task := <-e.ExportDeliveryCh():
t.Errorf("queued pending delivery %s", task.DeliveryID)
case task := <-e.ExportRetryCh():
t.Errorf("queued retrying delivery %s", task.DeliveryID)
case <-time.After(200 * time.Millisecond):
}
}
// TestRecovery_RetryingBackoffPassed: a retrying delivery whose backoff
// is over is queued for its next attempt at once.
func TestRecovery_RetryingBackoffPassed(t *testing.T) {
t.Parallel()
for _, run := range recoveryRuns() {
t.Run(run.name, func(t *testing.T) {
t.Parallel()
s, targetID := recoverySetup(t)
id := seedRecoveryDelivery(
t, s, targetID, database.DeliveryStatusRetrying, 1, false,
)
backdateAttempts(t, s, id)
s.Engine.ExportRecoverDeliveries(context.Background(), run.atStart)
task := requireQueued(t, s.Engine.ExportRetryCh())
assert.Equal(t, id, task.DeliveryID)
assert.Equal(t, 2, task.AttemptNum)
iAssertStatus(t, s.WebhookDB, id, database.DeliveryStatusRetrying)
})
}
}
// TestRecovery_RetryingBackoffNotPassed: a retrying delivery whose last
// attempt was just now waits out the rest of its backoff, ten attempts
// in, eight and a half minutes. It is not sent now, and the engine holds
// it, so a later tick leaves it to the timer.
func TestRecovery_RetryingBackoffNotPassed(t *testing.T) {
t.Parallel()
for _, run := range recoveryRuns() {
t.Run(run.name, func(t *testing.T) {
t.Parallel()
s, targetID := recoverySetup(t)
id := seedRecoveryDelivery(
t, s, targetID, database.DeliveryStatusRetrying, 10, false,
)
s.Engine.ExportRecoverDeliveries(context.Background(), run.atStart)
assertNothingQueued(t, s.Engine)
iAssertStatus(t, s.WebhookDB, id, database.DeliveryStatusRetrying)
assert.Equal(t, 1, s.Engine.ExportInflightHeld(),
"no retry timer holds the delivery",
)
})
}
}
// TestRecovery_PendingOlderThanTheAgeBound: a pending delivery untouched
// for longer than the sweep's age bound is sent by either run.
func TestRecovery_PendingOlderThanTheAgeBound(t *testing.T) {
t.Parallel()
for _, run := range recoveryRuns() {
t.Run(run.name, func(t *testing.T) {
t.Parallel()
s, targetID := recoverySetup(t)
id := seedRecoveryDelivery(
t, s, targetID, database.DeliveryStatusPending, 0, false,
)
rAgePending(t, s.WebhookDB, id)
s.Engine.ExportRecoverDeliveries(context.Background(), run.atStart)
task := requireQueued(t, s.Engine.ExportDeliveryCh())
assert.Equal(t, id, task.DeliveryID)
assert.Equal(t, 1, task.AttemptNum)
})
}
}
// TestRecovery_TaskCarriesEveryTargetSetting: the task the recovery
// routine queues carries every one of the target's settings, all six
// set here.
// They are copied into the task one field at a time, and a field left
// out is sent empty without any error.
func TestRecovery_TaskCarriesEveryTargetSetting(t *testing.T) {
t.Parallel()
for _, run := range recoveryRuns() {
t.Run(run.name, func(t *testing.T) {
t.Parallel()
s := newISetup(t)
iCreateWebhook(t, s.MainDB, s.WebhookID, "recovery")
target := database.Target{
WebhookID: s.WebhookID,
Name: "recovery",
Type: database.TargetTypeHTTP,
Active: true,
URL: "http://example.com/hook",
Headers: map[string]string{headerAuthorization: "Bearer t"},
TimeoutSeconds: 7,
ForwardQuery: true,
ArchiveExpiry: time.Hour,
ArchiveRotation: "daily",
MaxAttempts: 1,
}
require.NoError(t, s.MainDB.Create(&target).Error)
id := seedRecoveryDelivery(
t, s, target.ID, database.DeliveryStatusPending, 0, false,
)
rAgePending(t, s.WebhookDB, id)
s.Engine.ExportRecoverDeliveries(context.Background(), run.atStart)
task := requireQueued(t, s.Engine.ExportDeliveryCh())
assert.Equal(t, target.ID, task.TargetID)
assert.Equal(t, target.URL, task.TargetURL)
assert.Equal(t, target.Headers, task.TargetHeaders)
assert.Equal(t, target.TimeoutSeconds, task.TimeoutSeconds)
assert.Equal(t, target.ForwardQuery, task.ForwardQuery)
assert.Equal(t, target.ArchiveExpiry, task.ArchiveExpiry)
assert.Equal(t, target.ArchiveRotation, task.ArchiveRotation)
})
}
}
// TestRecovery_PendingYoungerThanTheAgeBound: a pending delivery touched
// a moment ago, as one a stop cut off is, is sent at start, which is how
// it reaches its target on the next start
// (https://git.eeqj.de/sneak/webhooker/issues/527). A tick leaves it.
func TestRecovery_PendingYoungerThanTheAgeBound(t *testing.T) {
t.Parallel()
for _, run := range recoveryRuns() {
t.Run(run.name, func(t *testing.T) {
t.Parallel()
s, targetID := recoverySetup(t)
id := seedRecoveryDelivery(
t, s, targetID, database.DeliveryStatusPending, 0, false,
)
s.Engine.ExportRecoverDeliveries(context.Background(), run.atStart)
if run.atStart {
task := requireQueued(t, s.Engine.ExportDeliveryCh())
assert.Equal(t, id, task.DeliveryID)
return
}
assertNothingQueued(t, s.Engine)
iAssertStatus(t, s.WebhookDB, id, database.DeliveryStatusPending)
assert.Zero(t, s.Engine.ExportInflightHeld())
})
}
}
// TestRecovery_AlreadySucceeded: a pending or retrying delivery whose
// success is recorded but whose status write was lost is marked
// delivered where it stands, and not sent again.
func TestRecovery_AlreadySucceeded(t *testing.T) {
t.Parallel()
for _, run := range recoveryRuns() {
t.Run(run.name, func(t *testing.T) {
t.Parallel()
s, targetID := recoverySetup(t)
pending := seedRecoveryDelivery(
t, s, targetID, database.DeliveryStatusPending, 1, true,
)
rAgePending(t, s.WebhookDB, pending)
retrying := seedRecoveryDelivery(
t, s, targetID, database.DeliveryStatusRetrying, 2, true,
)
backdateAttempts(t, s, retrying)
s.Engine.ExportRecoverDeliveries(context.Background(), run.atStart)
assertNothingQueued(t, s.Engine)
for id, attempts := range map[string]int{pending: 1, retrying: 2} {
iAssertStatus(
t, s.WebhookDB, id, database.DeliveryStatusDelivered,
)
assert.Len(t, iResults(t, s.WebhookDB, id), attempts,
"marking the delivery delivered recorded an attempt",
)
}
})
}
}
// TestRecovery_MissingDatabase: a tick leaves a webhook whose database
// file is missing alone, and the run at start opens it, which creates an
// empty one and reports the loss (see
// TestRecoverDeliveries_ReportsAMissingWebhookDatabase).
func TestRecovery_MissingDatabase(t *testing.T) {
t.Parallel()
mainDB := iMainDB(t)
webhookID := uuid.New().String()
iCreateWebhook(t, mainDB.DB(), webhookID, "missing-database")
dbMgr := databasetest.NewWebhookDBManager(t, t.TempDir())
t.Cleanup(func() { _ = dbMgr.CloseAll() })
engine := delivery.NewTestEngineWithDB(
mainDB, dbMgr,
slog.New(slog.DiscardHandler),
&http.Client{Timeout: 5 * time.Second}, 1,
)
engine.ExportRecoverDeliveries(context.Background(), onTick)
assert.False(t, dbMgr.DBExists(webhookID),
"a tick created the missing database",
)
engine.ExportRecoverDeliveries(context.Background(), atStart)
assert.True(t, dbMgr.DBExists(webhookID),
"the run at start did not open the missing database",
)
}
// TestRecovery_LeavesADeliveryTheEngineHolds: a delivery the engine is
// working on, has queued, or holds a retry timer for is left alone by
// either run, pending and retrying alike, however old.
func TestRecovery_LeavesADeliveryTheEngineHolds(t *testing.T) {
t.Parallel()
for _, run := range recoveryRuns() {
t.Run(run.name, func(t *testing.T) {
t.Parallel()
s, targetID := recoverySetup(t)
pending := seedRecoveryDelivery(
t, s, targetID, database.DeliveryStatusPending, 0, false,
)
rAgePending(t, s.WebhookDB, pending)
retrying := seedRecoveryDelivery(
t, s, targetID, database.DeliveryStatusRetrying, 1, false,
)
backdateAttempts(t, s, retrying)
require.True(t, s.Engine.ExportRetainDelivery(pending))
require.True(t, s.Engine.ExportRetainDelivery(retrying))
s.Engine.ExportRecoverDeliveries(context.Background(), run.atStart)
assertNothingQueued(t, s.Engine)
iAssertStatus(t, s.WebhookDB, pending, database.DeliveryStatusPending)
iAssertStatus(t, s.WebhookDB, retrying, database.DeliveryStatusRetrying)
assert.Equal(t, 2, s.Engine.ExportInflightHeld())
})
}
}
+15 -7
View File
@@ -85,14 +85,19 @@ func deliverWithProbeHeaders(
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
cfg, err := json.Marshal(delivery.HTTPTargetConfig{
URL: targetURL,
Headers: map[string]string{
probeHeaderName: probeHeaderValue,
},
})
require.NoError(t, err)
body := event.Body body := event.Body
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"redirect-target", targetURL, 1, 1, &body, "redirect-target", string(cfg), 0, 1, &body,
) )
task.TargetHeaders = map[string]string{
probeHeaderName: probeHeaderValue,
}
s.Engine.ExportProcessNewTask(context.TODO(), &task) s.Engine.ExportProcessNewTask(context.TODO(), &task)
@@ -304,7 +309,8 @@ func TestRedirectPolicy_StopsAtHopCap(t *testing.T) {
) )
client := engine.ExportClientForRequest( client := engine.ExportClientForRequest(
0, []string{probeHeaderName}, &delivery.HTTPTargetConfig{URL: srv.URL},
[]string{probeHeaderName},
) )
require.NotNil(t, client.CheckRedirect) require.NotNil(t, client.CheckRedirect)
@@ -344,7 +350,7 @@ func TestApplyRequestHeaders_ReportsOriginScopedNames(t *testing.T) {
inbound, err := json.Marshal(map[string][]string{ inbound, err := json.Marshal(map[string][]string{
inboundHeaderName: {inboundHeaderValue}, inboundHeaderName: {inboundHeaderValue},
"Content-Type": {testContentType}, "Content-Type": {testContentType},
headerUserAgent: {"curl/8.7.1"}, "User-Agent": {"curl/8.7.1"},
"Host": {"inbound.example.com"}, "Host": {"inbound.example.com"},
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -363,10 +369,12 @@ func TestApplyRequestHeaders_ReportsOriginScopedNames(t *testing.T) {
Headers: string(inbound), Headers: string(inbound),
ContentType: testContentType, ContentType: testContentType,
}, },
map[string]string{ &delivery.HTTPTargetConfig{
Headers: map[string]string{
probeHeaderName: probeHeaderValue, probeHeaderName: probeHeaderValue,
"Content-Type": testContentType, "Content-Type": testContentType,
}, },
},
"webhooker/dev", "webhooker/dev",
) )
-164
View File
@@ -1,164 +0,0 @@
package delivery
import (
"fmt"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
// StoreEvent stores event with one pending delivery for each of its
// webhook's active targets, and adds them to the totals, in one
// transaction, then queues the deliveries. It returns how many
// deliveries it created.
//
// The receiver stores every event it accepts through here, and a
// resubmit stores its copy of a stored event, so every event is
// stored, retried, SSRF-guarded and circuit-broken the same way however
// it entered the system. CreateReplay is the only other place a
// delivery is created.
//
// The targets are the webhook's active ones as they stand now: a
// target created after a resubmitted event first arrived is included,
// and an inactive one is skipped. An event with no active target is
// stored with no deliveries.
func (e *Engine) StoreEvent(event *database.Event) (int, error) {
var targets []database.Target
err := e.database.DB().Where(
"webhook_id = ? AND active = ?", event.WebhookID, true,
).Find(&targets).Error
if err != nil {
return 0, fmt.Errorf("loading active targets: %w", err)
}
webhookDB, err := e.dbManager.GetDB(event.WebhookID)
if err != nil {
return 0, fmt.Errorf("getting webhook database: %w", err)
}
event.BodyBytes = int64(len(event.Body))
tasks := make([]Task, 0, len(targets))
err = webhookDB.Transaction(func(tx *gorm.DB) error {
err := tx.Create(event).Error
if err != nil {
return fmt.Errorf("creating event: %w", err)
}
for i := range targets {
dlv, err := createDelivery(tx, event.ID, targets[i].ID, false)
if err != nil {
return err
}
tasks = append(tasks, buildTask(
dlv, event.WebhookID, event, &targets[i], 1,
))
}
err = database.AddEventTotals(tx, database.EventTotals{
Events: 1, LastEventAt: &event.CreatedAt,
})
if err != nil {
return err
}
// A resubmitted copy did not arrive on its entrypoint's URL, so
// it leaves the entrypoint's last event as it is.
if event.ResubmittedFromID != nil {
return nil
}
return database.AddEntrypointTotals(tx, database.EntrypointTotals{
EntrypointID: event.EntrypointID, LastEventAt: event.CreatedAt,
})
})
if err != nil {
return 0, err
}
// Counted here, after the commit: an event exists once it is
// durably stored, which is what the delivery counters are compared
// against on a dashboard. A resubmitted event counts too, because
// it produces deliveries that the delivery side counts; the
// resubmit counter is what separates the two.
e.mtr.EventReceived()
e.notify(tasks)
return len(tasks), nil
}
// CreateReplay creates one more pending delivery of the stored event
// eventID to target, marked as a replay, adds it to the target's totals
// in the same transaction, and queues it. It returns the new
// delivery's id.
//
// The caller passes the target as it stands now and has already
// decided the replay may go ahead. The delivery being replayed is not
// touched.
func (e *Engine) CreateReplay(
webhookID, eventID string, target *database.Target,
) (string, error) {
webhookDB, err := e.dbManager.GetDB(webhookID)
if err != nil {
return "", fmt.Errorf("getting webhook database: %w", err)
}
event, err := e.loadEvent(webhookDB, eventID)
if err != nil {
return "", err
}
var dlv *database.Delivery
err = webhookDB.Transaction(func(tx *gorm.DB) error {
var err error
dlv, err = createDelivery(tx, event.ID, target.ID, true)
return err
})
if err != nil {
return "", err
}
e.notify([]Task{buildTask(dlv, webhookID, &event, target, 1)})
return dlv.ID, nil
}
// createDelivery writes a pending delivery of the event to the target
// on the caller's transaction, and adds it to the target's totals.
//
// Neither Event nor Target is set on the row, and the per-webhook
// database omits associations on every write in any case (see
// internal/database/event_db_isolation.go), so the target's settings
// are never copied into the event database.
func createDelivery(
tx *gorm.DB, eventID, targetID string, replay bool,
) (*database.Delivery, error) {
dlv := &database.Delivery{
EventID: eventID,
TargetID: targetID,
Status: database.DeliveryStatusPending,
Replay: replay,
}
err := tx.Create(dlv).Error
if err != nil {
return nil, fmt.Errorf(
"creating delivery for target %s: %w", targetID, err,
)
}
err = database.AddTargetTotals(tx, database.TargetTotals{
TargetID: targetID, Deliveries: 1,
})
if err != nil {
return nil, err
}
return dlv, nil
}
-386
View File
@@ -1,386 +0,0 @@
package delivery_test
import (
"net/http"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
// storeTestTarget creates an active http target of the setup's webhook
// with every one of a target's settings set, so a task that drops one
// of them differs from the target.
func storeTestTarget(
t *testing.T, s iSetup, name string,
) database.Target {
t.Helper()
target := database.Target{
WebhookID: s.WebhookID,
Name: name,
Type: database.TargetTypeHTTP,
Active: true,
URL: "http://93.184.216.34/" + name,
Headers: map[string]string{"X-Api-Key": "k"},
TimeoutSeconds: 7,
ForwardQuery: true,
ArchiveExpiry: time.Hour,
ArchiveRotation: rotationDaily,
MaxAttempts: 3,
}
require.NoError(t, s.MainDB.Create(&target).Error)
return target
}
// newStoreTestEvent returns an event of the setup's webhook as the
// receiver builds one, not yet stored.
func newStoreTestEvent(s iSetup, body string) *database.Event {
return &database.Event{
WebhookID: s.WebhookID,
EntrypointID: uuid.New().String(),
Method: http.MethodPost,
RawQuery: "a=1&b=2",
Headers: `{"X-Test":["yes"]}`,
ContentType: testContentType,
Body: body,
}
}
// queuedTasks takes every task the engine has queued so far. The
// engine in these tests is never started, so nothing else takes them.
func queuedTasks(e *delivery.Engine) []delivery.Task {
var tasks []delivery.Task
for {
select {
case task := <-e.ExportDeliveryCh():
tasks = append(tasks, task)
default:
return tasks
}
}
}
// assertTaskFor proves task carries delivery deliveryID of event to
// target: every one of the target's settings and every event field.
// They are copied into the task one field at a time, and a field left
// out is sent empty without any error.
func assertTaskFor(
t *testing.T,
task delivery.Task,
deliveryID string,
event *database.Event,
target *database.Target,
) {
t.Helper()
assert.Equal(t, deliveryID, task.DeliveryID)
assert.Equal(t, event.ID, task.EventID)
assert.Equal(t, event.WebhookID, task.WebhookID)
assert.Equal(t, event.EntrypointID, task.EntrypointID)
assert.Equal(t, target.ID, task.TargetID)
assert.Equal(t, target.Name, task.TargetName)
assert.Equal(t, target.Type, task.TargetType)
assert.Equal(t, target.URL, task.TargetURL)
assert.Equal(t, target.Headers, task.TargetHeaders)
assert.Equal(t, target.TimeoutSeconds, task.TimeoutSeconds)
assert.Equal(t, target.ForwardQuery, task.ForwardQuery)
assert.Equal(t, target.ArchiveExpiry, task.ArchiveExpiry)
assert.Equal(t, target.ArchiveRotation, task.ArchiveRotation)
assert.Equal(t, target.MaxAttempts, task.MaxAttempts)
assert.Equal(t, event.Method, task.Method)
assert.Equal(t, event.RawQuery, task.RawQuery)
assert.Equal(t, event.Headers, task.Headers)
assert.Equal(t, event.ContentType, task.ContentType)
require.NotNil(t, task.Body)
assert.Equal(t, event.Body, *task.Body)
assert.Equal(t, 1, task.AttemptNum)
}
// storedDeliveries reads every delivery of an event.
func storedDeliveries(
t *testing.T, db *gorm.DB, eventID string,
) []database.Delivery {
t.Helper()
var deliveries []database.Delivery
require.NoError(t, db.Where("event_id = ?", eventID).
Find(&deliveries).Error)
return deliveries
}
// storedEventTotals reads the webhook's event totals.
func storedEventTotals(t *testing.T, db *gorm.DB) database.EventTotals {
t.Helper()
var totals database.EventTotals
require.NoError(t, db.First(&totals).Error)
return totals
}
// countRows counts the rows of model in db.
func countRows(t *testing.T, db *gorm.DB, model any) int64 {
t.Helper()
var n int64
require.NoError(t, db.Model(model).Count(&n).Error)
return n
}
// TestStoreEvent_StoresDeliveriesToActiveTargets proves StoreEvent
// stores the event with one pending delivery to each active target and
// none to an inactive one, adds them to the totals, and queues one task
// per delivery.
func TestStoreEvent_StoresDeliveriesToActiveTargets(t *testing.T) {
t.Parallel()
s := newISetup(t)
targets := []database.Target{
storeTestTarget(t, s, "first"),
storeTestTarget(t, s, "second"),
}
off := storeTestTarget(t, s, "off")
require.NoError(t, s.MainDB.Model(&database.Target{}).
Where("id = ?", off.ID).Update("active", false).Error)
event := newStoreTestEvent(s, `{"hello":"world"}`)
created, err := s.Engine.StoreEvent(event)
require.NoError(t, err)
assert.Equal(t, 2, created)
var stored database.Event
require.NoError(t, s.WebhookDB.First(&stored, "id = ?", event.ID).Error)
assert.Equal(t, event.Body, stored.Body)
assert.Equal(t, int64(len(event.Body)), stored.BodyBytes)
rows := storedDeliveries(t, s.WebhookDB, event.ID)
queued := queuedTasks(s.Engine)
require.Len(t, rows, 2)
require.Len(t, queued, 2)
deliveries := map[string]database.Delivery{}
for _, dlv := range rows {
deliveries[dlv.TargetID] = dlv
}
tasks := map[string]delivery.Task{}
for _, task := range queued {
tasks[task.TargetID] = task
}
for i := range targets {
dlv, ok := deliveries[targets[i].ID]
require.True(t, ok, "no delivery to %s", targets[i].Name)
assert.Equal(t, database.DeliveryStatusPending, dlv.Status)
assert.False(t, dlv.Replay)
assert.Equal(t, int64(1),
targetTotals(t, s.WebhookDB, targets[i].ID).Deliveries)
assertTaskFor(t, tasks[targets[i].ID], dlv.ID, event, &targets[i])
}
totals := storedEventTotals(t, s.WebhookDB)
assert.Equal(t, int64(1), totals.Events)
require.NotNil(t, totals.LastEventAt)
assert.True(t, totals.LastEventAt.Equal(stored.CreatedAt))
var entrypoint database.EntrypointTotals
require.NoError(t, s.WebhookDB.First(
&entrypoint, "entrypoint_id = ?", event.EntrypointID,
).Error)
assert.True(t, entrypoint.LastEventAt.Equal(stored.CreatedAt))
}
// TestStoreEvent_ResubmittedCopyLeavesTheEntrypoint proves a
// resubmitted copy is counted among the webhook's events but leaves its
// entrypoint's last event as it is: it did not arrive on that URL.
func TestStoreEvent_ResubmittedCopyLeavesTheEntrypoint(t *testing.T) {
t.Parallel()
s := newISetup(t)
storeTestTarget(t, s, "only")
sourceID := uuid.New().String()
event := newStoreTestEvent(s, `{"copy":true}`)
event.ResubmittedFromID = &sourceID
created, err := s.Engine.StoreEvent(event)
require.NoError(t, err)
assert.Equal(t, 1, created)
assert.Len(t, queuedTasks(s.Engine), 1)
assert.Equal(t, int64(1), storedEventTotals(t, s.WebhookDB).Events)
assert.Zero(t, countRows(t, s.WebhookDB, &database.EntrypointTotals{}))
}
// TestStoreEvent_NoActiveTarget proves an event of a webhook with no
// active target is stored with no delivery and nothing queued.
func TestStoreEvent_NoActiveTarget(t *testing.T) {
t.Parallel()
s := newISetup(t)
event := newStoreTestEvent(s, `{"to":"nobody"}`)
created, err := s.Engine.StoreEvent(event)
require.NoError(t, err)
assert.Zero(t, created)
assert.Equal(t, int64(1), countRows(t, s.WebhookDB, &database.Event{}))
assert.Empty(t, storedDeliveries(t, s.WebhookDB, event.ID))
assert.Empty(t, queuedTasks(s.Engine))
}
// TestStoreEvent_LargeBodyIsNotCarried proves a body at the inline
// limit is stored whole but left off the task, for the attempt to read
// from the event row.
func TestStoreEvent_LargeBodyIsNotCarried(t *testing.T) {
t.Parallel()
s := newISetup(t)
storeTestTarget(t, s, "only")
event := newStoreTestEvent(
s, strings.Repeat("x", delivery.MaxInlineBodySize),
)
_, err := s.Engine.StoreEvent(event)
require.NoError(t, err)
tasks := queuedTasks(s.Engine)
require.Len(t, tasks, 1)
assert.Nil(t, tasks[0].Body)
var stored database.Event
require.NoError(t, s.WebhookDB.First(&stored, "id = ?", event.ID).Error)
assert.Equal(t, event.Body, stored.Body)
}
// TestStoreEvent_RollsBackWhenAWriteFails proves the event, its
// deliveries and its totals are one transaction: when the entrypoint
// totals write fails, the last one, after the event, its delivery, the
// target totals and the event totals were written, none of them is left
// and nothing is queued.
func TestStoreEvent_RollsBackWhenAWriteFails(t *testing.T) {
t.Parallel()
s := newISetup(t)
storeTestTarget(t, s, "only")
require.NoError(t,
s.WebhookDB.Migrator().DropTable(&database.EntrypointTotals{}))
_, err := s.Engine.StoreEvent(newStoreTestEvent(s, `{"lost":true}`))
require.Error(t, err)
assert.Zero(t, countRows(t, s.WebhookDB, &database.Event{}))
assert.Zero(t, countRows(t, s.WebhookDB, &database.Delivery{}))
assert.Zero(t, countRows(t, s.WebhookDB, &database.TargetTotals{}))
totals := storedEventTotals(t, s.WebhookDB)
assert.Zero(t, totals.Events)
assert.Nil(t, totals.LastEventAt)
assert.Empty(t, queuedTasks(s.Engine))
assert.Zero(t, s.Engine.ExportInflightHeld())
}
// TestCreateReplay_CreatesAndQueuesAReplay proves CreateReplay adds a
// new pending delivery marked as a replay, counts it in the target's
// totals, queues its task built from the target it was given and the
// stored event, and leaves the delivery it repeats as it was. The
// stored event has every event field set, so a task that drops one of
// them differs from the event.
func TestCreateReplay_CreatesAndQueuesAReplay(t *testing.T) {
t.Parallel()
s := newISetup(t)
target := storeTestTarget(t, s, "replayed")
event := newStoreTestEvent(s, `{"replay":"me"}`)
require.NoError(t, s.WebhookDB.Create(event).Error)
original := iSeedDelivery(
t, s.WebhookDB, event.ID, target.ID,
database.DeliveryStatusFailed,
)
deliveryID, err := s.Engine.CreateReplay(s.WebhookID, event.ID, &target)
require.NoError(t, err)
replay := storedDelivery(t, s.WebhookDB, deliveryID)
assert.Equal(t, event.ID, replay.EventID)
assert.Equal(t, target.ID, replay.TargetID)
assert.Equal(t, database.DeliveryStatusPending, replay.Status)
assert.True(t, replay.Replay)
assert.Equal(t, database.DeliveryStatusFailed,
storedDelivery(t, s.WebhookDB, original.ID).Status)
assert.Equal(t, int64(1),
targetTotals(t, s.WebhookDB, target.ID).Deliveries)
tasks := queuedTasks(s.Engine)
require.Len(t, tasks, 1)
assertTaskFor(t, tasks[0], deliveryID, event, &target)
}
// TestCreateReplay_RollsBackWhenAWriteFails proves the replay delivery
// and its target totals are one transaction: when the totals write
// fails, after the delivery was written, no new delivery is left and
// nothing is queued.
func TestCreateReplay_RollsBackWhenAWriteFails(t *testing.T) {
t.Parallel()
s := newISetup(t)
target := storeTestTarget(t, s, "replayed")
event := iSeedEvent(t, s.WebhookDB, s.WebhookID, `{"replay":"me"}`)
iSeedDelivery(
t, s.WebhookDB, event.ID, target.ID,
database.DeliveryStatusFailed,
)
require.NoError(t,
s.WebhookDB.Migrator().DropTable(&database.TargetTotals{}))
_, err := s.Engine.CreateReplay(s.WebhookID, event.ID, &target)
require.Error(t, err)
assert.Len(t, storedDeliveries(t, s.WebhookDB, event.ID), 1)
assert.Empty(t, queuedTasks(s.Engine))
assert.Zero(t, s.Engine.ExportInflightHeld())
}
// storedDelivery reads one delivery back.
func storedDelivery(
t *testing.T, db *gorm.DB, deliveryID string,
) database.Delivery {
t.Helper()
var dlv database.Delivery
require.NoError(t, db.First(&dlv, "id = ?", deliveryID).Error)
return dlv
}
+23 -12
View File
@@ -13,8 +13,8 @@ import (
// The engine provides one to each target so a target can own // The engine provides one to each target so a target can own
// its retries durably: it records the attempt, marks the // its retries durably: it records the attempt, marks the
// delivery retrying, and asks the Scheduler to deliver the // delivery retrying, and asks the Scheduler to deliver the
// next attempt after delay — exactly what the engine's // next attempt after delay — exactly what the engine does for
// recovery routine does for a retrying delivery. // its own restart recovery.
type Scheduler interface { type Scheduler interface {
ScheduleRetry(task Task, delay time.Duration) ScheduleRetry(task Task, delay time.Duration)
} }
@@ -32,8 +32,7 @@ type Scheduler interface {
// Engine.observeAttempt, alongside the DeliveryResult it records for // Engine.observeAttempt, alongside the DeliveryResult it records for
// it. Deliver is also entered for attempts that never happen — an // it. Deliver is also entered for attempts that never happen — an
// open circuit breaker refuses one — so the count cannot be taken // open circuit breaker refuses one — so the count cannot be taken
// from around this call. An attempt cut off by a stop is neither // from around this call.
// counted nor recorded.
type Target interface { type Target interface {
Deliver( Deliver(
ctx context.Context, ctx context.Context,
@@ -45,20 +44,29 @@ type Target interface {
} }
// rescheduler is implemented by targets that own durable // rescheduler is implemented by targets that own durable
// retries. The engine's recovery routine uses it to let the // retries. The engine's restart recovery and periodic sweep
// target recompute the schedule for an orphaned retrying // use it to let the target recompute the schedule for an
// delivery, keeping the retry schedule target-owned. // orphaned retrying delivery, keeping the retry schedule
// Fire-and-forget targets do not implement it, and a retrying // target-owned. Fire-and-forget targets do not implement it
// delivery found for one is terminally failed. // and their (never-occurring) retrying deliveries are
// skipped.
type rescheduler interface { type rescheduler interface {
// remainingBackoff returns how long to wait before the // remainingBackoff returns how long to wait before the
// next attempt of a recovered retrying delivery: zero once // next attempt of a recovered retrying delivery.
// its backoff has passed.
remainingBackoff( remainingBackoff(
webhookDB *gorm.DB, webhookDB *gorm.DB,
deliveryID string, deliveryID string,
attemptNum int, attemptNum int,
) time.Duration ) time.Duration
// backoffElapsed reports whether the backoff window for
// the last attempt has already passed, so the periodic
// sweep can re-enqueue the delivery now.
backoffElapsed(
webhookDB *gorm.DB,
deliveryID string,
attemptNum int,
) bool
} }
// attemptResult is the outcome of a single delivery attempt, // attemptResult is the outcome of a single delivery attempt,
@@ -94,13 +102,16 @@ func (e *Engine) initTargets(client *http.Client) {
client: client, client: client,
} }
dbT := &databaseTarget{eng: e}
e.httpTarget = httpT e.httpTarget = httpT
e.slackTarget = slackT e.slackTarget = slackT
e.dbTarget = dbT
e.targets = map[database.TargetType]Target{ e.targets = map[database.TargetType]Target{
database.TargetTypeHTTP: httpT, database.TargetTypeHTTP: httpT,
database.TargetTypeSlack: slackT, database.TargetTypeSlack: slackT,
database.TargetTypeDatabase: &databaseTarget{eng: e}, database.TargetTypeDatabase: dbT,
database.TargetTypeLog: &logTarget{eng: e}, database.TargetTypeLog: &logTarget{eng: e},
} }
} }
+136
View File
@@ -0,0 +1,136 @@
package delivery
import (
"encoding/json"
"errors"
"fmt"
"sneak.berlin/go/webhooker/internal/database"
)
// errUnknownTargetTypeForEdit is returned when a stored target has a
// type the edit form has no field set for.
var errUnknownTargetTypeForEdit = errors.New(
"unknown target type",
)
// TargetConfigForm is the UNMASKED projection of a target's stored
// configuration, for pre-filling the target edit form.
//
// It is the deliberate exception to the rule TargetView enforces
// everywhere else: TargetView exists so that no template can render
// a target's stored blob, because a destination URL's path segments
// and a header value are both routinely the credential. An operator
// cannot correct a value they cannot see, so the edit form — and
// only the edit form — is shown the full value.
//
// Everything that keeps that exception narrow lives at the call
// site: the route is behind RequireAuth and the webhook's ownership
// check, and its group sets NoCache so the rendered secret is not
// written to a shared cache. Do not reach for this type from any
// other page.
type TargetConfigForm struct {
// URL is the destination for an HTTP target and the webhook
// URL for a Slack target.
URL string
// Headers is the HTTP target's configured headers in the
// textarea representation, one "Name: value" per line.
Headers string
// Timeout is the HTTP target's per-request timeout in seconds,
// empty when unset.
Timeout string
// ForwardQuery is the HTTP target's setting that passes each
// event's query string on to it.
ForwardQuery bool
// Expiry is the database (archive) target's row expiry.
Expiry string
// Rotation is the database (archive) target's rotation.
Rotation string
}
// NewTargetConfigForm parses a target's stored configuration into
// the edit form's fields.
//
// A configuration that does not parse is an error rather than a
// zero-valued form that silently looks like a target with no
// settings. The caller shows the operator that the stored value
// could not be read, so that saving the form is understood as
// replacing it rather than preserving it.
func NewTargetConfigForm(
t *database.Target,
) (TargetConfigForm, error) {
switch t.Type {
case database.TargetTypeHTTP:
cfg, err := parseHTTPConfig(t.Config)
if err != nil {
return TargetConfigForm{}, err
}
return TargetConfigForm{
URL: cfg.URL,
Headers: FormatTargetHeaders(cfg.Headers),
Timeout: FormatTargetTimeout(cfg.Timeout),
ForwardQuery: cfg.ForwardQuery,
}, nil
case database.TargetTypeSlack:
cfg, err := parseSlackConfig(t.Config)
if err != nil {
return TargetConfigForm{}, err
}
return TargetConfigForm{URL: cfg.WebhookURL}, nil
case database.TargetTypeDatabase:
return databaseConfigForm(t.Config)
case database.TargetTypeLog:
// The log target takes no configuration.
return TargetConfigForm{}, nil
default:
return TargetConfigForm{}, fmt.Errorf(
"%w: %q", errUnknownTargetTypeForEdit, t.Type,
)
}
}
// databaseConfigForm parses an archive target's optional expiry and
// rotation. An absent, empty or never expiry yields an empty expiry,
// on which the edit form starts at never; saving it unchanged stores
// never, which means the same as an empty expiry. An absent rotation
// 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(
configJSON string,
) (TargetConfigForm, error) {
if configJSON == "" {
return TargetConfigForm{}, nil
}
var cfg databaseTargetConfig
err := json.Unmarshal([]byte(configJSON), &cfg)
if err != nil {
return TargetConfigForm{}, fmt.Errorf(
"parsing config JSON: %w", err,
)
}
err = ValidateArchiveRotation(cfg.Rotation)
if err != nil {
return TargetConfigForm{}, err
}
form := TargetConfigForm{Rotation: cfg.Rotation}
if cfg.Expiry == "" || cfg.Expiry == archiveExpiryNever {
return form, nil
}
err = ValidateArchiveExpiry(cfg.Expiry)
if err != nil {
return TargetConfigForm{}, err
}
form.Expiry = cfg.Expiry
return form, nil
}
+84 -39
View File
@@ -5,21 +5,19 @@ import (
"strconv" "strconv"
"time" "time"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
// configUnavailable is what a target's settings render as when // configUnavailable is what a target's configuration renders
// the target is of an unknown type, and what its URL renders as // as when it is absent, of an unknown type, or does not
// when it does not parse into a scheme and host. The stored URL is // parse. The stored blob is never shown as a fallback: it can
// never shown as a fallback: it can be a credential (a Slack // hold a credential (a Slack incoming webhook URL is a bearer
// incoming webhook URL is a bearer token) and a UI that prints it // token) and a UI that prints it leaks that credential into
// leaks that credential into browser history, screenshots and // browser history, screenshots and screen shares.
// screen shares.
const configUnavailable = "(unavailable)" const configUnavailable = "(unavailable)"
// ConfigField is one labelled, display-safe value derived // ConfigField is one labelled, display-safe value derived
// from a target's settings. // from a target's stored configuration.
type ConfigField struct { type ConfigField struct {
Label string Label string
Value string Value string
@@ -33,9 +31,9 @@ type ConfigField struct {
const deletedNameSuffix = " (deleted)" const deletedNameSuffix = " (deleted)"
// TargetView is the display-safe projection of a target for // TargetView is the display-safe projection of a target for
// the UI. It deliberately has no URL or header field, so no // the UI. It deliberately has no raw configuration field, so
// template — present or future — can render the stored // no template — present or future — can render the stored
// credential. // blob.
type TargetView struct { type TargetView struct {
ID string ID string
Name string Name string
@@ -62,7 +60,8 @@ func (v TargetView) DisplayName() string {
} }
// NewTargetViews projects targets for rendering, replacing // NewTargetViews projects targets for rendering, replacing
// each target's settings with named, display-safe fields. // each stored configuration blob with named, display-safe
// fields.
// //
// A soft-deleted row projects exactly as a live one does, minus // A soft-deleted row projects exactly as a live one does, minus
// the deleted marker on its name: masking is a property of the // the deleted marker on its name: masking is a property of the
@@ -91,8 +90,8 @@ func NewTargetViews(
} }
// targetConfigFields returns the display-safe fields for a // targetConfigFields returns the display-safe fields for a
// target's settings. A target of an unknown type gets the // target's configuration. Anything it cannot parse becomes
// neutral placeholder. // the neutral placeholder.
func targetConfigFields( func targetConfigFields(
t *database.Target, t *database.Target,
) []ConfigField { ) []ConfigField {
@@ -102,26 +101,37 @@ func targetConfigFields(
case database.TargetTypeHTTP: case database.TargetTypeHTTP:
return httpConfigFields(t) return httpConfigFields(t)
case database.TargetTypeDatabase: case database.TargetTypeDatabase:
return databaseConfigFields(t) return databaseConfigFields(t.Config)
case database.TargetTypeLog: case database.TargetTypeLog:
// The log target takes no settings. // The log target takes no configuration.
return nil return nil
default: default:
return unavailableConfigFields()
}
}
// unavailableConfigFields is the neutral placeholder shown
// for a configuration that could not be presented.
func unavailableConfigFields() []ConfigField {
return []ConfigField{{ return []ConfigField{{
Label: "Configuration", Label: "Configuration",
Value: configUnavailable, Value: configUnavailable,
}} }}
} }
}
// slackConfigFields describes a Slack target: its masked // slackConfigFields describes a Slack target: its masked
// webhook URL and its number of delivery attempts. Only the // webhook URL and its retry count. Only the masked URL is
// 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)
if err != nil {
return unavailableConfigFields()
}
return []ConfigField{{ return []ConfigField{{
Label: "Webhook URL", Label: "Webhook URL",
Value: MaskURL(t.URL), Value: cfg.MaskedWebhookURL(),
}, maxAttemptsField(t)} }, maxRetriesField(t)}
} }
// httpConfigFields describes an HTTP target: its destination // httpConfigFields describes an HTTP target: its destination
@@ -135,55 +145,79 @@ func slackConfigFields(t *database.Target) []ConfigField {
// whose path segments are the credential, and the field takes // whose path segments are the credential, and the field takes
// an arbitrary URL, so no segment can be assumed non-secret. // an arbitrary URL, so no segment can be assumed non-secret.
func httpConfigFields(t *database.Target) []ConfigField { func httpConfigFields(t *database.Target) []ConfigField {
cfg, err := parseHTTPConfig(t.Config)
if err != nil {
return unavailableConfigFields()
}
fields := []ConfigField{{ fields := []ConfigField{{
Label: "Destination URL", Label: "Destination URL",
Value: MaskURL(t.URL), Value: MaskURL(cfg.URL),
}} }}
if t.TimeoutSeconds > 0 { if cfg.Timeout > 0 {
fields = append(fields, ConfigField{ fields = append(fields, ConfigField{
Label: "Timeout", Label: "Timeout",
Value: strconv.Itoa(t.TimeoutSeconds) + "s", Value: strconv.Itoa(cfg.Timeout) + "s",
}) })
} }
if len(t.Headers) > 0 { if len(cfg.Headers) > 0 {
fields = append(fields, ConfigField{ fields = append(fields, ConfigField{
Label: "Headers", Label: "Headers",
Value: fmt.Sprintf( Value: fmt.Sprintf(
"%d configured", len(t.Headers), "%d configured", len(cfg.Headers),
), ),
}) })
} }
if t.ForwardQuery { if cfg.ForwardQuery {
fields = append(fields, ConfigField{ fields = append(fields, ConfigField{
Label: "Query string", Label: "Query string",
Value: "passed on to this target", Value: "passed on to this target",
}) })
} }
fields = append(fields, maxAttemptsField(t)) fields = append(fields, maxRetriesField(t))
return fields return fields
} }
// maxAttemptsField describes a target's number of delivery // maxRetriesField describes a target's retry count, which lives
// attempts. // on the target row rather than in its configuration blob. A
func maxAttemptsField(t *database.Target) ConfigField { // stored 0 makes a single attempt, so it is shown as 1.
func maxRetriesField(t *database.Target) ConfigField {
attempts := strconv.Itoa(t.MaxRetries)
if t.MaxRetries == 0 {
attempts = "1 (fire-and-forget: no retries, no circuit breaker)"
}
return ConfigField{ return ConfigField{
Label: "Delivery attempts", Label: "Delivery attempts",
Value: strconv.Itoa(t.MaxAttempts), Value: attempts,
} }
} }
// databaseConfigFields describes an archive target by its // databaseConfigFields describes an archive target by its
// expiry in plain units, such as "30 days", or "never" when // expiry in plain units, such as "30 days", or "never" when
// the archive is kept forever, and by its rotation. // the archive is kept forever, and by its rotation. An expiry
func databaseConfigFields(t *database.Target) []ConfigField { // that is set but not a valid duration, or a rotation that is
value := archive.ExpiryNever // not one of the four, is reported as unavailable rather than
if t.ArchiveExpiry > 0 { // echoed back.
value = plainDuration(t.ArchiveExpiry) func databaseConfigFields(configJSON string) []ConfigField {
expiry, err := parseArchiveExpiry(configJSON)
if err != nil {
return unavailableConfigFields()
}
rotation, err := parseArchiveRotation(configJSON)
if err != nil {
return unavailableConfigFields()
}
value := archiveExpiryNever
if expiry > 0 {
value = plainDuration(expiry)
} }
return []ConfigField{{ return []ConfigField{{
@@ -191,7 +225,7 @@ func databaseConfigFields(t *database.Target) []ConfigField {
Value: value, Value: value,
}, { }, {
Label: "Archive rotation", Label: "Archive rotation",
Value: t.ArchiveRotation, Value: rotation,
}} }}
} }
@@ -227,3 +261,14 @@ func plainDuration(d time.Duration) string {
return d.String() return d.String()
} }
// MaskedWebhookURL returns the Slack webhook URL reduced to
// its scheme and host, with the path, query and any userinfo
// elided. The path segments are the credential, so none of
// them is shown: the field accepts an arbitrary URL, so no
// segment can be assumed non-secret. A URL that does not
// parse into a scheme and host yields the neutral
// placeholder, never the raw string.
func (c *SlackTargetConfig) MaskedWebhookURL() string {
return MaskURL(c.WebhookURL)
}
+110 -67
View File
@@ -32,20 +32,10 @@ const (
viewMaskedOrigin = viewExampleOrigin + "/..." viewMaskedOrigin = viewExampleOrigin + "/..."
viewUnavailable = "(unavailable)" viewUnavailable = "(unavailable)"
viewExpiryNever = "never" viewExpiryNever = "never"
viewMaxAttempts = "Delivery attempts" viewMaxRetries = "Delivery attempts"
) )
// The archive rotations a database target's settings can name. func TestMaskedWebhookURL(t *testing.T) {
const (
rotationNone = "none"
rotationMonthly = "monthly"
rotationDaily = "daily"
rotationHourly = "hourly"
)
// TestNewTargetViews_SlackMasksWebhookURL shows what a Slack target's
// URL renders as on the target list.
func TestNewTargetViews_SlackMasksWebhookURL(t *testing.T) {
t.Parallel() t.Parallel()
tests := map[string]struct { tests := map[string]struct {
@@ -89,24 +79,28 @@ func TestNewTargetViews_SlackMasksWebhookURL(t *testing.T) {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
t.Parallel() t.Parallel()
target := slackTarget() cfg := &delivery.SlackTargetConfig{
target.URL = tc.url WebhookURL: tc.url,
}
assert.Equal( assert.Equal(
t, tc.want, t, tc.want, cfg.MaskedWebhookURL(),
fieldMap(viewFor(t, target).Config)["Webhook URL"],
) )
}) })
} }
} }
// TestNewTargetViews_SlackNeverLeaksPath is the direct // TestMaskedWebhookURL_NeverLeaksPath is the direct
// expression of the rule: whatever the input, the masked // expression of the rule: whatever the input, the masked
// value never contains a path segment of it. // value never contains a path segment of it.
func TestNewTargetViews_SlackNeverLeaksPath(t *testing.T) { func TestMaskedWebhookURL_NeverLeaksPath(t *testing.T) {
t.Parallel() t.Parallel()
masked := fieldMap(viewFor(t, slackTarget()).Config)["Webhook URL"] cfg := &delivery.SlackTargetConfig{
WebhookURL: slackWebhookURL,
}
masked := cfg.MaskedWebhookURL()
assert.NotContains(t, masked, "T00000000") assert.NotContains(t, masked, "T00000000")
assert.NotContains(t, masked, "B00000000") assert.NotContains(t, masked, "B00000000")
@@ -175,8 +169,8 @@ func slackTarget() database.Target {
Name: slackTargetName, Name: slackTargetName,
Type: database.TargetTypeSlack, Type: database.TargetTypeSlack,
Active: true, Active: true,
URL: slackWebhookURL, Config: `{"webhookUrl":"` +
MaxAttempts: 1, slackWebhookURL + `"}`,
} }
} }
@@ -196,20 +190,19 @@ func TestNewTargetViews_Slack(t *testing.T) {
t, t,
map[string]string{ map[string]string{
"Webhook URL": slackMaskedURL, "Webhook URL": slackMaskedURL,
viewMaxAttempts: "1", viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
}, },
fieldMap(view.Config), fieldMap(view.Config),
) )
} }
// TestNewTargetViews_SlackAttempts proves a Slack target shows // TestNewTargetViews_SlackRetries proves a Slack target shows
// its number of delivery attempts the same way an HTTP target // its retry count the same way an HTTP target does.
// does. func TestNewTargetViews_SlackRetries(t *testing.T) {
func TestNewTargetViews_SlackAttempts(t *testing.T) {
t.Parallel() t.Parallel()
target := slackTarget() target := slackTarget()
target.MaxAttempts = 2 target.MaxRetries = 2
view := viewFor(t, target) view := viewFor(t, target)
@@ -217,7 +210,7 @@ func TestNewTargetViews_SlackAttempts(t *testing.T) {
t, t,
map[string]string{ map[string]string{
"Webhook URL": slackMaskedURL, "Webhook URL": slackMaskedURL,
viewMaxAttempts: "2", viewMaxRetries: "2",
}, },
fieldMap(view.Config), fieldMap(view.Config),
) )
@@ -228,11 +221,11 @@ func TestNewTargetViews_HTTP(t *testing.T) {
view := viewFor(t, database.Target{ view := viewFor(t, database.Target{
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
URL: viewExampleHook, Config: `{"url":"` + viewExampleHook + `",` +
TimeoutSeconds: 30, `"timeout":30,` +
Headers: map[string]string{headerAuthorization: "Bearer sekrit"}, `"headers":{"Authorization":"Bearer sekrit"},` +
ForwardQuery: true, `"forwardQuery":true}`,
MaxAttempts: 5, MaxRetries: 5,
}) })
fields := fieldMap(view.Config) fields := fieldMap(view.Config)
@@ -244,7 +237,7 @@ func TestNewTargetViews_HTTP(t *testing.T) {
"Timeout": "30s", "Timeout": "30s",
"Headers": "1 configured", "Headers": "1 configured",
"Query string": "passed on to this target", "Query string": "passed on to this target",
viewMaxAttempts: "5", viewMaxRetries: "5",
}, },
fields, fields,
) )
@@ -255,22 +248,19 @@ func TestNewTargetViews_HTTP(t *testing.T) {
} }
} }
// TestNewTargetViews_HTTPSingleAttempt proves a single attempt is func TestNewTargetViews_HTTPFireAndForget(t *testing.T) {
// shown as the plain number, like any other.
func TestNewTargetViews_HTTPSingleAttempt(t *testing.T) {
t.Parallel() t.Parallel()
view := viewFor(t, database.Target{ view := viewFor(t, database.Target{
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
URL: viewExampleHook, Config: `{"url":"` + viewExampleHook + `"}`,
MaxAttempts: 1,
}) })
assert.Equal( assert.Equal(
t, t,
map[string]string{ map[string]string{
"Destination URL": viewMaskedOrigin, "Destination URL": viewMaskedOrigin,
viewMaxAttempts: "1", viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
}, },
fieldMap(view.Config), fieldMap(view.Config),
) )
@@ -285,8 +275,7 @@ func TestNewTargetViews_HTTPMasksDestinationURL(t *testing.T) {
view := viewFor(t, database.Target{ view := viewFor(t, database.Target{
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
URL: slackWebhookURL, Config: `{"url":"` + slackWebhookURL + `"}`,
MaxAttempts: 1,
}) })
fields := fieldMap(view.Config) fields := fieldMap(view.Config)
@@ -309,20 +298,25 @@ func TestNewTargetViews_Database(t *testing.T) {
t.Parallel() t.Parallel()
tests := map[string]struct { tests := map[string]struct {
expiry time.Duration config string
want string want string
}{ }{
"never": {expiry: 0, want: viewExpiryNever}, "empty config": {config: "", want: viewExpiryNever},
"1h": {expiry: time.Hour, want: "1 hour"}, "empty expiry": {config: `{}`, want: viewExpiryNever},
"12h": {expiry: 12 * time.Hour, want: "12 hours"}, "never literal": {
"24h": {expiry: 24 * time.Hour, want: "1 day"}, config: `{"expiry":"` + viewExpiryNever + `"}`,
"720h": {expiry: 720 * time.Hour, want: "30 days"}, want: viewExpiryNever,
"2160h": {expiry: 2160 * time.Hour, want: "90 days"}, },
"8760h": {expiry: 8760 * time.Hour, want: "365 days"}, "1h": {config: `{"expiry":"1h"}`, want: "1 hour"},
"36h": {expiry: 36 * time.Hour, want: "36 hours"}, "12h": {config: `{"expiry":"12h"}`, want: "12 hours"},
"1h30m": {expiry: 90 * time.Minute, want: "90 minutes"}, "24h": {config: `{"expiry":"24h"}`, want: "1 day"},
"45s": {expiry: 45 * time.Second, want: "45 seconds"}, "720h": {config: `{"expiry":"720h"}`, want: "30 days"},
"1.5s": {expiry: 1500 * time.Millisecond, want: "1.5s"}, "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 {
@@ -331,8 +325,7 @@ func TestNewTargetViews_Database(t *testing.T) {
view := viewFor(t, database.Target{ view := viewFor(t, database.Target{
Type: database.TargetTypeDatabase, Type: database.TargetTypeDatabase,
ArchiveExpiry: tc.expiry, Config: tc.config,
ArchiveRotation: rotationNone,
}) })
assert.Equal( assert.Equal(
@@ -348,23 +341,33 @@ func TestNewTargetViews_Database(t *testing.T) {
} }
// TestNewTargetViews_DatabaseRotation proves the target list shows a // TestNewTargetViews_DatabaseRotation proves the target list shows a
// database target's rotation. // database target's rotation, none when it has none stored.
func TestNewTargetViews_DatabaseRotation(t *testing.T) { func TestNewTargetViews_DatabaseRotation(t *testing.T) {
t.Parallel() t.Parallel()
// Each stored config, and the rotation the list shows for it.
tests := map[string]string{
"": rotationNone,
`{"rotation":""}`: rotationNone,
}
for _, rotation := range []string{ for _, rotation := range []string{
rotationNone, rotationMonthly, rotationDaily, rotationHourly, rotationNone, rotationMonthly, rotationDaily, rotationHourly,
} { } {
t.Run(rotation, func(t *testing.T) { tests[`{"rotation":"`+rotation+`"}`] = rotation
}
for config, want := range tests {
t.Run(config, func(t *testing.T) {
t.Parallel() t.Parallel()
view := viewFor(t, database.Target{ view := viewFor(t, database.Target{
Type: database.TargetTypeDatabase, Type: database.TargetTypeDatabase,
ArchiveRotation: rotation, Config: config,
}) })
assert.Equal( assert.Equal(
t, rotation, fieldMap(view.Config)["Archive rotation"], t, want, fieldMap(view.Config)["Archive rotation"],
) )
}) })
} }
@@ -375,21 +378,59 @@ func TestNewTargetViews_Log(t *testing.T) {
view := viewFor(t, database.Target{ view := viewFor(t, database.Target{
Type: database.TargetTypeLog, Type: database.TargetTypeLog,
Config: "",
}) })
assert.Empty(t, view.Config) assert.Empty(t, view.Config)
} }
// TestNewTargetViews_UnknownType proves that a target of a type the // TestNewTargetViews_Unpresentable proves that no config the
// view does not know shows the neutral placeholder, and never its // view cannot present falls back to the stored blob.
// URL. func TestNewTargetViews_Unpresentable(t *testing.T) {
func TestNewTargetViews_UnknownType(t *testing.T) {
t.Parallel() t.Parallel()
view := viewFor(t, database.Target{ const blob = `{"webhookUrl":"https://hooks.slack.com` +
slackSecretPath + `"`
tests := map[string]database.Target{
"unknown target type": {
Type: database.TargetType("carrier-pigeon"), Type: database.TargetType("carrier-pigeon"),
URL: slackWebhookURL, Config: blob,
}) },
"unparseable json": {
Type: database.TargetTypeSlack,
Config: blob,
},
"empty slack config": {
Type: database.TargetTypeSlack,
},
"slack config without url": {
Type: database.TargetTypeSlack,
Config: `{}`,
},
"unparseable http json": {
Type: database.TargetTypeHTTP,
Config: `{"url":`,
},
"unparseable archive json": {
Type: database.TargetTypeDatabase,
Config: `{"expiry":`,
},
"invalid archive expiry": {
Type: database.TargetTypeDatabase,
Config: `{"expiry":"a fortnight"}`,
},
"invalid archive rotation": {
Type: database.TargetTypeDatabase,
Config: weeklyConfig,
},
}
for name, target := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()
view := viewFor(t, target)
assert.Equal( assert.Equal(
t, t,
@@ -398,4 +439,6 @@ func TestNewTargetViews_UnknownType(t *testing.T) {
}, },
fieldMap(view.Config), fieldMap(view.Config),
) )
})
}
} }
+369 -4
View File
@@ -2,23 +2,83 @@ package delivery
import ( import (
"context" "context"
"fmt"
"strings"
"sync"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
// archiveNameMaxLen is how many characters of a webhook or target
// name an archive file name keeps.
const archiveNameMaxLen = 40
// databaseTarget is a no-retry target that archives the full // databaseTarget is a no-retry target that archives the full
// inbound event into the target's own archive SQLite file, separate // inbound event into the target's own archive SQLite file, separate
// 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
// through the engine's archive.Writers, 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. Package archive holds everything about the files // succeeded. See archiveWriter for the close/reopen, auto-recreate,
// themselves. // and expiry semantics.
type databaseTarget struct { type databaseTarget struct {
eng *Engine eng *Engine
// writers holds one archive writer per database target, keyed
// by target ID.
mu sync.Mutex
writers map[string]*archiveWriter
}
// ArchiveFileName returns the file name of a database target's
// archive: archive-WEBHOOKNAME-TARGETNAME-TARGETID.db, with both
// names passed through archiveNamePart. The target ID keeps the
// name unique when two targets' names come out the same.
func ArchiveFileName(webhookName, targetName, targetID string) string {
return "archive-" + archiveNamePart(webhookName) + "-" +
archiveNamePart(targetName) + "-" + targetID + ".db"
}
// archiveNamePart makes a webhook or target name safe to put in a
// file name. It is lowercased; ASCII letters and digits are kept,
// every other run of characters becomes a single "-", and no "-" is
// left at either end. It is cut to archiveNameMaxLen characters, and
// a name with nothing left is "unnamed".
func archiveNamePart(name string) string {
var b strings.Builder
dash := false
for _, r := range strings.ToLower(name) {
if (r < 'a' || r > 'z') && (r < '0' || r > '9') {
dash = b.Len() > 0
continue
}
if dash {
b.WriteByte('-')
dash = false
}
b.WriteRune(r)
}
part := b.String()
if len(part) > archiveNameMaxLen {
part = strings.TrimRight(part[:archiveNameMaxLen], "-")
}
if part == "" {
return "unnamed"
}
return part
} }
// Deliver implements Target. It archives the event, then // Deliver implements Target. It archives the event, then
@@ -38,7 +98,7 @@ func (t *databaseTarget) Deliver(
) { ) {
start := time.Now() start := time.Now()
err := t.eng.archives.Write(d) err := t.archive(d)
elapsed := time.Since(start) elapsed := time.Since(start)
@@ -85,3 +145,308 @@ func (t *databaseTarget) Deliver(
database.DeliveryStatusDelivered, database.DeliveryStatusDelivered,
) )
} }
// archive writes the full event as a row into the target's
// archive database, honouring the optional per-target expiry and
// 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 {
webhookID := d.Event.WebhookID
if webhookID == "" {
return errArchiveMissingWebhookID
}
expiry, err := parseArchiveExpiry(d.Target.Config)
if err != nil {
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)
if err != nil {
return err
}
row := archivedEvent{
EventID: d.Event.ID,
WebhookID: webhookID,
EntrypointID: d.Event.EntrypointID,
Method: d.Event.Method,
RawQuery: d.Event.RawQuery,
Headers: d.Event.Headers,
Body: d.Event.Body,
ContentType: d.Event.ContentType,
}
return w.write(row, expiry, archivePeriod(rotation, receivedAt))
}
// writerFor returns the archive writer for a database target,
// creating and caching it on first use. Each target has one writer
// so its close/reopen debounce state is shared across concurrent
// deliveries, and so a rename and the idle sweep take the same lock
// as its writes.
func (t *databaseTarget) writerFor(
targetID string,
) (*archiveWriter, error) {
t.mu.Lock()
defer t.mu.Unlock()
w, ok := t.writers[targetID]
if !ok {
var err error
w, err = t.newWriter(targetID)
if err != nil {
return nil, err
}
if t.writers == nil {
t.writers = make(map[string]*archiveWriter)
}
t.writers[targetID] = w
}
// A delivery claims the entry: even if the idle sweep created
// it moments ago, it now belongs to the registry proper and
// the sweep must leave it in place when it finishes.
w.sweepOwned = false
return w, nil
}
// sweepWriterFor returns the archive writer the idle sweep should
// prune a target's archive through, together with whether the sweep
// itself created the registry entry.
//
// The sweep must route its prune through the registered writer so
// the writer's mutex orders it against concurrent writes, but it
// must never leave a registry entry behind: a sweep that ran
// concurrently with the target's deletion would otherwise
// re-create an entry that nothing will ever evict again, which is
// exactly the leak eviction exists to prevent. An entry the sweep
// creates is therefore marked sweep-owned and handed back to
// releaseSweepWriter when the sweep is done.
func (t *databaseTarget) sweepWriterFor(
targetID string,
) (*archiveWriter, bool, error) {
t.mu.Lock()
defer t.mu.Unlock()
w, ok := t.writers[targetID]
if ok {
return w, false, nil
}
w, err := t.newWriter(targetID)
if err != nil {
return nil, false, err
}
if t.writers == nil {
t.writers = make(map[string]*archiveWriter)
}
w.sweepOwned = true
t.writers[targetID] = w
return w, true, nil
}
// releaseSweepWriter drops a registry entry that the idle sweep
// created, so a sweep leaves the registry exactly as it found it.
//
// The entry is removed only if it is still the very writer the
// sweep installed and no delivery has claimed it in the meantime
// (writerFor clears sweepOwned when it hands a writer to the
// write path). Both conditions are evaluated under the registry
// lock, so an eviction that raced the sweep — which removes the
// entry outright — simply finds nothing left to do here, and a
// delivery that adopted the writer keeps a registered, evictable
// one.
func (t *databaseTarget) releaseSweepWriter(
targetID string, w *archiveWriter,
) {
t.mu.Lock()
defer t.mu.Unlock()
cur, ok := t.writers[targetID]
if !ok || cur != w || !cur.sweepOwned {
return
}
delete(t.writers, targetID)
}
// newWriter builds the writer for a database target's archive. Its
// 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 name the writer uses. It does not touch the archive files.
func (t *databaseTarget) newWriter(
targetID string,
) (*archiveWriter, error) {
if t.eng.dbManager == nil {
return nil, errArchiveNoDataDir
}
var target database.Target
err := t.eng.database.DB().
Preload("Webhook").
First(&target, "id = ?", targetID).Error
if err != nil {
return nil, fmt.Errorf(
"loading database target %s: %w", targetID, err,
)
}
w := newArchiveWriter(
ArchivePath(t.eng.dbManager, &target.Webhook, &target),
t.eng.log,
)
w.webhookID = target.WebhookID
return w, nil
}
// rename moves every one of a database target's archive files to the
// name for webhookName and targetName. It goes through the target's
// writer, so the move holds the lock that writes and the idle sweep
// take, and later writes use the new name.
//
// The writer is created if there is none, and it stays cached. The
// handlers rename before they save the new name, so until the save
// the main database still has the old one; a delivery in that window
// must find this writer rather than build one from the old name.
func (t *databaseTarget) rename(
targetID, webhookName, targetName string,
) error {
w, err := t.writerFor(targetID)
if err != nil {
return err
}
return w.rename(ArchiveFileName(webhookName, targetName, targetID))
}
// evict drops a database target's archive writer from the registry
// and closes its handle, so a deleted target does not leave a
// writer (and an open archive handle within its debounce window)
// alive for the process lifetime.
//
// The map entry is removed under the registry lock, which is
// then released before the handle is closed under the writer's
// own lock: that ordering keeps the registry available to other
// targets while an in-flight write on this one drains, and
// closing under the writer's lock means eviction can never race
// a write.
//
// Eviction is idempotent and silent for a target with no writer,
// which is the common case: only a database target that has
// received an event or been renamed has one. It never deletes the
// archive file.
func (t *databaseTarget) evict(targetID string) {
t.mu.Lock()
w, ok := t.writers[targetID]
if ok {
delete(t.writers, targetID)
}
t.mu.Unlock()
if !ok {
return
}
w.evict()
t.eng.log.Info(
"evicted archive writer",
"target_id", targetID,
"path", w.path,
)
}
// evictWebhook evicts, exactly as evict does, the writer of every
// database target of a webhook.
func (t *databaseTarget) evictWebhook(webhookID string) {
t.mu.Lock()
var gone []*archiveWriter
for targetID, w := range t.writers {
if w.webhookID == webhookID {
delete(t.writers, targetID)
gone = append(gone, w)
}
}
t.mu.Unlock()
for _, w := range gone {
w.evict()
t.eng.log.Info(
"evicted archive writer",
"webhook_id", webhookID,
"path", w.path,
)
}
}
// evictAll evicts every cached archive writer, exactly as evict
// does for one target. The engine calls it at shutdown, once its
// workers have returned. Closing the last handle on an archive
// moves the contents of its -wal into the .db and removes the
// -wal, so a clean stop leaves each archive as a single file.
func (t *databaseTarget) evictAll() {
t.mu.Lock()
writers := t.writers
t.writers = nil
t.mu.Unlock()
for _, w := range writers {
w.evict()
}
}
// sweepArchive prunes one database target's archive of rows older
// than expiry, without requiring a write. A missing archive file is
// left missing (see sweepExpired), so a sweep never creates an
// archive for a target that has never received an event.
//
// It also never leaves a registry entry behind: an entry it had
// to create to reach the writer's mutex is released again once
// the prune is done, so a sweep racing a target deletion cannot
// resurrect the writer the eviction just dropped.
func (t *databaseTarget) sweepArchive(
targetID string, expiry time.Duration,
) error {
w, created, err := t.sweepWriterFor(targetID)
if err != nil {
return err
}
if created {
defer t.releaseSweepWriter(targetID, w)
}
return w.sweepExpired(expiry)
}
@@ -1,6 +1,7 @@
package archive package delivery
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io/fs" "io/fs"
@@ -13,14 +14,12 @@ import (
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/db/migrations"
"sneak.berlin/go/webhooker/internal/gormlog" "sneak.berlin/go/webhooker/internal/gormlog"
) )
// ExpiryNever is the archive expiry, as the target forms take it, // archiveExpiryNever is the expiry sentinel (and default) that
// that disables pruning so archived rows are kept forever. It is // disables pruning so archived rows are kept forever.
// stored as an ArchiveExpiry of 0. const archiveExpiryNever = "never"
const ExpiryNever = "never"
// archiveReopenDebounce bounds how often an archive file is // archiveReopenDebounce bounds how often an archive file is
// closed and reopened. After each write the handle is closed // closed and reopened. After each write the handle is closed
@@ -71,14 +70,27 @@ var (
"archive writer has been evicted", "archive writer has been evicted",
) )
// ErrNameTaken is returned when an archive cannot be // ErrArchiveNameTaken is returned when an archive cannot be
// renamed because a file already has the new name. That file may // renamed because a file already has the new name. That file may
// be an archive with rows of its own, so it is never replaced. // be an archive with rows of its own, so it is never replaced.
ErrNameTaken = errors.New( ErrArchiveNameTaken = errors.New(
"a file already has the archive's new name", "a file already has the archive's new name",
) )
) )
// databaseTargetConfig is the optional per-target JSON config
// for a database (archive) target.
type databaseTargetConfig struct {
// Expiry is a Go duration (e.g. "720h") after which
// archived rows are pruned, or "never" (the default) to
// keep them forever.
Expiry string `json:"expiry"`
// Rotation is none (the default), monthly, daily or hourly: see
// archivePeriod.
Rotation string `json:"rotation"`
}
// archivedEvent is one fully captured webhook event stored in a // archivedEvent is one fully captured webhook event stored in a
// database target's archive for long-term retention. It is a // database target's archive for long-term retention. It is a
// self-contained copy — independent of the per-webhook event // self-contained copy — independent of the per-webhook event
@@ -99,35 +111,78 @@ type archivedEvent struct {
ArchivedAt time.Time `gorm:"index"` ArchivedAt time.Time `gorm:"index"`
} }
// ParseExpiry reads a user-supplied archive expiry for a database // parseArchiveExpiry reads the optional expiry from a database
// target, as the target forms submit it, into the ArchiveExpiry the // target's config JSON. An empty config, an empty expiry, or
// target stores. Empty and "never" both mean keep forever, which is // the literal "never" all mean keep forever, returned as a zero
// 0; anything else must be a positive Go duration such as "720h". Any // duration. Any other value must parse as a positive Go
// other value is an error, so a bad expiry is refused at the form // duration; a set-but-invalid value (unparseable, zero, or
// rather than stored. // negative) is an error rather than a silent default, matching
func ParseExpiry(expiry string) (time.Duration, error) { // ValidateArchiveExpiry at target creation.
if expiry == "" || expiry == ExpiryNever { func parseArchiveExpiry(
configJSON string,
) (time.Duration, error) {
if configJSON == "" {
return 0, nil return 0, nil
} }
dur, err := time.ParseDuration(expiry) var cfg databaseTargetConfig
err := json.Unmarshal([]byte(configJSON), &cfg)
if err != nil { if err != nil {
return 0, fmt.Errorf( return 0, fmt.Errorf(
"expiry must be %q or a Go duration "+ "parsing database target config: %w", err,
"such as \"720h\": %w", )
ExpiryNever, err, }
if cfg.Expiry == "" || cfg.Expiry == archiveExpiryNever {
return 0, nil
}
dur, err := time.ParseDuration(cfg.Expiry)
if err != nil {
return 0, fmt.Errorf(
"parsing archive expiry %q: %w", cfg.Expiry, err,
) )
} }
if dur <= 0 { if dur <= 0 {
return 0, fmt.Errorf( return 0, fmt.Errorf(
"%w: %q", errArchiveExpiryNotPositive, expiry, "%w: %q", errArchiveExpiryNotPositive, cfg.Expiry,
) )
} }
return dur, nil return dur, nil
} }
// ValidateArchiveExpiry checks a user-supplied archive expiry
// for a database target at configuration time. Valid values are
// empty, "never" (both meaning keep forever), or a positive Go
// duration such as "720h". Anything else is an error, so a bad
// expiry is rejected when the target is created rather than
// failing every subsequent delivery.
func ValidateArchiveExpiry(expiry string) error {
if expiry == "" || expiry == archiveExpiryNever {
return nil
}
dur, err := time.ParseDuration(expiry)
if err != nil {
return fmt.Errorf(
"expiry must be %q or a Go duration "+
"such as \"720h\": %w",
archiveExpiryNever, err,
)
}
if dur <= 0 {
return fmt.Errorf(
"%w: %q", errArchiveExpiryNotPositive, expiry,
)
}
return nil
}
// archiveWriter owns one database target's archive SQLite files. // 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
@@ -138,7 +193,7 @@ func ParseExpiry(expiry string) (time.Duration, error) {
type archiveWriter struct { type archiveWriter struct {
mu sync.Mutex mu sync.Mutex
// path is the target's archive file as Path names it. A // path is the target's archive file as ArchivePath names it. A
// target that rotates writes to the files archivePeriodPath names // target that rotates writes to the files archivePeriodPath names
// for path and a period instead. // for path and a period instead.
path string path string
@@ -176,7 +231,7 @@ type archiveWriter struct {
// registry proper. // registry proper.
// //
// Unlike every other field here it is guarded by // Unlike every other field here it is guarded by
// Writers.mu, not by this writer's mu: it describes the // databaseTarget.mu, not by this writer's mu: it describes the
// registry entry rather than the file. // registry entry rather than the file.
sweepOwned bool sweepOwned bool
} }
@@ -221,11 +276,7 @@ func (w *archiveWriter) write(
} }
} }
// GORM's clock, the one that stamps created_at in the other row.ArchivedAt = time.Now()
// databases, so this is stored in the same form. A bare time.Now()
// carries a monotonic clock reading, which the driver would store
// as an " m=+…" suffix.
row.ArchivedAt = w.db.NowFunc()
err := w.db.Create(&row).Error err := w.db.Create(&row).Error
if err != nil { if err != nil {
@@ -284,7 +335,7 @@ func (w *archiveWriter) openMode(
) )
} }
err = migrations.Apply(gdb, migrations.Archive) err = gdb.AutoMigrate(&archivedEvent{})
if err != nil { if err != nil {
_ = sqlDB.Close() _ = sqlDB.Close()
@@ -443,7 +494,7 @@ func (w *archiveWriter) sweepFile(
// the next write creates its file under the new name. // the next write creates its file under the new name.
// //
// If a file already has one of the new names, nothing is moved and // If a file already has one of the new names, nothing is moved and
// the error is ErrNameTaken. 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 {
@@ -481,7 +532,7 @@ func (w *archiveWriter) rename(name string) error {
for _, taken := range to { for _, taken := range to {
if fileExists(taken) { if fileExists(taken) {
return fmt.Errorf( return fmt.Errorf(
"%w: %s", ErrNameTaken, filepath.Base(taken), "%w: %s", ErrArchiveNameTaken, filepath.Base(taken),
) )
} }
} }
@@ -534,7 +585,7 @@ func (w *archiveWriter) evict() {
// each row's archived time. It runs on every (re)open, so a // each row's archived time. It runs on every (re)open, so a
// steadily written archive is swept by its own write traffic. An // steadily written archive is swept by its own write traffic. An
// archive that goes idle receives no further reopens, which is // archive that goes idle receives no further reopens, which is
// why Sweeper exists to drive sweepExpired on a timer. // why ArchiveSweeper exists to drive sweepExpired on a timer.
// Failures are logged, not fatal: a prune error must not stop // Failures are logged, not fatal: a prune error must not stop
// archiving. // archiving.
func (w *archiveWriter) prune(expiry time.Duration) { func (w *archiveWriter) prune(expiry time.Duration) {
@@ -561,9 +612,9 @@ func (w *archiveWriter) prune(expiry time.Duration) {
} }
} }
// FileInfo is what the metadata of a database target's archive // ArchiveFileInfo is what the metadata of a database target's archive
// files says about them. // files says about them.
type FileInfo struct { type ArchiveFileInfo struct {
// Files counts the files. // Files counts the files.
Files int Files int
@@ -575,18 +626,18 @@ type FileInfo struct {
Written time.Time Written time.Time
} }
// Stat reads the metadata of a database target's archive // StatArchive reads the metadata of a database target's archive
// files, given the path Path gives it (see archiveFiles), and // files, given the path ArchivePath gives it (see archiveFiles), and
// of their -wal, without opening them. With no files, which is so // of their -wal, without opening them. With no files, which is so
// before the first write and after the operator moved them away, the // before the first write and after the operator moved them away, the
// error wraps fs.ErrNotExist. // error wraps fs.ErrNotExist.
func Stat(path string) (FileInfo, error) { func StatArchive(path string) (ArchiveFileInfo, error) {
files, err := archiveFiles(path) files, err := archiveFiles(path)
if err != nil { if err != nil {
return FileInfo{}, err return ArchiveFileInfo{}, err
} }
var info FileInfo var info ArchiveFileInfo
for _, file := range files { for _, file := range files {
db, err := os.Stat(file.path) db, err := os.Stat(file.path)
@@ -595,7 +646,7 @@ func Stat(path string) (FileInfo, error) {
} }
if err != nil { if err != nil {
return FileInfo{}, err return ArchiveFileInfo{}, err
} }
info.Files++ info.Files++
@@ -611,7 +662,7 @@ func Stat(path string) (FileInfo, error) {
} }
if err != nil { if err != nil {
return FileInfo{}, err return ArchiveFileInfo{}, err
} }
info.Size += wal.Size() info.Size += wal.Size()
@@ -622,7 +673,7 @@ func Stat(path string) (FileInfo, error) {
} }
if info.Files == 0 { if info.Files == 0 {
return FileInfo{}, fmt.Errorf( return ArchiveFileInfo{}, fmt.Errorf(
"no archive file for %s: %w", path, fs.ErrNotExist, "no archive file for %s: %w", path, fs.ErrNotExist,
) )
} }
@@ -1,4 +1,4 @@
package archive_test package delivery_test
import ( import (
"bytes" "bytes"
@@ -14,7 +14,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gorm.io/gorm" "gorm.io/gorm"
gormlogger "gorm.io/gorm/logger" gormlogger "gorm.io/gorm/logger"
"sneak.berlin/go/webhooker/internal/archive" "sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/middleware" "sneak.berlin/go/webhooker/internal/middleware"
) )
@@ -57,7 +57,7 @@ func (g *gormDefaultBuf) String() string {
// without exporting production code to carry it, and a logging // without exporting production code to carry it, and a logging
// detector is not worth a production symbol. What it detects is the // detector is not worth a production symbol. What it detects is the
// third gorm.Open in this service, at // third gorm.Open in this service, at
// internal/archive/writer.go — the archive writer, // internal/delivery/target_database_archive.go — the archive writer,
// whose type is unexported, so nothing outside this package can drive // whose type is unexported, so nothing outside this package can drive
// it. // it.
func captureArchiveGORMDefault(t *testing.T) *gormDefaultBuf { func captureArchiveGORMDefault(t *testing.T) *gormDefaultBuf {
@@ -85,7 +85,7 @@ func captureArchiveGORMDefault(t *testing.T) *gormDefaultBuf {
// writer's gorm.Open to the adapter. // writer's gorm.Open to the adapter.
// //
// Restore a bare &gorm.Config{} at // Restore a bare &gorm.Config{} at
// internal/archive/writer.go and this fails: the // internal/delivery/target_database_archive.go and this fails: the
// default logger prints the fully interpolated SELECT on every // default logger prints the fully interpolated SELECT on every
// ErrRecordNotFound, so the client-chosen event id below arrives whole // ErrRecordNotFound, so the client-chosen event id below arrives whole
// and unbounded on stdout, answering to no level the operator set. // and unbounded on stdout, answering to no level the operator set.
@@ -100,7 +100,7 @@ func TestArchiveWriter_NeverUsesGORMsDefaultLogger(t *testing.T) {
gormDefault := captureArchiveGORMDefault(t) gormDefault := captureArchiveGORMDefault(t)
w := archive.NewExportArchiveWriter( w := delivery.NewExportArchiveWriter(
filepath.Join(t.TempDir(), "archive.db"), filepath.Join(t.TempDir(), "archive.db"),
slog.New(slog.NewTextHandler( slog.New(slog.NewTextHandler(
&captured, &slog.HandlerOptions{Level: slog.LevelDebug}, &captured, &slog.HandlerOptions{Level: slog.LevelDebug},
@@ -118,7 +118,7 @@ func TestArchiveWriter_NeverUsesGORMsDefaultLogger(t *testing.T) {
value := strings.Repeat("\x01", archiveGORMFillBytes) + value := strings.Repeat("\x01", archiveGORMFillBytes) +
archiveGORMTailMarker archiveGORMTailMarker
var row archive.ExportArchivedEvent var row delivery.ExportArchivedEvent
err := w.DB().Where("event_id = ?", value).First(&row).Error err := w.DB().Where("event_id = ?", value).First(&row).Error
require.ErrorIs(t, err, gorm.ErrRecordNotFound) require.ErrorIs(t, err, gorm.ErrRecordNotFound)
@@ -1,6 +1,7 @@
package archive_test package delivery_test
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
@@ -12,8 +13,8 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
) )
// deliverTo archives one event to a database target, leaving the // deliverTo archives one event to a database target, leaving the
@@ -23,9 +24,12 @@ func deliverTo(
) { ) {
t.Helper() t.Helper()
event := newEvent(`{"archived":true}`) webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"archived":true}`)
require.NoError(t, env.writers.Write(databaseTargetDelivery(event, tgt))) env.eng.ExportDeliverDatabase(
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
)
} }
// TestEvictWebhook_ClosesAndRemovesWriter proves that evicting // TestEvictWebhook_ClosesAndRemovesWriter proves that evicting
@@ -37,28 +41,28 @@ func TestEvictWebhook_ClosesAndRemovesWriter(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupArchiveTest(t)
first := env.seedDatabaseTarget(t, 0, "") first := env.seedDatabaseTarget(t, "")
second := env.addDatabaseTarget(t, first.WebhookID, 0, "") second := env.addDatabaseTarget(t, first.WebhookID, "")
other := env.seedDatabaseTarget(t, 0, "") other := env.seedDatabaseTarget(t, "")
for _, tgt := range []*database.Target{first, second, other} { for _, tgt := range []*database.Target{first, second, other} {
deliverTo(t, env, tgt) deliverTo(t, env, tgt)
require.True( require.True(
t, env.writers.ExportArchiveHandleOpen(tgt.ID), t, env.eng.ExportArchiveHandleOpen(tgt.ID),
"the writer should hold an open handle after a write", "the writer should hold an open handle after a write",
) )
} }
env.writers.EvictWebhook(first.WebhookID) env.eng.EvictWebhook(first.WebhookID)
for _, tgt := range []*database.Target{first, second} { for _, tgt := range []*database.Target{first, second} {
assert.False( assert.False(
t, env.writers.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"eviction should remove the registry entry", "eviction should remove the registry entry",
) )
assert.False( assert.False(
t, env.writers.ExportArchiveHandleOpen(tgt.ID), t, env.eng.ExportArchiveHandleOpen(tgt.ID),
"eviction should close the archive handle", "eviction should close the archive handle",
) )
assert.FileExists( assert.FileExists(
@@ -68,7 +72,7 @@ func TestEvictWebhook_ClosesAndRemovesWriter(t *testing.T) {
} }
assert.True( assert.True(
t, env.writers.ExportArchiveHandleOpen(other.ID), t, env.eng.ExportArchiveHandleOpen(other.ID),
"another webhook's writer must be left alone", "another webhook's writer must be left alone",
) )
} }
@@ -80,21 +84,21 @@ func TestEvictTarget_LeavesOtherTargets(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupArchiveTest(t)
doomed := env.seedDatabaseTarget(t, 0, "") doomed := env.seedDatabaseTarget(t, "")
kept := env.addDatabaseTarget(t, doomed.WebhookID, 0, "") kept := env.addDatabaseTarget(t, doomed.WebhookID, "")
deliverTo(t, env, doomed) deliverTo(t, env, doomed)
deliverTo(t, env, kept) deliverTo(t, env, kept)
env.writers.EvictTarget(doomed.ID) env.eng.EvictTarget(doomed.ID)
assert.False(t, env.writers.ExportHasArchiveWriter(doomed.ID)) assert.False(t, env.eng.ExportHasArchiveWriter(doomed.ID))
assert.FileExists( assert.FileExists(
t, env.archivePath(doomed), t, env.archivePath(doomed),
"eviction must not delete the archive file", "eviction must not delete the archive file",
) )
assert.True( assert.True(
t, env.writers.ExportArchiveHandleOpen(kept.ID), t, env.eng.ExportArchiveHandleOpen(kept.ID),
"the other target's writer must be left alone", "the other target's writer must be left alone",
) )
} }
@@ -108,21 +112,21 @@ func TestEvictWebhook_UnknownWebhookIsNoOp(t *testing.T) {
env := setupArchiveTest(t) env := setupArchiveTest(t)
assert.NotPanics(t, func() { assert.NotPanics(t, func() {
env.writers.EvictWebhook("no-such-webhook") env.eng.EvictWebhook("no-such-webhook")
env.writers.EvictWebhook("no-such-webhook") env.eng.EvictWebhook("no-such-webhook")
env.writers.EvictTarget("no-such-target") env.eng.EvictTarget("no-such-target")
env.writers.EvictTarget("no-such-target") env.eng.EvictTarget("no-such-target")
}) })
assert.False( assert.False(
t, env.writers.ExportHasArchiveWriter("no-such-target"), t, env.eng.ExportHasArchiveWriter("no-such-target"),
"eviction must not create a writer", "eviction must not create a writer",
) )
} }
// evictTestRow builds an archive row for the eviction tests. // evictTestRow builds an archive row for the eviction tests.
func evictTestRow(eventID string) archive.ExportArchivedEvent { func evictTestRow(eventID string) delivery.ExportArchivedEvent {
return archive.ExportArchivedEvent{ return delivery.ExportArchivedEvent{
EventID: eventID, EventID: eventID,
WebhookID: "wh-evict", WebhookID: "wh-evict",
Method: http.MethodPost, Method: http.MethodPost,
@@ -143,7 +147,7 @@ func TestEvictedWriter_WriteDoesNotReopenFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "archive-evicted.db") path := filepath.Join(t.TempDir(), "archive-evicted.db")
w := archive.NewExportArchiveWriter( w := delivery.NewExportArchiveWriter(
path, archiveTestLogger(), 0, path, archiveTestLogger(), 0,
) )
@@ -160,7 +164,7 @@ func TestEvictedWriter_WriteDoesNotReopenFile(t *testing.T) {
err := w.Write(evictTestRow("ev-2"), 0) err := w.Write(evictTestRow("ev-2"), 0)
require.ErrorIs( require.ErrorIs(
t, err, archive.ErrExportArchiveWriterEvicted, t, err, delivery.ErrExportArchiveWriterEvicted,
"an evicted writer must refuse writes", "an evicted writer must refuse writes",
) )
assert.NoFileExists( assert.NoFileExists(
@@ -183,7 +187,7 @@ func TestEvictedWriter_SweepDoesNotReopenFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "archive-evicted.db") path := filepath.Join(t.TempDir(), "archive-evicted.db")
w := archive.NewExportArchiveWriter( w := delivery.NewExportArchiveWriter(
path, archiveTestLogger(), 0, path, archiveTestLogger(), 0,
) )
@@ -195,7 +199,7 @@ func TestEvictedWriter_SweepDoesNotReopenFile(t *testing.T) {
err := w.SweepExpired(time.Hour) err := w.SweepExpired(time.Hour)
require.ErrorIs( require.ErrorIs(
t, err, archive.ErrExportArchiveWriterEvicted, t, err, delivery.ErrExportArchiveWriterEvicted,
"an evicted writer must refuse an idle sweep", "an evicted writer must refuse an idle sweep",
) )
assert.False( assert.False(
@@ -226,7 +230,7 @@ const racingWriteGoroutines = 4
// against a hang, not a timing assumption: the first write after // against a hang, not a timing assumption: the first write after
// the eviction is refused. // the eviction is refused.
func startRacingWrites( func startRacingWrites(
w *archive.ExportArchiveWriter, w *delivery.ExportArchiveWriter,
) *racingWrites { ) *racingWrites {
r := &racingWrites{ r := &racingWrites{
started: make(chan struct{}, racingWriteGoroutines), started: make(chan struct{}, racingWriteGoroutines),
@@ -272,7 +276,7 @@ func (r *racingWrites) record(err error) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
if errors.Is(err, archive.ErrExportArchiveWriterEvicted) { if errors.Is(err, delivery.ErrExportArchiveWriterEvicted) {
r.sawEvicted = true r.sawEvicted = true
return return
@@ -303,20 +307,20 @@ func (r *racingWrites) wait() (bool, error) {
// contending for the writer's mutex when the eviction takes it. // contending for the writer's mutex when the eviction takes it.
// The write that wins the mutex after the eviction must abandon // The write that wins the mutex after the eviction must abandon
// its work rather than reopen the archive, leaving the writer // its work rather than reopen the archive, leaving the writer
// permanently handle-free. // permanently handle-free. Run under -race.
func TestEvictWebhook_RacingWriteDoesNotReopenHandle( func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, "") tgt := env.seedDatabaseTarget(t, "")
// Prime the registry so the test can hold the very writer the // Prime the registry so the test can hold the very writer the
// eviction is about to detach. // eviction is about to detach.
deliverTo(t, env, tgt) deliverTo(t, env, tgt)
w := env.writers.ExportArchiveWriterFor(tgt.ID) w := env.eng.ExportArchiveWriterFor(tgt.ID)
require.NotNil(t, w) require.NotNil(t, w)
require.True(t, w.HandleOpen()) require.True(t, w.HandleOpen())
@@ -326,7 +330,7 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
// eviction has to contend for the writer's mutex. // eviction has to contend for the writer's mutex.
race.awaitFirstWrite() race.awaitFirstWrite()
env.writers.EvictWebhook(tgt.WebhookID) env.eng.EvictWebhook(tgt.WebhookID)
sawEvicted, otherErr := race.wait() sawEvicted, otherErr := race.wait()
@@ -341,7 +345,7 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
"been evicted", "been evicted",
) )
assert.False( assert.False(
t, env.writers.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"the registry entry must stay gone", "the registry entry must stay gone",
) )
} }
@@ -355,45 +359,45 @@ func TestEvictWebhook_LaterDeliveryRecreatesWriter(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, "") tgt := env.seedDatabaseTarget(t, "")
deliverTo(t, env, tgt) deliverTo(t, env, tgt)
require.True(t, env.writers.ExportHasArchiveWriter(tgt.ID)) require.True(t, env.eng.ExportHasArchiveWriter(tgt.ID))
env.writers.EvictWebhook(tgt.WebhookID) env.eng.EvictWebhook(tgt.WebhookID)
// A fresh delivery for the same target gets a brand new // A fresh delivery for the same target gets a brand new
// writer from the registry, so archiving keeps working. // writer from the registry, so archiving keeps working.
deliverTo(t, env, tgt) deliverTo(t, env, tgt)
assert.True( assert.True(
t, env.writers.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"a later delivery should recreate the writer", "a later delivery should recreate the writer",
) )
} }
// TestEvictAll_WriteAfterIsRefused proves that EvictAll, which the // TestEngineStop_WriteAfterStopIsRefused proves the engine's stop
// delivery engine calls when it stops, closes each archive writer the // closes each archive writer the way deleting its webhook does: a
// way deleting its webhook does: a write that reaches a writer after // write that reaches a writer after the stop is refused, reopens
// it is refused, reopens nothing and adds no row. // nothing and adds no row.
func TestEvictAll_WriteAfterIsRefused(t *testing.T) { func TestEngineStop_WriteAfterStopIsRefused(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, "") tgt := env.seedDatabaseTarget(t, "")
deliverTo(t, env, tgt) deliverTo(t, env, tgt)
w := env.writers.ExportArchiveWriterFor(tgt.ID) w := env.eng.ExportArchiveWriterFor(tgt.ID)
require.NotNil(t, w) require.NotNil(t, w)
require.True(t, w.HandleOpen()) require.True(t, w.HandleOpen())
env.writers.EvictAll() require.NoError(t, env.eng.ExportStop(context.Background()))
err := w.Write(evictTestRow("ev-after-stop"), 0) err := w.Write(evictTestRow("ev-after-stop"), 0)
require.ErrorIs( require.ErrorIs(
t, err, archive.ErrExportArchiveWriterEvicted, t, err, delivery.ErrExportArchiveWriterEvicted,
"a write after the stop must be refused", "a write after the stop must be refused",
) )
assert.False( assert.False(
@@ -401,7 +405,7 @@ func TestEvictAll_WriteAfterIsRefused(t *testing.T) {
"a refused write must not reopen the archive", "a refused write must not reopen the archive",
) )
assert.False( assert.False(
t, env.writers.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"the stop should empty the registry", "the stop should empty the registry",
) )
@@ -1,4 +1,4 @@
package archive package delivery
import ( import (
"compress/gzip" "compress/gzip"
@@ -28,25 +28,25 @@ import (
const archiveTableQuery = "SELECT count(*) FROM sqlite_master " + const archiveTableQuery = "SELECT count(*) FROM sqlite_master " +
"WHERE type = 'table' AND name = 'archived_events'" "WHERE type = 'table' AND name = 'archived_events'"
// Path returns where a database target's archive file is: in // ArchivePath returns where a database target's archive file is: in
// the data directory, beside the webhook's event database, under the // the data directory, beside the webhook's event database, under the
// name FileName gives it. // name ArchiveFileName gives it.
func Path( func ArchivePath(
dbMgr *database.WebhookDBManager, dbMgr *database.WebhookDBManager,
webhook *database.Webhook, webhook *database.Webhook,
target *database.Target, target *database.Target,
) string { ) string {
return filepath.Join( return filepath.Join(
filepath.Dir(dbMgr.DBPath(webhook.ID)), filepath.Dir(dbMgr.DBPath(webhook.ID)),
FileName(webhook.Name, target.Name, target.ID), ArchiveFileName(webhook.Name, target.Name, target.ID),
) )
} }
// ExportFileName returns the name a database target's archive // ArchiveExportFileName returns the name a database target's archive
// downloads under: // downloads under:
// archive-WEBHOOKNAME-TARGETNAME-YYYYMMDDTHHMMSSZ.json.gz, the names // archive-WEBHOOKNAME-TARGETNAME-YYYYMMDDTHHMMSSZ.json.gz, the names
// made safe as in FileName and the time in UTC. // made safe as in ArchiveFileName and the time in UTC.
func ExportFileName( func ArchiveExportFileName(
webhookName, targetName string, at time.Time, webhookName, targetName string, at time.Time,
) string { ) string {
return "archive-" + archiveNamePart(webhookName) + "-" + return "archive-" + archiveNamePart(webhookName) + "-" +
@@ -54,7 +54,7 @@ func ExportFileName(
at.UTC().Format("20060102T150405Z") + ".json.gz" at.UTC().Format("20060102T150405Z") + ".json.gz"
} }
// Export is a database target's archive listed for download. It // ArchiveExport is a database target's archive listed for download. It
// opens one of the target's files at a time, only when its rows are // opens one of the target's files at a time, only when its rows are
// about to be written out, and closes it before it opens the next, so // about to be written out, and closes it before it opens the next, so
// an export holds at most one file open however many the target has. // an export holds at most one file open however many the target has.
@@ -66,7 +66,7 @@ func ExportFileName(
// is open, and the export does not see them. SQLite cannot checkpoint // 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 // a -wal past an open snapshot, so the open file's -wal grows until the
// export has written that file out. // export has written that file out.
type Export struct { type ArchiveExport struct {
// periods are the periods of the target's files when the export // periods are the periods of the target's files when the export
// was listed, "" for the file without one, in the order // was listed, "" for the file without one, in the order
// archiveFiles lists them. // archiveFiles lists them.
@@ -77,7 +77,7 @@ type Export struct {
// between. // between.
lock sync.Locker lock sync.Locker
// currentPath returns the path Path gives the target under // currentPath returns the path ArchivePath gives the target under
// the names stored for it now, which a rename may have changed since // the names stored for it now, which a rename may have changed since
// the export was listed. // the export was listed.
currentPath func() (string, error) currentPath func() (string, error)
@@ -103,12 +103,11 @@ type exportedName struct {
Name string `json:"name"` Name string `json:"name"`
} }
// NewExport lists a database target's archive files for export, // NewArchiveExport lists a database target's archive files for export,
// given the path Path gives it (see archiveFiles). It opens none // given the path ArchivePath gives it (see archiveFiles). It opens none
// of them. Its caller holds lock, which every rename of the target's // of them. Its caller holds lock, which every rename of the target's
// files runs under (Writers.RenameLock returns it), from reading the names // files runs under, from reading the names path is made of until it
// path is made of until it returns, so the files it lists are the ones // returns, so the files it lists are the ones those names give.
// those names give.
// //
// WriteGzipJSON, called without lock held, finds each file again by its // WriteGzipJSON, called without lock held, finds each file again by its
// period under the path currentPath gives, holding lock while it does // period under the path currentPath gives, holding lock while it does
@@ -116,18 +115,18 @@ type exportedName struct {
// file. A file that is gone by then, emptied by the sweep or moved // file. A file that is gone by then, emptied by the sweep or moved
// away, is skipped. The export never creates a file: with no files, it // away, is skipped. The export never creates a file: with no files, it
// has no rows. // has no rows.
func NewExport( func NewArchiveExport(
path string, path string,
lock sync.Locker, lock sync.Locker,
currentPath func() (string, error), currentPath func() (string, error),
log *slog.Logger, log *slog.Logger,
) (*Export, error) { ) (*ArchiveExport, error) {
files, err := archiveFiles(path) files, err := archiveFiles(path)
if err != nil { if err != nil {
return nil, err return nil, err
} }
x := &Export{lock: lock, currentPath: currentPath, log: log} x := &ArchiveExport{lock: lock, currentPath: currentPath, log: log}
for _, file := range files { for _, file := range files {
x.periods = append(x.periods, file.period) x.periods = append(x.periods, file.period)
@@ -200,7 +199,7 @@ func openExportFile(
// closed once its rows are written, before the next is opened. When it // closed once its rows are written, before the next is opened. When it
// returns, no file is open. After an error the gzip stream is left // 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. // unfinished, so what was written does not decompress as a whole file.
func (x *Export) WriteGzipJSON( func (x *ArchiveExport) WriteGzipJSON(
ctx context.Context, ctx context.Context,
w io.Writer, w io.Writer,
webhook *database.Webhook, webhook *database.Webhook,
@@ -229,7 +228,7 @@ func (x *Export) WriteGzipJSON(
// openFile finds the target's archive file for period under the path // openFile finds the target's archive file for period under the path
// currentPath gives now, and opens it for the export, holding x.lock // currentPath gives now, and opens it for the export, holding x.lock
// for both. For a file that is gone, the error wraps fs.ErrNotExist. // for both. For a file that is gone, the error wraps fs.ErrNotExist.
func (x *Export) openFile( func (x *ArchiveExport) openFile(
ctx context.Context, period string, ctx context.Context, period string,
) (*exportFile, error) { ) (*exportFile, error) {
x.lock.Lock() x.lock.Lock()
@@ -259,7 +258,7 @@ func (f *exportFile) close() error {
// writeJSON writes head with archived_events added as its last key, // writeJSON writes head with archived_events added as its last key,
// the rows going into it one at a time. // the rows going into it one at a time.
func (x *Export) writeJSON( func (x *ArchiveExport) writeJSON(
ctx context.Context, w io.Writer, head []byte, ctx context.Context, w io.Writer, head []byte,
) error { ) error {
// head goes out without its closing brace, so that // head goes out without its closing brace, so that
@@ -287,7 +286,7 @@ func (x *Export) writeJSON(
// writeRows writes the archived rows of each file to w, one per line, // writeRows writes the archived rows of each file to w, one per line,
// separated by commas, opening each file in turn and closing it once // separated by commas, opening each file in turn and closing it once
// its rows are written. // its rows are written.
func (x *Export) writeRows(ctx context.Context, w io.Writer) error { func (x *ArchiveExport) writeRows(ctx context.Context, w io.Writer) error {
sep := "\n" sep := "\n"
for _, period := range x.periods { for _, period := range x.periods {
@@ -1,4 +1,4 @@
package archive_test package delivery_test
import ( import (
"bufio" "bufio"
@@ -19,8 +19,8 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
) )
// The webhook and the target the export tests' archives belong to. // The webhook and the target the export tests' archives belong to.
@@ -37,7 +37,7 @@ const binaryBody = "\xff\xfe\x00\x01binary\x80"
// 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.
func writeExportTo( func writeExportTo(
t *testing.T, export *archive.Export, w io.Writer, t *testing.T, export *delivery.ArchiveExport, w io.Writer,
) error { ) error {
t.Helper() t.Helper()
@@ -57,7 +57,7 @@ func writeExportTo(
// listExport lists the archive at path for export, as the archive of a // listExport lists the archive at path for export, as the archive of a
// target whose names do not change. // target whose names do not change.
func listExport(t *testing.T, path string) *archive.Export { func listExport(t *testing.T, path string) *delivery.ArchiveExport {
t.Helper() t.Helper()
return newExport(t, path, &sync.Mutex{}, func() (string, error) { return newExport(t, path, &sync.Mutex{}, func() (string, error) {
@@ -73,10 +73,10 @@ func newExport(
path string, path string,
lock sync.Locker, lock sync.Locker,
currentPath func() (string, error), currentPath func() (string, error),
) *archive.Export { ) *delivery.ArchiveExport {
t.Helper() t.Helper()
export, err := archive.NewExport( export, err := delivery.NewArchiveExport(
path, lock, currentPath, archiveTestLogger(), path, lock, currentPath, archiveTestLogger(),
) )
require.NoError(t, err) require.NoError(t, err)
@@ -96,7 +96,7 @@ func exportArchive(t *testing.T, path string) map[string]any {
// decompressed and parsed. Reading to the end makes the gzip reader // decompressed and parsed. Reading to the end makes the gzip reader
// check that the stream was finished. // check that the stream was finished.
func writeExport( func writeExport(
t *testing.T, export *archive.Export, t *testing.T, export *delivery.ArchiveExport,
) map[string]any { ) map[string]any {
t.Helper() t.Helper()
@@ -157,11 +157,11 @@ func TestArchiveExport_MatchesStoredRows(t *testing.T) {
t.Parallel() t.Parallel()
path := filepath.Join(t.TempDir(), "archive.db") path := filepath.Join(t.TempDir(), "archive.db")
w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0) w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
bodies := []string{`{"order":1}`, "plain text", "", binaryBody} bodies := []string{`{"order":1}`, "plain text", "", binaryBody}
for i, body := range bodies { for i, body := range bodies {
require.NoError(t, w.Write(archive.ExportArchivedEvent{ require.NoError(t, w.Write(delivery.ExportArchivedEvent{
EventID: fmt.Sprintf("ev-%d", i), EventID: fmt.Sprintf("ev-%d", i),
WebhookID: exportWebhookID, WebhookID: exportWebhookID,
EntrypointID: "ep-1", EntrypointID: "ep-1",
@@ -173,7 +173,7 @@ func TestArchiveExport_MatchesStoredRows(t *testing.T) {
}, 0)) }, 0))
} }
var stored []archive.ExportArchivedEvent var stored []delivery.ExportArchivedEvent
require.NoError(t, openArchiveDBForRead(t, path). require.NoError(t, openArchiveDBForRead(t, path).
Order("id").Find(&stored).Error) Order("id").Find(&stored).Error)
@@ -201,7 +201,7 @@ func TestArchiveExport_MatchesStoredRows(t *testing.T) {
// assertExportedRow checks that ev, from an export, holds every column // assertExportedRow checks that ev, from an export, holds every column
// of the stored row. // of the stored row.
func assertExportedRow( func assertExportedRow(
t *testing.T, row archive.ExportArchivedEvent, ev map[string]any, t *testing.T, row delivery.ExportArchivedEvent, ev map[string]any,
) { ) {
t.Helper() t.Helper()
@@ -248,7 +248,7 @@ func TestArchiveExport_Empty(t *testing.T) {
require.NoError(t, os.WriteFile(noTable, nil, 0o600)) require.NoError(t, os.WriteFile(noTable, nil, 0o600))
require.NoError(t, require.NoError(t,
archive.NewExportArchiveWriter(noRows, archiveTestLogger(), 0). delivery.NewExportArchiveWriter(noRows, archiveTestLogger(), 0).
Open(0), Open(0),
) )
@@ -284,18 +284,18 @@ 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 := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0) w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
require.NoError(t, w.Write(archive.ExportArchivedEvent{EventID: "listed"}, 0)) require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "listed"}, 0))
opened := &unlockHook{fn: func() { opened := &unlockHook{fn: func() {
require.NoError(t, w.Write(archive.ExportArchivedEvent{EventID: "during"}, 0)) require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "during"}, 0))
}} }}
export := newExport(t, path, opened, func() (string, error) { export := newExport(t, path, opened, func() (string, error) {
return path, nil return path, nil
}) })
require.NoError(t, w.Write(archive.ExportArchivedEvent{EventID: "before-open"}, 0)) require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "before-open"}, 0))
assert.Equal(t, assert.Equal(t,
[]string{"listed", "before-open"}, []string{"listed", "before-open"},
@@ -305,7 +305,7 @@ func TestArchiveExport_ReadsOneSnapshot(t *testing.T) {
var stored int64 var stored int64
require.NoError(t, openArchiveDBForRead(t, path). require.NoError(t, openArchiveDBForRead(t, path).
Model(&archive.ExportArchivedEvent{}).Count(&stored).Error) Model(&delivery.ExportArchivedEvent{}).Count(&stored).Error)
assert.Equal(t, int64(3), stored) assert.Equal(t, int64(3), stored)
} }
@@ -318,11 +318,11 @@ func TestArchiveExport_FindsFilesAfterRename(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "archive-old.db") path := filepath.Join(dir, "archive-old.db")
w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0) w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
for _, period := range []string{"", dayPeriod, hourPeriod} { for _, period := range []string{"", dayPeriod, hourPeriod} {
require.NoError(t, w.WritePeriod( require.NoError(t, w.WritePeriod(
archive.ExportArchivedEvent{EventID: "in-" + period}, 0, period, delivery.ExportArchivedEvent{EventID: "in-" + period}, 0, period,
)) ))
} }
@@ -353,19 +353,19 @@ func TestArchiveExport_EveryFileOldestFirst(t *testing.T) {
t.Parallel() t.Parallel()
path := filepath.Join(t.TempDir(), "archive-wh.db") path := filepath.Join(t.TempDir(), "archive-wh.db")
w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0) w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
// Written in an order that is not the export's. // Written in an order that is not the export's.
for _, period := range []string{nextDayPeriod, "", hourPeriod, "2026-03"} { for _, period := range []string{nextDayPeriod, "", hourPeriod, "2026-03"} {
require.NoError(t, w.WritePeriod( require.NoError(t, w.WritePeriod(
archive.ExportArchivedEvent{EventID: "in-" + period}, 0, period, delivery.ExportArchivedEvent{EventID: "in-" + period}, 0, period,
)) ))
} }
export := listExport(t, path) export := listExport(t, path)
require.NoError(t, w.WritePeriod( require.NoError(t, w.WritePeriod(
archive.ExportArchivedEvent{EventID: "later"}, 0, "2026-03-06", delivery.ExportArchivedEvent{EventID: "later"}, 0, "2026-03-06",
)) ))
events := exportedEvents(t, writeExport(t, export)) events := exportedEvents(t, writeExport(t, export))
@@ -434,13 +434,13 @@ func TestArchiveExport_OneFileOpenAtATime(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
path := filepath.Join(dir, "archive-wh.db") path := filepath.Join(dir, "archive-wh.db")
w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0) w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
random := make([]byte, 36<<10) random := make([]byte, 36<<10)
for hour := range 24 { for hour := range 24 {
_, _ = rand.Read(random) _, _ = rand.Read(random)
require.NoError(t, w.WritePeriod(archive.ExportArchivedEvent{ require.NoError(t, w.WritePeriod(delivery.ExportArchivedEvent{
Body: base64.StdEncoding.EncodeToString(random), Body: base64.StdEncoding.EncodeToString(random),
}, 0, fmt.Sprintf("2026-10-01-%02d", hour))) }, 0, fmt.Sprintf("2026-10-01-%02d", hour)))
} }
@@ -489,7 +489,7 @@ func exportHeapGrowth(t *testing.T, rows, bodySize int) uint64 {
t.Helper() t.Helper()
path := filepath.Join(t.TempDir(), "archive.db") path := filepath.Join(t.TempDir(), "archive.db")
w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0) w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
// Base64 makes four characters of every three bytes. // Base64 makes four characters of every three bytes.
random := make([]byte, bodySize/4*3) random := make([]byte, bodySize/4*3)
@@ -497,7 +497,7 @@ func exportHeapGrowth(t *testing.T, rows, bodySize int) uint64 {
for range rows { for range rows {
_, _ = rand.Read(random) _, _ = rand.Read(random)
require.NoError(t, w.Write(archive.ExportArchivedEvent{ require.NoError(t, w.Write(delivery.ExportArchivedEvent{
Body: base64.StdEncoding.EncodeToString(random), Body: base64.StdEncoding.EncodeToString(random),
}, 0)) }, 0))
} }
@@ -565,7 +565,7 @@ func TestArchiveExportFileName(t *testing.T) {
assert.Equal(t, assert.Equal(t,
"archive-orders-eu-long-term-archive-20261002T120304Z.json.gz", "archive-orders-eu-long-term-archive-20261002T120304Z.json.gz",
archive.ExportFileName( delivery.ArchiveExportFileName(
exportWebhookName, exportTargetName, exportWebhookName, exportTargetName,
time.Date(2026, 10, 2, 14, 3, 4, 0, cest), time.Date(2026, 10, 2, 14, 3, 4, 0, cest),
), ),
@@ -1,6 +1,7 @@
package archive package delivery
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"os" "os"
@@ -40,10 +41,10 @@ type archiveFile struct {
period string period string
} }
// ValidateRotation checks a user-supplied archive rotation for // ValidateArchiveRotation checks a user-supplied archive rotation for
// a database target: empty or none (both meaning one file), monthly, // a database target: empty or none (both meaning one file), monthly,
// daily or hourly. // daily or hourly.
func ValidateRotation(rotation string) error { func ValidateArchiveRotation(rotation string) error {
switch rotation { switch rotation {
case "", archiveRotationNone, archiveRotationMonthly, case "", archiveRotationNone, archiveRotationMonthly,
archiveRotationDaily, archiveRotationHourly: archiveRotationDaily, archiveRotationHourly:
@@ -53,10 +54,35 @@ func ValidateRotation(rotation string) error {
} }
} }
// 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 // archivePeriod returns the period, in UTC, that a rotation puts an
// event received at receivedAt in: "2026-10" for monthly, // event received at receivedAt in: "2026-10" for monthly,
// "2026-10-01" for daily, "2026-10-01-19" for hourly, and "" for none // "2026-10-01" for daily, "2026-10-01-19" for hourly, and "" for none.
// or an empty rotation.
func archivePeriod(rotation string, receivedAt time.Time) string { func archivePeriod(rotation string, receivedAt time.Time) string {
switch rotation { switch rotation {
case archiveRotationMonthly: case archiveRotationMonthly:
@@ -71,7 +97,7 @@ func archivePeriod(rotation string, receivedAt time.Time) string {
} }
// archivePeriodPath returns the path of a database target's archive // archivePeriodPath returns the path of a database target's archive
// file for a period: path, as Path gives it, with "-" and the // file for a period: path, as ArchivePath gives it, with "-" and the
// period put before its ".db". The period "" gives path itself. // period put before its ".db". The period "" gives path itself.
func archivePeriodPath(path, period string) string { func archivePeriodPath(path, period string) string {
if period == "" { if period == "" {
@@ -81,23 +107,28 @@ func archivePeriodPath(path, period string) string {
return strings.TrimSuffix(path, ".db") + "-" + period + ".db" return strings.TrimSuffix(path, ".db") + "-" + period + ".db"
} }
// PathAt returns the archive file a database target writes an // ArchivePathAt returns the archive file a database target writes an
// event received at receivedAt to: Path's file, with the // event received at receivedAt to: ArchivePath's file, with the
// period in its name when the target rotates. // period in its name when the target rotates.
func PathAt( func ArchivePathAt(
dbMgr *database.WebhookDBManager, dbMgr *database.WebhookDBManager,
webhook *database.Webhook, webhook *database.Webhook,
target *database.Target, target *database.Target,
receivedAt time.Time, receivedAt time.Time,
) string { ) (string, error) {
rotation, err := parseArchiveRotation(target.Config)
if err != nil {
return "", err
}
return archivePeriodPath( return archivePeriodPath(
Path(dbMgr, webhook, target), ArchivePath(dbMgr, webhook, target),
archivePeriod(target.ArchiveRotation, receivedAt), archivePeriod(rotation, receivedAt),
) ), nil
} }
// archiveFiles lists the database target's archive files that exist, // archiveFiles lists the database target's archive files that exist,
// given the path Path gives it: the file at path, then each // given the path ArchivePath gives it: the file at path, then each
// file archivePeriodPath names for path and a period, oldest period // file archivePeriodPath names for path and a period, oldest period
// first. Which rotation wrote a file does not matter, so the files of // first. Which rotation wrote a file does not matter, so the files of
// an earlier rotation setting are listed too. // an earlier rotation setting are listed too.
@@ -1,4 +1,4 @@
package archive_test package delivery_test
import ( import (
"os" "os"
@@ -9,17 +9,21 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/database/databasetest" "sneak.berlin/go/webhooker/internal/database/databasetest"
"sneak.berlin/go/webhooker/internal/delivery"
) )
// The archive rotations. // The archive rotations, and the configs of a daily target and of one
// whose rotation is not one of the four.
const ( const (
rotationNone = "none" rotationNone = "none"
rotationMonthly = "monthly" rotationMonthly = "monthly"
rotationDaily = "daily" rotationDaily = "daily"
rotationHourly = "hourly" rotationHourly = "hourly"
dailyConfig = `{"rotation":"daily"}`
weeklyConfig = `{"rotation":"weekly"}`
) )
// The periods the tests archive into most: two days, and an hour of // The periods the tests archive into most: two days, and an hour of
@@ -36,30 +40,32 @@ func periodPath(path, period string) string {
return strings.TrimSuffix(path, ".db") + "-" + period + ".db" return strings.TrimSuffix(path, ".db") + "-" + period + ".db"
} }
// deliverReceivedAt archives, as a delivery to a database target // deliverReceivedAt delivers to a database target an event whose
// does, an event whose receive time is receivedAt, and returns the // receive time is receivedAt, and returns the event's id. The receive
// event's id. The receive time is what decides a rotated archive's // time is what decides a rotated archive's file, so setting it is how
// file, so setting it is how these tests move the clock across a // these tests move the clock across a period boundary.
// period boundary.
func (env *archiveEnv) deliverReceivedAt( func (env *archiveEnv) deliverReceivedAt(
t *testing.T, tgt *database.Target, receivedAt time.Time, t *testing.T, tgt *database.Target, receivedAt time.Time,
) string { ) string {
t.Helper() t.Helper()
event := newEvent(`{"n":1}`) webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`)
event.CreatedAt = receivedAt event.CreatedAt = receivedAt
require.NoError(t, env.writers.Write(databaseTargetDelivery(event, tgt))) env.eng.ExportDeliverDatabase(
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
)
return event.ID return event.ID
} }
// TestWrite_RotatesAtEachPeriodBoundary writes, for each // TestDeliverDatabase_RotatesAtEachPeriodBoundary delivers, for each
// rotation, an event received in the last second of a period and one // 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 // 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 // the file named for its own period, in UTC. Rotation none keeps both
// in the one file. // in the one file.
func TestWrite_RotatesAtEachPeriodBoundary(t *testing.T) { func TestDeliverDatabase_RotatesAtEachPeriodBoundary(t *testing.T) {
t.Parallel() t.Parallel()
berlin := time.FixedZone("CEST", 2*60*60) berlin := time.FixedZone("CEST", 2*60*60)
@@ -104,7 +110,7 @@ func TestWrite_RotatesAtEachPeriodBoundary(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, tc.rotation) tgt := env.seedDatabaseTarget(t, `{"rotation":"`+tc.rotation+`"}`)
path := env.archivePath(tgt) path := env.archivePath(tgt)
first := env.deliverReceivedAt(t, tgt, tc.before) first := env.deliverReceivedAt(t, tgt, tc.before)
@@ -123,7 +129,7 @@ func TestWrite_RotatesAtEachPeriodBoundary(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, rotationNone) tgt := env.seedDatabaseTarget(t, `{"rotation":"`+rotationNone+`"}`)
first := env.deliverReceivedAt(t, tgt, cases[0].before) first := env.deliverReceivedAt(t, tgt, cases[0].before)
second := env.deliverReceivedAt(t, tgt, cases[0].after) second := env.deliverReceivedAt(t, tgt, cases[0].after)
@@ -133,14 +139,14 @@ func TestWrite_RotatesAtEachPeriodBoundary(t *testing.T) {
}) })
} }
// TestWrite_EventWithoutReceiveTime proves an event whose // TestDeliverDatabase_EventWithoutReceiveTime proves an event whose
// receive time is not known goes to the file for the time it is // receive time is not known goes to the file for the time it is
// archived, rather than to one for the year 1. // archived, rather than to one for the year 1.
func TestWrite_EventWithoutReceiveTime(t *testing.T) { func TestDeliverDatabase_EventWithoutReceiveTime(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, rotationMonthly) tgt := env.seedDatabaseTarget(t, `{"rotation":"`+rotationMonthly+`"}`)
path := env.archivePath(tgt) path := env.archivePath(tgt)
before := time.Now().UTC().Format("2006-01") before := time.Now().UTC().Format("2006-01")
@@ -158,20 +164,20 @@ func TestWrite_EventWithoutReceiveTime(t *testing.T) {
assert.NoFileExists(t, periodPath(path, "0001-01")) assert.NoFileExists(t, periodPath(path, "0001-01"))
} }
// TestWrite_RotationChangeKeepsOldFiles changes a target's // TestDeliverDatabase_RotationChangeKeepsOldFiles changes a target's
// rotation from none to daily between two events, and checks the // rotation from none to daily between two events, and checks the
// second goes to the daily file while the first stays where it was. // second goes to the daily file while the first stays where it was.
func TestWrite_RotationChangeKeepsOldFiles(t *testing.T) { func TestDeliverDatabase_RotationChangeKeepsOldFiles(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, rotationNone) tgt := env.seedDatabaseTarget(t, "")
path := env.archivePath(tgt) path := env.archivePath(tgt)
at := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC) at := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
first := env.deliverReceivedAt(t, tgt, at) first := env.deliverReceivedAt(t, tgt, at)
tgt.ArchiveRotation = rotationDaily tgt.Config = dailyConfig
second := env.deliverReceivedAt(t, tgt, at) second := env.deliverReceivedAt(t, tgt, at)
assert.Equal(t, []string{first}, archivedEventIDs(t, path)) assert.Equal(t, []string{first}, archivedEventIDs(t, path))
@@ -189,7 +195,9 @@ func TestArchiveSweep_PrunesEveryFile(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, time.Hour, rotationDaily) tgt := env.seedDatabaseTarget(
t, `{"expiry":"1h","rotation":"`+rotationDaily+`"}`,
)
path := env.archivePath(tgt) path := env.archivePath(tgt)
emptied := periodPath(path, dayPeriod) emptied := periodPath(path, dayPeriod)
kept := periodPath(path, nextDayPeriod) kept := periodPath(path, nextDayPeriod)
@@ -219,17 +227,17 @@ func TestRename_MovesEveryFile(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, rotationNone) tgt := env.seedDatabaseTarget(t, "")
oldPath := env.archivePath(tgt) oldPath := env.archivePath(tgt)
day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC) day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
unrotated := env.deliverReceivedAt(t, tgt, day) unrotated := env.deliverReceivedAt(t, tgt, day)
tgt.ArchiveRotation = rotationDaily tgt.Config = dailyConfig
first := env.deliverReceivedAt(t, tgt, day) first := env.deliverReceivedAt(t, tgt, day)
second := env.deliverReceivedAt(t, tgt, day.Add(24*time.Hour)) second := env.deliverReceivedAt(t, tgt, day.Add(24*time.Hour))
require.NoError(t, env.writers.Rename(tgt.ID, "Orders", "Long Term")) require.NoError(t, env.eng.Rename(tgt.ID, "Orders", "Long Term"))
newPath := filepath.Join( newPath := filepath.Join(
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db", env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
@@ -261,7 +269,7 @@ func TestRename_NeverReplacesARotatedFile(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, rotationDaily) tgt := env.seedDatabaseTarget(t, dailyConfig)
oldPath := env.archivePath(tgt) oldPath := env.archivePath(tgt)
day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC) day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
@@ -275,8 +283,8 @@ func TestRename_NeverReplacesARotatedFile(t *testing.T) {
require.NoError(t, os.WriteFile(planted, []byte("planted"), 0o600)) require.NoError(t, os.WriteFile(planted, []byte("planted"), 0o600))
require.ErrorIs( require.ErrorIs(
t, env.writers.Rename(tgt.ID, "Orders", "Long Term"), t, env.eng.Rename(tgt.ID, "Orders", "Long Term"),
archive.ErrNameTaken, delivery.ErrArchiveNameTaken,
) )
assert.FileExists(t, periodPath(oldPath, dayPeriod)) assert.FileExists(t, periodPath(oldPath, dayPeriod))
@@ -284,7 +292,7 @@ func TestRename_NeverReplacesARotatedFile(t *testing.T) {
assert.NoFileExists(t, periodPath(newPath, dayPeriod)) assert.NoFileExists(t, periodPath(newPath, dayPeriod))
} }
// TestStatArchive_EveryFile proves Stat counts and adds up every // TestStatArchive_EveryFile proves StatArchive counts and adds up every
// one of a target's files, takes the latest write of any of them, and // 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. // leaves out files whose names only look like the target's.
func TestStatArchive_EveryFile(t *testing.T) { func TestStatArchive_EveryFile(t *testing.T) {
@@ -316,7 +324,7 @@ func TestStatArchive_EveryFile(t *testing.T) {
os.WriteFile(filepath.Join(dir, other), []byte("x"), 0o600)) os.WriteFile(filepath.Join(dir, other), []byte("x"), 0o600))
} }
got, err := archive.Stat(path) got, err := delivery.StatArchive(path)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, len(files), got.Files) assert.Equal(t, len(files), got.Files)
assert.Equal(t, size, got.Size) assert.Equal(t, size, got.Size)
@@ -336,26 +344,33 @@ func TestArchivePathAt(t *testing.T) {
cases := map[string]string{ cases := map[string]string{
"": "", "": "",
rotationNone: "", `{"rotation":"` + rotationNone + `"}`: "",
rotationMonthly: "-2026-10", `{"rotation":"` + rotationMonthly + `"}`: "-2026-10",
rotationDaily: "-2026-10-01", dailyConfig: "-2026-10-01",
rotationHourly: "-2026-10-01-19", `{"rotation":"` + rotationHourly + `"}`: "-2026-10-01-19",
} }
for rotation, period := range cases { for config, period := range cases {
target := &database.Target{ target := &database.Target{
BaseModel: database.BaseModel{ID: "tgt-id"}, BaseModel: database.BaseModel{ID: "tgt-id"},
Name: "Archive", Name: "Archive",
ArchiveRotation: rotation, Config: config,
} }
got, err := delivery.ArchivePathAt(dbMgr, webhook, target, at)
require.NoError(t, err, config)
assert.Equal(t, assert.Equal(t,
filepath.Join( filepath.Join(
dataDir, "archive-orders-archive-tgt-id"+period+".db", dataDir, "archive-orders-archive-tgt-id"+period+".db",
), ),
archive.PathAt(dbMgr, webhook, target, at), rotation, got, config,
) )
} }
_, err := delivery.ArchivePathAt(dbMgr, webhook, &database.Target{
Config: weeklyConfig,
}, at)
require.Error(t, err)
} }
// TestValidateArchiveRotation accepts the four rotations, and empty, // TestValidateArchiveRotation accepts the four rotations, and empty,
@@ -366,10 +381,10 @@ func TestValidateArchiveRotation(t *testing.T) {
for _, ok := range []string{ for _, ok := range []string{
"", rotationNone, rotationMonthly, rotationDaily, rotationHourly, "", rotationNone, rotationMonthly, rotationDaily, rotationHourly,
} { } {
require.NoError(t, archive.ValidateRotation(ok), ok) require.NoError(t, delivery.ValidateArchiveRotation(ok), ok)
} }
for _, bad := range []string{"weekly", "Daily", "hourly "} { for _, bad := range []string{"weekly", "Daily", "hourly "} {
require.Error(t, archive.ValidateRotation(bad), bad) require.Error(t, delivery.ValidateArchiveRotation(bad), bad)
} }
} }
File diff suppressed because it is too large Load Diff
+9 -7
View File
@@ -17,7 +17,7 @@ import (
const MaxTargetTimeoutSeconds = 300 const MaxTargetTimeoutSeconds = 300
// Errors returned when a target's header or timeout form input // Errors returned when a target's header or timeout form input
// cannot be turned into the target's setting. // cannot be turned into a configuration.
// //
// None of these ever quotes a header VALUE. A target header value // None of these ever quotes a header VALUE. A target header value
// is routinely an authorization token, and these messages are shown // is routinely an authorization token, and these messages are shown
@@ -48,8 +48,8 @@ var (
) )
// isReservedTargetHeader reports whether name (canonicalised) is a // isReservedTargetHeader reports whether name (canonicalised) is a
// header a target may not set, because the delivery path or // header a target configuration may not set, because the delivery
// net/http itself writes it regardless. // path or net/http itself writes it regardless.
// //
// These are rejected rather than accepted-and-ignored. Storing a // These are rejected rather than accepted-and-ignored. Storing a
// header that provably never reaches the wire tells the operator // header that provably never reaches the wire tells the operator
@@ -77,11 +77,13 @@ func isReservedTargetHeader(name string) bool {
// ParseTargetHeaders turns the target form's headers field — one // ParseTargetHeaders turns the target form's headers field — one
// "Name: value" pair per line, blank lines ignored — into the map // "Name: value" pair per line, blank lines ignored — into the map
// stored in database.Target.Headers. Names are canonicalised, so a // stored in HTTPTargetConfig.Headers. Names are canonicalised, so a
// name repeated in a different case is still a duplicate rather than // name repeated in a different case is still a duplicate rather than
// one pair silently overwriting the other. // one pair silently overwriting the other.
// //
// An input with no pairs yields an empty map. // An input with no pairs yields an empty map, which omitempty drops
// from the stored config: a target configured with no headers keeps
// the same config JSON it had before this field existed.
func ParseTargetHeaders(raw string) (map[string]string, error) { func ParseTargetHeaders(raw string) (map[string]string, error) {
headers := make(map[string]string) headers := make(map[string]string)
@@ -221,8 +223,8 @@ func FormatTargetHeaders(headers map[string]string) string {
// ParseTargetTimeout interprets the target form's timeout field as // ParseTargetTimeout interprets the target form's timeout field as
// a whole number of seconds. An empty field means "unset" and yields // a whole number of seconds. An empty field means "unset" and yields
// 0, which the delivery path reads as "use the shared client's // 0, which omitempty drops from the stored config and which the
// timeout". // delivery path reads as "use the shared client's timeout".
// //
// Anything else that is not a whole number in range is an error, not // Anything else that is not a whole number in range is an error, not
// a silently substituted default: a target whose timeout was typed // a silently substituted default: a target whose timeout was typed
+97 -4
View File
@@ -1,21 +1,23 @@
package delivery_test package delivery_test
import ( import (
"encoding/json"
"strconv" "strconv"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
) )
// Literals these tests repeat, named so that each header name has one // Literals these tests repeat, named so that the header names and the
// definition. // keep-forever archive config each have one definition.
const ( const (
headerAuthorization = "Authorization" headerAuthorization = "Authorization"
headerContentType = "Content-Type" headerContentType = "Content-Type"
headerUserAgent = "User-Agent"
bearerValue = "Bearer abc" bearerValue = "Bearer abc"
archiveConfigNever = "{\"expiry\":\"never\"}"
) )
func TestParseTargetHeaders_AcceptsPairs(t *testing.T) { func TestParseTargetHeaders_AcceptsPairs(t *testing.T) {
@@ -39,13 +41,23 @@ func TestParseTargetHeaders_AcceptsPairs(t *testing.T) {
) )
} }
// An input of blank lines is no headers, not an error. // A configuration with no headers must stay indistinguishable from
// one written before the field existed, so omitempty drops the key.
func TestParseTargetHeaders_EmptyInputYieldsNoHeaders(t *testing.T) { func TestParseTargetHeaders_EmptyInputYieldsNoHeaders(t *testing.T) {
t.Parallel() t.Parallel()
got, err := delivery.ParseTargetHeaders("\n \n") got, err := delivery.ParseTargetHeaders("\n \n")
require.NoError(t, err) require.NoError(t, err)
assert.Empty(t, got) assert.Empty(t, got)
encoded, err := json.Marshal(delivery.HTTPTargetConfig{
URL: "https://example.com/h",
Headers: got,
})
require.NoError(t, err)
assert.JSONEq(
t, `{"url":"https://example.com/h"}`, string(encoded),
)
} }
func TestParseTargetHeaders_Rejects(t *testing.T) { func TestParseTargetHeaders_Rejects(t *testing.T) {
@@ -169,6 +181,87 @@ func TestFormatTargetTimeout(t *testing.T) {
assert.Empty(t, delivery.FormatTargetTimeout(-1)) assert.Empty(t, delivery.FormatTargetTimeout(-1))
} }
func TestNewTargetConfigForm(t *testing.T) {
t.Parallel()
form, err := delivery.NewTargetConfigForm(&database.Target{
Type: database.TargetTypeHTTP,
Config: `{"url":"https://example.com/h",` +
`"headers":{"Authorization":"Bearer abc"},` +
`"timeout":9}`,
})
require.NoError(t, err)
assert.Equal(t, "https://example.com/h", form.URL)
assert.Equal(t, "Authorization: Bearer abc\n", form.Headers)
assert.Equal(t, "9", form.Timeout)
form, err = delivery.NewTargetConfigForm(&database.Target{
Type: database.TargetTypeSlack,
Config: `{"webhookUrl":"https://hooks.example/s"}`,
})
require.NoError(t, err)
assert.Equal(t, "https://hooks.example/s", form.URL)
form, err = delivery.NewTargetConfigForm(&database.Target{
Type: database.TargetTypeDatabase,
Config: `{"expiry":"720h","rotation":"daily"}`,
})
require.NoError(t, err)
assert.Equal(t, "720h", form.Expiry)
assert.Equal(t, rotationDaily, form.Rotation)
form, err = delivery.NewTargetConfigForm(&database.Target{
Type: database.TargetTypeLog,
})
require.NoError(t, err)
assert.Empty(t, form.URL)
}
// A keep-forever archive target yields an empty expiry, so the edit
// form starts on never; saving it unchanged stores never, which means
// the same as an empty expiry.
func TestNewTargetConfigForm_DatabaseNeverIsBlank(t *testing.T) {
t.Parallel()
for _, cfg := range []string{"", `{}`, archiveConfigNever} {
form, err := delivery.NewTargetConfigForm(
&database.Target{
Type: database.TargetTypeDatabase,
Config: cfg,
},
)
require.NoError(t, err, cfg)
assert.Empty(t, form.Expiry, cfg)
}
}
// An unreadable stored config is an error rather than a blank form
// that looks like a target with no settings, so the caller can tell
// the operator that saving replaces the stored value.
func TestNewTargetConfigForm_UnreadableConfigErrors(t *testing.T) {
t.Parallel()
cases := []*database.Target{
{Type: database.TargetTypeHTTP, Config: "not json"},
{Type: database.TargetTypeHTTP, Config: `{}`},
{Type: database.TargetTypeSlack, Config: ""},
{
Type: database.TargetTypeDatabase,
Config: `{"expiry":"soon"}`,
},
{
Type: database.TargetTypeDatabase,
Config: weeklyConfig,
},
{Type: database.TargetType("nope")},
}
for _, target := range cases {
_, err := delivery.NewTargetConfigForm(target)
require.Error(t, err, target.Type)
}
}
// The ceiling exists so one misconfigured target cannot hold a // The ceiling exists so one misconfigured target cannot hold a
// delivery worker indefinitely, and it is inclusive. // delivery worker indefinitely, and it is inclusive.
func TestParseTargetTimeout_CeilingIsInclusive(t *testing.T) { func TestParseTargetTimeout_CeilingIsInclusive(t *testing.T) {
+135 -82
View File
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -16,6 +17,29 @@ import (
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
// Sentinel errors returned by the config parsers.
var (
errEmptyTargetConfig = errors.New(
"empty target config",
)
errMissingTargetURL = errors.New(
"target URL is required",
)
)
// HTTPTargetConfig holds configuration for http target
// types.
type HTTPTargetConfig struct {
URL string `json:"url"`
Headers map[string]string `json:"headers,omitempty"`
Timeout int `json:"timeout,omitempty"`
// ForwardQuery passes each event's query string on to the target,
// appended to URL. Off, the target URL is sent exactly as
// configured.
ForwardQuery bool `json:"forwardQuery,omitempty"`
}
// httpCore holds the retry, backoff, and circuit-breaker // httpCore holds the retry, backoff, and circuit-breaker
// machinery shared by the HTTP and Slack targets. Each of // machinery shared by the HTTP and Slack targets. Each of
// those targets owns its own httpCore instance (and thus its // those targets owns its own httpCore instance (and thus its
@@ -29,72 +53,34 @@ type httpCore struct {
} }
// deliver runs one delivery attempt through the retry core. // deliver runs one delivery attempt through the retry core.
// A maxAttempts of 1 is a single attempt, recorded without // A maxRetries of 0 is fire-and-forget: a single attempt is
// consulting a circuit breaker. A higher maxAttempts gates the // recorded and no circuit breaker is consulted. A positive
// attempt on the circuit breaker and schedules a backed-off retry // maxRetries gates the attempt on the circuit breaker and
// on failure, until maxAttempts attempts have been made. // schedules a backed-off retry on failure.
//
// ctx is the context attempt sends its request with. Stopping the
// engine cancels it; see interruptedByStop.
func (c *httpCore) deliver( func (c *httpCore) deliver(
ctx context.Context,
webhookDB *gorm.DB, webhookDB *gorm.DB,
d *database.Delivery, d *database.Delivery,
task *Task, task *Task,
sched Scheduler, sched Scheduler,
maxAttempts int, maxRetries int,
attempt func() attemptResult, attempt func() attemptResult,
) { ) {
if maxAttempts == 1 { if maxRetries == 0 {
c.fireAndForget(ctx, webhookDB, d, attempt()) c.fireAndForget(webhookDB, d, attempt())
return return
} }
c.withRetry( c.withRetry(
ctx, webhookDB, d, task, sched, maxAttempts, attempt, webhookDB, d, task, sched, maxRetries, attempt,
) )
} }
// interruptedByStop reports whether an attempt failed because the
// engine is stopping, which cancels the context its request is made
// with. Such an attempt is not recorded: no DeliveryResult, no circuit
// breaker outcome, no status change, no count in the attempt metrics.
// The delivery stays pending or retrying, and the recovery routine
// sends it again on the next start.
//
// A target's own timeout leaves that context alone, so its attempt is
// still recorded as the failure it is.
func (c *httpCore) interruptedByStop(
ctx context.Context,
d *database.Delivery,
res attemptResult,
) bool {
if res.success || ctx.Err() == nil {
return false
}
c.eng.log.Warn(
"delivery attempt interrupted by shutdown; not recorded, "+
"it is sent again on the next start",
"delivery_id", d.ID,
"event_id", d.EventID,
"target_id", d.TargetID,
)
return true
}
func (c *httpCore) fireAndForget( func (c *httpCore) fireAndForget(
ctx context.Context,
webhookDB *gorm.DB, webhookDB *gorm.DB,
d *database.Delivery, d *database.Delivery,
res attemptResult, res attemptResult,
) { ) {
if c.interruptedByStop(ctx, d, res) {
return
}
c.eng.observeAttempt(d.Target.Type, res.elapsed()) c.eng.observeAttempt(d.Target.Type, res.elapsed())
err := c.eng.recordResult( err := c.eng.recordResult(
@@ -124,12 +110,11 @@ func (c *httpCore) fireAndForget(
} }
func (c *httpCore) withRetry( func (c *httpCore) withRetry(
ctx context.Context,
webhookDB *gorm.DB, webhookDB *gorm.DB,
d *database.Delivery, d *database.Delivery,
task *Task, task *Task,
sched Scheduler, sched Scheduler,
maxAttempts int, maxRetries int,
attempt func() attemptResult, attempt func() attemptResult,
) { ) {
cb := c.getCircuitBreaker(task.TargetID) cb := c.getCircuitBreaker(task.TargetID)
@@ -146,10 +131,6 @@ func (c *httpCore) withRetry(
res := attempt() res := attempt()
if c.interruptedByStop(ctx, d, res) {
return
}
c.eng.observeAttempt(d.Target.Type, res.elapsed()) c.eng.observeAttempt(d.Target.Type, res.elapsed())
err := c.eng.recordResult( err := c.eng.recordResult(
@@ -181,7 +162,7 @@ func (c *httpCore) withRetry(
cb.RecordFailure() cb.RecordFailure()
c.handleRetry( c.handleRetry(
webhookDB, d, task, sched, maxAttempts, attemptNum, webhookDB, d, task, sched, maxRetries, attemptNum,
) )
} }
@@ -242,10 +223,10 @@ func (c *httpCore) handleRetry(
d *database.Delivery, d *database.Delivery,
task *Task, task *Task,
sched Scheduler, sched Scheduler,
maxAttempts int, maxRetries int,
attemptNum int, attemptNum int,
) { ) {
if attemptNum >= maxAttempts { if attemptNum >= maxRetries {
c.eng.settleStatus( c.eng.settleStatus(
webhookDB, d, d.Target.Type, webhookDB, d, d.Target.Type,
database.DeliveryStatusFailed, database.DeliveryStatusFailed,
@@ -310,8 +291,7 @@ func (c *httpCore) publishCircuitState(
// remainingBackoff returns how long remains of the backoff // remainingBackoff returns how long remains of the backoff
// window for the last attempt of a recovered retrying // window for the last attempt of a recovered retrying
// delivery: zero once it has passed, or when the last attempt // delivery. It implements rescheduler.
// cannot be read. It implements rescheduler.
func (c *httpCore) remainingBackoff( func (c *httpCore) remainingBackoff(
webhookDB *gorm.DB, webhookDB *gorm.DB,
deliveryID string, deliveryID string,
@@ -334,6 +314,29 @@ func (c *httpCore) remainingBackoff(
return max(remaining, 0) return max(remaining, 0)
} }
// backoffElapsed reports whether the backoff window for the
// last attempt of a retrying delivery has passed. It
// implements rescheduler.
func (c *httpCore) backoffElapsed(
webhookDB *gorm.DB,
deliveryID string,
attemptNum int,
) bool {
var lastResult database.DeliveryResult
err := webhookDB.
Where("delivery_id = ?", deliveryID).
Order("created_at DESC").
First(&lastResult).Error
if err != nil {
return true
}
backoff := Backoff(attemptNum)
return time.Since(lastResult.CreatedAt) >= backoff
}
// Backoff is how long an http or slack target with retries waits after // Backoff is how long an http or slack target with retries waits after
// a delivery's failed attempt attemptNum before trying it again. // a delivery's failed attempt attemptNum before trying it again.
func Backoff(attemptNum int) time.Duration { func Backoff(attemptNum int) time.Duration {
@@ -361,13 +364,39 @@ func (t *httpTarget) Deliver(
task *Task, task *Task,
sched Scheduler, sched Scheduler,
) { ) {
cfg, err := parseHTTPConfig(d.Target.Config)
if err != nil {
t.eng.log.Error(
"invalid HTTP target config",
"target_id", d.TargetID,
"error", err,
)
recErr := t.eng.recordResult(
webhookDB, d, task.AttemptNum,
false, 0, "", err.Error(), 0,
)
if recErr != nil {
t.eng.bookkeepingFailed(d, recErr)
return
}
t.eng.settleStatus(
webhookDB, d, d.Target.Type,
database.DeliveryStatusFailed,
)
return
}
attempt := func() attemptResult { attempt := func() attemptResult {
return t.attempt(ctx, &d.Target, &d.Event) return t.attempt(ctx, cfg, &d.Event)
} }
t.deliver( t.deliver(
ctx, webhookDB, d, task, sched, webhookDB, d, task, sched,
d.Target.MaxAttempts, attempt, d.Target.MaxRetries, attempt,
) )
} }
@@ -377,11 +406,11 @@ func (t *httpTarget) Deliver(
// string; only a transport-level error does. // string; only a transport-level error does.
func (t *httpTarget) attempt( func (t *httpTarget) attempt(
ctx context.Context, ctx context.Context,
target *database.Target, cfg *HTTPTargetConfig,
event *database.Event, event *database.Event,
) attemptResult { ) attemptResult {
statusCode, respBody, duration, reqErr := statusCode, respBody, duration, reqErr :=
t.doHTTPRequest(ctx, target, event) t.doHTTPRequest(ctx, cfg, event)
success := reqErr == nil && success := reqErr == nil &&
statusCode >= httpSuccessMin && statusCode >= httpSuccessMin &&
@@ -403,7 +432,7 @@ func (t *httpTarget) attempt(
func (t *httpTarget) doHTTPRequest( func (t *httpTarget) doHTTPRequest(
ctx context.Context, ctx context.Context,
target *database.Target, cfg *HTTPTargetConfig,
event *database.Event, event *database.Event,
) (int, string, int64, error) { ) (int, string, int64, error) {
start := time.Now() start := time.Now()
@@ -411,7 +440,7 @@ func (t *httpTarget) doHTTPRequest(
req, reqErr := http.NewRequestWithContext( req, reqErr := http.NewRequestWithContext(
ctx, ctx,
http.MethodPost, http.MethodPost,
target.URL, cfg.URL,
bytes.NewReader([]byte(event.Body)), bytes.NewReader([]byte(event.Body)),
) )
if reqErr != nil { if reqErr != nil {
@@ -421,15 +450,15 @@ func (t *httpTarget) doHTTPRequest(
) )
} }
if target.ForwardQuery { if cfg.ForwardQuery {
appendQuery(req.URL, event.RawQuery) appendQuery(req.URL, event.RawQuery)
} }
originScoped := applyRequestHeaders( originScoped := applyRequestHeaders(
req, event, target.Headers, t.eng.userAgent(), req, event, cfg, t.eng.userAgent(),
) )
client := t.clientForRequest(target.TimeoutSeconds, originScoped) client := t.clientForRequest(cfg, originScoped)
resp, doErr := executeHTTPRequest(client, req) resp, doErr := executeHTTPRequest(client, req)
@@ -469,17 +498,17 @@ func appendQuery(u *url.URL, rawQuery string) {
} }
// clientForRequest returns the client for one delivery attempt. // clientForRequest returns the client for one delivery attempt.
// timeoutSeconds is the target's TimeoutSeconds, and originScoped is // originScoped is the header set applyRequestHeaders built for that
// the header set applyRequestHeaders built for that attempt; a // attempt; a request with neither a per-target timeout nor an
// request with neither a per-target timeout nor an origin-scoped // origin-scoped header gets the shared client, because there is
// header gets the shared client, because there is then nothing for // then nothing for the redirect policy to strip and net/http's
// the redirect policy to strip and net/http's default policy already // default policy already withholds Authorization and Cookie across
// withholds Authorization and Cookie across hosts. // hosts.
func (t *httpTarget) clientForRequest( func (t *httpTarget) clientForRequest(
timeoutSeconds int, cfg *HTTPTargetConfig,
originScoped []string, originScoped []string,
) *http.Client { ) *http.Client {
if timeoutSeconds <= 0 && len(originScoped) == 0 { if cfg.Timeout <= 0 && len(originScoped) == 0 {
return t.client return t.client
} }
@@ -492,9 +521,9 @@ func (t *httpTarget) clientForRequest(
Transport: t.client.Transport, Transport: t.client.Transport,
} }
if timeoutSeconds > 0 { if cfg.Timeout > 0 {
client.Timeout = time.Duration( client.Timeout = time.Duration(
timeoutSeconds, cfg.Timeout,
) * time.Second ) * time.Second
} }
@@ -505,6 +534,31 @@ func (t *httpTarget) clientForRequest(
return client return client
} }
func parseHTTPConfig(
configJSON string,
) (*HTTPTargetConfig, error) {
if configJSON == "" {
return nil, errEmptyTargetConfig
}
var cfg HTTPTargetConfig
err := json.Unmarshal(
[]byte(configJSON), &cfg,
)
if err != nil {
return nil, fmt.Errorf(
"parsing config JSON: %w", err,
)
}
if cfg.URL == "" {
return nil, errMissingTargetURL
}
return &cfg, nil
}
// isForwardableHeader returns true if the header should // isForwardableHeader returns true if the header should
// be forwarded to targets. // be forwarded to targets.
func isForwardableHeader(name string) bool { func isForwardableHeader(name string) bool {
@@ -536,12 +590,11 @@ func isForwardableHeader(name string) bool {
// wins, otherwise the event's ContentType, otherwise none. The inbound // wins, otherwise the event's ContentType, otherwise none. The inbound
// Content-Type in the event's headers is never forwarded. // Content-Type in the event's headers is never forwarded.
// //
// configured is the target's Headers. userAgent is set last, over // userAgent is set last, over any configured or inbound User-Agent.
// any configured or inbound User-Agent.
func applyRequestHeaders( func applyRequestHeaders(
req *http.Request, req *http.Request,
event *database.Event, event *database.Event,
configured map[string]string, cfg *HTTPTargetConfig,
userAgent string, userAgent string,
) []string { ) []string {
if event.ContentType != "" { if event.ContentType != "" {
@@ -552,7 +605,7 @@ func applyRequestHeaders(
originScoped := forwardEventHeaders(req, event) originScoped := forwardEventHeaders(req, event)
for k, v := range configured { for k, v := range cfg.Headers {
req.Header.Set(k, v) req.Header.Set(k, v)
originScoped[http.CanonicalHeaderKey(k)] = struct{}{} originScoped[http.CanonicalHeaderKey(k)] = struct{}{}
} }
@@ -617,7 +670,7 @@ func forwardEventHeaders(
} }
// executeHTTPRequest sends an HTTP request using the provided // executeHTTPRequest sends an HTTP request using the provided
// client. URLs are validated by the target forms and the // client. URLs are validated by the config parsers and the
// SSRF-safe transport before reaching here. // SSRF-safe transport before reaching here.
// //
// Transport failures are masked here, at the single point // Transport failures are masked here, at the single point
+26 -17
View File
@@ -2,6 +2,7 @@ package delivery_test
import ( import (
"context" "context"
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -17,13 +18,25 @@ import (
// with. // with.
const eventQuery = "a=1&b=2" const eventQuery = "a=1&b=2"
// deliverWithQuery sends one event that arrived with eventQuery to an // httpTargetConfig is the stored configuration of an HTTP target at
// HTTP target at targetURL, with its ForwardQuery setting as given, // targetURL.
// through the path a received event's delivery takes, and returns the func httpTargetConfig(
// attempt it recorded.
func deliverWithQuery(
t *testing.T, targetURL string, forwardQuery bool, t *testing.T, targetURL string, forwardQuery bool,
) database.DeliveryResult { ) string {
t.Helper()
cfg, err := json.Marshal(delivery.HTTPTargetConfig{
URL: targetURL, ForwardQuery: forwardQuery,
})
require.NoError(t, err)
return string(cfg)
}
// deliverWithQuery sends one event that arrived with eventQuery to an
// HTTP target configured with cfg, through the path a received event's
// delivery takes, and returns the attempt it recorded.
func deliverWithQuery(t *testing.T, cfg string) database.DeliveryResult {
t.Helper() t.Helper()
s := newISetup(t) s := newISetup(t)
@@ -34,10 +47,8 @@ func deliverWithQuery(
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
task := iTask( task := iTask(
d, event, s.WebhookID, d.TargetID, "query", targetURL, 1, 1, d, event, s.WebhookID, d.TargetID, "query", cfg, 0, 1, &event.Body,
&event.Body,
) )
task.ForwardQuery = forwardQuery
task.RawQuery = eventQuery task.RawQuery = eventQuery
s.Engine.ExportProcessNewTask(context.TODO(), &task) s.Engine.ExportProcessNewTask(context.TODO(), &task)
@@ -100,9 +111,9 @@ func TestDeliverHTTP_ForwardQuery(t *testing.T) {
)) ))
t.Cleanup(ts.Close) t.Cleanup(ts.Close)
result := deliverWithQuery( result := deliverWithQuery(t, httpTargetConfig(
t, ts.URL+tc.path, tc.forwardQuery, t, ts.URL+tc.path, tc.forwardQuery,
) ))
assert.True(t, result.Success) assert.True(t, result.Success)
require.Len(t, received, 1) require.Len(t, received, 1)
@@ -134,12 +145,10 @@ func TestDeliverHTTP_ForwardedQueryKeepsTheTargetURLMasked(t *testing.T) {
target := &database.Target{ target := &database.Target{
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
URL: ts.URL + "/in?token=" + secret, Config: httpTargetConfig(t, ts.URL+"/in?token="+secret, true),
ForwardQuery: true,
MaxAttempts: 1,
} }
deliverWithQuery(t, target.URL, target.ForwardQuery) deliverWithQuery(t, target.Config)
require.Len(t, received, 1) require.Len(t, received, 1)
sent := <-received sent := <-received
@@ -154,9 +163,9 @@ func TestDeliverHTTP_ForwardedQueryKeepsTheTargetURLMasked(t *testing.T) {
} }
// Nothing listens on port 1. // Nothing listens on port 1.
failed := deliverWithQuery( failed := deliverWithQuery(t, httpTargetConfig(
t, "http://127.0.0.1:1/in?token="+secret, true, t, "http://127.0.0.1:1/in?token="+secret, true,
) ))
require.NotEmpty(t, failed.Error) require.NotEmpty(t, failed.Error)
assert.NotContains(t, failed.Error, secret) assert.NotContains(t, failed.Error, secret)
assert.NotContains(t, failed.Error, eventQuery) assert.NotContains(t, failed.Error, eventQuery)
+30 -9
View File
@@ -13,12 +13,12 @@ import (
const RedactionMarker = "(redacted)" const RedactionMarker = "(redacted)"
// Redactor removes one target's own credential material from // Redactor removes one target's own credential material from
// a delivery response body, whose text the target's remote // text that target's remote peer chose: a delivery response
// peer chose, and from a stored delivery error as a second // body, or a delivery error stored before the delivery path
// line behind the delivery path's own URL masking. // learned to mask the URLs it embeds.
// //
// It removes byte-identical echoes of strings taken from the // It removes byte-identical echoes of strings taken from the
// target's URL and headers, and nothing else. Anything // target's stored configuration, and nothing else. Anything
// the remote re-encodes survives: JSON "\/" escaping (what // the remote re-encodes survives: JSON "\/" escaping (what
// PHP's json_encode emits by default), percent-encoding, HTML // PHP's json_encode emits by default), percent-encoding, HTML
// entities, and an echo of only part of a path. It cannot // entities, and an echo of only part of a path. It cannot
@@ -115,7 +115,7 @@ func (r Redactor) secretPrefixSuffix(s string) int {
} }
// targetSecrets returns the credential-bearing strings a // targetSecrets returns the credential-bearing strings a
// target's settings carry. // target's configuration carries.
// //
// The destination URL contributes. Its path, query and // The destination URL contributes. Its path, query and
// userinfo are the credential for both target types that have // userinfo are the credential for both target types that have
@@ -129,15 +129,36 @@ func (r Redactor) secretPrefixSuffix(s string) int {
// picks out. That is the same class-based rule applied to // picks out. That is the same class-based rule applied to
// URLs: an echoed Accept or User-Agent still renders, an // URLs: an echoed Accept or User-Agent still renders, an
// echoed Authorization does not. // echoed Authorization does not.
//
// A setting a type does not use is empty and contributes
// nothing, so a database or log target has nothing to redact.
func targetSecrets(t *database.Target) []string { func targetSecrets(t *database.Target) []string {
if t == nil { if t == nil {
return nil return nil
} }
return append(urlSecrets(t.URL), headerSecrets(t.Headers)...) switch t.Type {
case database.TargetTypeSlack:
cfg, err := parseSlackConfig(t.Config)
if err != nil {
return nil
}
return urlSecrets(cfg.WebhookURL)
case database.TargetTypeHTTP:
cfg, err := parseHTTPConfig(t.Config)
if err != nil {
return nil
}
return append(
urlSecrets(cfg.URL),
headerSecrets(cfg.Headers)...,
)
case database.TargetTypeDatabase, database.TargetTypeLog:
// Neither has a destination URL, so neither has
// anything to redact.
return nil
default:
return nil
}
} }
// urlSecrets returns the substrings of a destination URL that // urlSecrets returns the substrings of a destination URL that
+28 -37
View File
@@ -23,8 +23,7 @@ func TestRedactor_RemovesSlackWebhookURL(t *testing.T) {
r := delivery.NewRedactor(&database.Target{ r := delivery.NewRedactor(&database.Target{
Type: database.TargetTypeSlack, Type: database.TargetTypeSlack,
URL: redactWebhookURL, Config: `{"webhookUrl":"` + redactWebhookURL + `"}`,
MaxAttempts: 1,
}) })
got := r.Redact("no_service for " + redactWebhookURL) got := r.Redact("no_service for " + redactWebhookURL)
@@ -54,8 +53,7 @@ func TestRedactor_RemovesSecretSeveredByACut(t *testing.T) {
r := delivery.NewRedactor(&database.Target{ r := delivery.NewRedactor(&database.Target{
Type: database.TargetTypeSlack, Type: database.TargetTypeSlack,
URL: redactWebhookURL, Config: `{"webhookUrl":"` + redactWebhookURL + `"}`,
MaxAttempts: 1,
}) })
// Every cut position inside the credential, not just a // Every cut position inside the credential, not just a
@@ -86,20 +84,18 @@ func TestRedactor_RedactsCredentialShapedHeaderValues(
r := delivery.NewRedactor(&database.Target{ r := delivery.NewRedactor(&database.Target{
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
URL: "https://example.com/in", Config: `{"url":"https://example.com/in",` +
Headers: map[string]string{ `"headers":{` +
headerAuthorization: "Bearer AAAAAAAAAAAA", `"Authorization":"Bearer AAAAAAAAAAAA",` +
"Cookie": "session=BBBBBBBBBBBB", `"Cookie":"session=BBBBBBBBBBBB",` +
probeHeaderName: "CCCCCCCCCCCC", `"X-Api-Key":"CCCCCCCCCCCC",` +
"X-Hub-Signature": "sha256=DDDDDDDDDDDD", `"X-Hub-Signature":"sha256=DDDDDDDDDDDD",` +
"X-Sig": "EEEEEEEEEEEE", `"X-Sig":"EEEEEEEEEEEE",` +
"X-Pass": "FFFFFFFFFFFF", `"X-Pass":"FFFFFFFFFFFF",` +
"X-HMAC": "GGGGGGGGGGGG", `"X-HMAC":"GGGGGGGGGGGG",` +
"X-Credential": "HHHHHHHHHHHH", `"X-Credential":"HHHHHHHHHHHH",` +
"Accept": "application/json", `"Accept":"application/json",` +
headerUserAgent: "webhooker/1.0", `"User-Agent":"webhooker/1.0"}}`,
},
MaxAttempts: 1,
}) })
for _, secret := range []string{ for _, secret := range []string{
@@ -138,9 +134,8 @@ func TestRedactor_IgnoresVeryShortHeaderValues(t *testing.T) {
r := delivery.NewRedactor(&database.Target{ r := delivery.NewRedactor(&database.Target{
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
URL: "https://example.com/in", Config: `{"url":"https://example.com/in",` +
Headers: map[string]string{probeHeaderName: "ab"}, `"headers":{"X-Api-Key":"ab"}}`,
MaxAttempts: 1,
}) })
const response = "rabbit" const response = "rabbit"
@@ -156,8 +151,7 @@ func TestRedactor_RemovesBarePath(t *testing.T) {
r := delivery.NewRedactor(&database.Target{ r := delivery.NewRedactor(&database.Target{
Type: database.TargetTypeSlack, Type: database.TargetTypeSlack,
URL: redactWebhookURL, Config: `{"webhookUrl":"` + redactWebhookURL + `"}`,
MaxAttempts: 1,
}) })
got := r.Redact("POST " + redactSecretPath + " 404") got := r.Redact("POST " + redactSecretPath + " 404")
@@ -190,8 +184,7 @@ func TestRedactor_RemovesHTTPURLQueryAndUserinfo(t *testing.T) {
r := delivery.NewRedactor(&database.Target{ r := delivery.NewRedactor(&database.Target{
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
URL: raw, Config: `{"url":"` + raw + `"}`,
MaxAttempts: 1,
}) })
for _, echoed := range []string{ for _, echoed := range []string{
@@ -240,8 +233,7 @@ func TestRedactor_RemovesEchoedQueryOfURLWithoutPath(t *testing.T) {
} { } {
r := delivery.NewRedactor(&database.Target{ r := delivery.NewRedactor(&database.Target{
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
URL: dest, Config: `{"url":"` + dest + `"}`,
MaxAttempts: 1,
}) })
for echoed, want := range echoes { for echoed, want := range echoes {
@@ -262,8 +254,7 @@ func TestRedactor_LeavesUnrelatedTextAlone(t *testing.T) {
r := delivery.NewRedactor(&database.Target{ r := delivery.NewRedactor(&database.Target{
Type: database.TargetTypeSlack, Type: database.TargetTypeSlack,
URL: redactWebhookURL, Config: `{"webhookUrl":"` + redactWebhookURL + `"}`,
MaxAttempts: 1,
}) })
assert.Equal(t, response, r.Redact(response)) assert.Equal(t, response, r.Redact(response))
@@ -289,8 +280,7 @@ func TestRedactor_EmptyUserinfoDoesNotShredTheBody(t *testing.T) {
r := delivery.NewRedactor(&database.Target{ r := delivery.NewRedactor(&database.Target{
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
URL: dest, Config: `{"url":"` + dest + `"}`,
MaxAttempts: 1,
}) })
const body = "ok=false error=channel_not_found" const body = "ok=false error=channel_not_found"
@@ -307,10 +297,11 @@ func TestRedactor_EmptyUserinfoDoesNotShredTheBody(t *testing.T) {
) )
} }
// TestRedactor_ZeroValueAndURLlessTargets pins that a caller // TestRedactor_ZeroValueAndConfiglessTargets pins that a
// with no target, or a target with no destination URL, gets a // caller with no target, an unparseable config, or a target
// redactor that changes nothing rather than one that panics. // type with no destination URL gets a redactor that changes
func TestRedactor_ZeroValueAndURLlessTargets(t *testing.T) { // nothing rather than one that panics.
func TestRedactor_ZeroValueAndConfiglessTargets(t *testing.T) {
t.Parallel() t.Parallel()
const text = "some response body" const text = "some response body"
@@ -323,8 +314,8 @@ func TestRedactor_ZeroValueAndURLlessTargets(t *testing.T) {
for _, tgt := range []database.Target{ for _, tgt := range []database.Target{
{Type: database.TargetTypeLog}, {Type: database.TargetTypeLog},
{Type: database.TargetTypeDatabase}, {Type: database.TargetTypeDatabase},
{Type: database.TargetTypeSlack, MaxAttempts: 1}, {Type: database.TargetTypeSlack, Config: "not json"},
{Type: database.TargetTypeHTTP, MaxAttempts: 1}, {Type: database.TargetTypeHTTP, Config: ""},
} { } {
assert.Equal( assert.Equal(
t, text, t, text,
+81 -18
View File
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -14,11 +15,24 @@ import (
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
// errMissingWebhookURL is returned when a Slack target config
// omits its webhook URL.
var errMissingWebhookURL = errors.New(
"webhook_url is required",
)
// SlackTargetConfig holds configuration for slack target
// types.
type SlackTargetConfig struct {
WebhookURL string `json:"webhookUrl"`
}
// slackTarget delivers events to Slack incoming webhooks. It // slackTarget delivers events to Slack incoming webhooks. It
// formats the event into a Slack message and posts it as // formats the event into a Slack message and posts it as
// JSON. It shares the retry core with the HTTP target: a // JSON. It shares the retry core with the HTTP target: a
// MaxAttempts of 1 is a single attempt, while a higher one // MaxRetries of 0 stays single-attempt fire-and-forget
// adds backoff and circuit breaking. // (preserving existing Slack targets), while a positive
// MaxRetries adds backoff and circuit breaking.
type slackTarget struct { type slackTarget struct {
*httpCore *httpCore
@@ -33,6 +47,19 @@ func (t *slackTarget) Deliver(
task *Task, task *Task,
sched Scheduler, sched Scheduler,
) { ) {
cfg, err := parseSlackConfig(d.Target.Config)
if err != nil {
t.eng.log.Error(
"invalid Slack target config",
"target_id", d.TargetID,
"error", err,
)
t.failConfig(webhookDB, d, err)
return
}
msg := FormatSlackMessage(&d.Event) msg := FormatSlackMessage(&d.Event)
payload, err := json.Marshal( payload, err := json.Marshal(
@@ -45,6 +72,29 @@ func (t *slackTarget) Deliver(
"error", err, "error", err,
) )
t.failConfig(webhookDB, d, err)
return
}
attempt := func() attemptResult {
return t.attempt(ctx, cfg, payload)
}
t.deliver(
webhookDB, d, task, sched,
d.Target.MaxRetries, attempt,
)
}
// failConfig records a first-attempt failure for a delivery
// that could not be prepared (bad config or unmarshalable
// payload) and marks it failed.
func (t *slackTarget) failConfig(
webhookDB *gorm.DB,
d *database.Delivery,
err error,
) {
recErr := t.eng.recordResult( recErr := t.eng.recordResult(
webhookDB, d, 1, webhookDB, d, 1,
false, 0, "", err.Error(), 0, false, 0, "", err.Error(), 0,
@@ -59,27 +109,15 @@ func (t *slackTarget) Deliver(
webhookDB, d, d.Target.Type, webhookDB, d, d.Target.Type,
database.DeliveryStatusFailed, database.DeliveryStatusFailed,
) )
return
} }
attempt := func() attemptResult { // attempt performs a single Slack POST and derives its
return t.attempt(ctx, d.Target.URL, payload) // outcome, preserving the engine's original semantics: a
}
t.deliver(
ctx, webhookDB, d, task, sched,
d.Target.MaxAttempts, attempt,
)
}
// attempt performs a single Slack POST to targetURL and derives
// its outcome, preserving the engine's original semantics: a
// non-2xx response records an "HTTP <code>" error string and // non-2xx response records an "HTTP <code>" error string and
// a transport error records a "sending request" error. // a transport error records a "sending request" error.
func (t *slackTarget) attempt( func (t *slackTarget) attempt(
ctx context.Context, ctx context.Context,
targetURL string, cfg *SlackTargetConfig,
payload []byte, payload []byte,
) attemptResult { ) attemptResult {
start := time.Now() start := time.Now()
@@ -87,7 +125,7 @@ func (t *slackTarget) attempt(
req, err := http.NewRequestWithContext( req, err := http.NewRequestWithContext(
ctx, ctx,
http.MethodPost, http.MethodPost,
targetURL, cfg.WebhookURL,
bytes.NewReader(payload), bytes.NewReader(payload),
) )
if err != nil { if err != nil {
@@ -149,6 +187,31 @@ func (t *slackTarget) readSlackResponse(
} }
} }
func parseSlackConfig(
configJSON string,
) (*SlackTargetConfig, error) {
if configJSON == "" {
return nil, errEmptyTargetConfig
}
var cfg SlackTargetConfig
err := json.Unmarshal(
[]byte(configJSON), &cfg,
)
if err != nil {
return nil, fmt.Errorf(
"parsing config JSON: %w", err,
)
}
if cfg.WebhookURL == "" {
return nil, errMissingWebhookURL
}
return &cfg, nil
}
// FormatSlackMessage builds a Slack-compatible message // FormatSlackMessage builds a Slack-compatible message
// string from a webhook event. // string from a webhook event.
func FormatSlackMessage( func FormatSlackMessage(
+53 -49
View File
@@ -45,7 +45,7 @@ func tSeedDeletedTarget(
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, name, t, s.MainDB, targetID, s.WebhookID, name,
database.TargetTypeHTTP, url, 5, database.TargetTypeHTTP, iHTTPConfig(url), 5,
) )
event := iSeedEvent( event := iSeedEvent(
@@ -124,7 +124,7 @@ func TestProcessDelivery_UnknownTargetType_RecordsWhy(
target := database.Target{ target := database.Target{
Name: "mystery", Name: "mystery",
Type: tUnknownType, Type: tUnknownType,
URL: "http://example.com/hook", Config: iHTTPConfig("http://example.com/hook"),
} }
target.ID = targetID target.ID = targetID
@@ -140,7 +140,7 @@ func TestProcessDelivery_UnknownTargetType_RecordsWhy(
body := event.Body body := event.Body
task := iTask( task := iTask(
seeded, event, s.WebhookID, targetID, "mystery", seeded, event, s.WebhookID, targetID, "mystery",
target.URL, 0, 1, &body, target.Config, 0, 1, &body,
) )
task.TargetType = tUnknownType task.TargetType = tUnknownType
@@ -178,7 +178,7 @@ func TestRecoverSingleRetry_TargetDeleted(t *testing.T) {
) )
s.Engine.ExportRecoverWebhookDeliveries( s.Engine.ExportRecoverWebhookDeliveries(
context.Background(), s.WebhookID, atStart, context.Background(), s.WebhookID,
) )
iAssertStatus( iAssertStatus(
@@ -201,7 +201,7 @@ func TestRecoverSingleRetry_TargetDeleted(t *testing.T) {
) )
} }
func TestRecoverSingleRetry_OnTick_TargetDeleted(t *testing.T) { func TestSweepSingleRetry_TargetDeleted(t *testing.T) {
t.Parallel() t.Parallel()
s := newISetup(t) s := newISetup(t)
@@ -218,11 +218,11 @@ func TestRecoverSingleRetry_OnTick_TargetDeleted(t *testing.T) {
// Twice, because the bug was an error the sweep repeated every // Twice, because the bug was an error the sweep repeated every
// minute for the life of the database: the second sweep must // minute for the life of the database: the second sweep must
// find nothing left to do. // find nothing left to do.
s.Engine.ExportRecoverWebhookDeliveries( s.Engine.ExportSweepWebhookRetries(
context.Background(), s.WebhookID, onTick, context.Background(), s.WebhookID,
) )
s.Engine.ExportRecoverWebhookDeliveries( s.Engine.ExportSweepWebhookRetries(
context.Background(), s.WebhookID, onTick, context.Background(), s.WebhookID,
) )
iAssertStatus( iAssertStatus(
@@ -239,11 +239,10 @@ func TestRecoverSingleRetry_OnTick_TargetDeleted(t *testing.T) {
assert.Zero(t, s.Engine.ExportInflightHeld()) assert.Zero(t, s.Engine.ExportInflightHeld())
} }
// TestRecoverSingleRetry_OnTick_TargetNeverExisted covers the other // TestSweepSingleRetry_TargetNeverExisted covers the other half of the
// half of the soft-delete distinction: an id with no row at all, // soft-delete distinction: an id with no row at all, deleted or
// deleted or otherwise, must not be reported as something the operator // otherwise, must not be reported as something the operator deleted.
// deleted. func TestSweepSingleRetry_TargetNeverExisted(t *testing.T) {
func TestRecoverSingleRetry_OnTick_TargetNeverExisted(t *testing.T) {
t.Parallel() t.Parallel()
s := newISetup(t) s := newISetup(t)
@@ -265,8 +264,8 @@ func TestRecoverSingleRetry_OnTick_TargetNeverExisted(t *testing.T) {
iSeedFailedResult(t, s.WebhookDB, d.ID) iSeedFailedResult(t, s.WebhookDB, d.ID)
s.Engine.ExportRecoverWebhookDeliveries( s.Engine.ExportSweepWebhookRetries(
context.Background(), s.WebhookID, onTick, context.Background(), s.WebhookID,
) )
iAssertStatus( iAssertStatus(
@@ -304,8 +303,8 @@ func TestFailMissingTarget_WritesNoTargetRow(
database.DeliveryStatusRetrying, database.DeliveryStatusRetrying,
) )
s.Engine.ExportRecoverWebhookDeliveries( s.Engine.ExportSweepWebhookRetries(
context.Background(), s.WebhookID, onTick, context.Background(), s.WebhookID,
) )
iAssertStatus( iAssertStatus(
@@ -313,9 +312,13 @@ func TestFailMissingTarget_WritesNoTargetRow(
database.DeliveryStatusFailed, database.DeliveryStatusFailed,
) )
// The per-webhook database has no targets table, so writing a var configs []string
// target row there would have failed the status write above.
assert.False(t, s.WebhookDB.Migrator().HasTable("targets"), require.NoError(t, s.WebhookDB.
Table("targets").
Pluck("config", &configs).Error)
assert.Empty(t, configs,
"the deleted-target terminal path wrote a target row "+ "the deleted-target terminal path wrote a target row "+
"into the per-webhook event database", "into the per-webhook event database",
) )
@@ -346,10 +349,11 @@ func tRetryChainSetup(
iCreateWebhook(t, s.MainDB, s.WebhookID, name) iCreateWebhook(t, s.MainDB, s.WebhookID, name)
targetID := uuid.New().String() targetID := uuid.New().String()
cfg := iHTTPConfig(ts.URL)
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, name, t, s.MainDB, targetID, s.WebhookID, name,
database.TargetTypeHTTP, ts.URL, 5, database.TargetTypeHTTP, cfg, 5,
) )
event := iSeedEvent( event := iSeedEvent(
@@ -366,7 +370,7 @@ func tRetryChainSetup(
body := event.Body body := event.Body
return iTask( return iTask(
d, event, s.WebhookID, targetID, name, ts.URL, 5, 2, &body, d, event, s.WebhookID, targetID, name, cfg, 5, 2, &body,
), targetID ), targetID
} }
@@ -527,7 +531,7 @@ func TestRecoverSingleRetry_TargetUnreadable_LeavesDeliveryAlone(
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "healthy", t, s.MainDB, targetID, s.WebhookID, "healthy",
database.TargetTypeHTTP, database.TargetTypeHTTP,
"http://example.com/hook", 5, iHTTPConfig("http://example.com/hook"), 5,
) )
event := iSeedEvent( event := iSeedEvent(
@@ -546,7 +550,7 @@ func TestRecoverSingleRetry_TargetUnreadable_LeavesDeliveryAlone(
require.NoError(t, sqlDB.Close()) require.NoError(t, sqlDB.Close())
s.Engine.ExportRecoverRetryingDeliveries( s.Engine.ExportRecoverRetryingDeliveries(
context.Background(), s.WebhookDB, s.WebhookID, s.WebhookDB, s.WebhookID,
) )
iAssertStatus( iAssertStatus(
@@ -564,7 +568,7 @@ func TestRecoverSingleRetry_TargetUnreadable_LeavesDeliveryAlone(
// --- 4. A pending delivery whose target is gone --- // --- 4. A pending delivery whose target is gone ---
func TestRecoverPendingDeliveries_TargetDeleted(t *testing.T) { func TestRecoverPending_TargetDeleted(t *testing.T) {
t.Parallel() t.Parallel()
s := newISetup(t) s := newISetup(t)
@@ -577,7 +581,7 @@ func TestRecoverPendingDeliveries_TargetDeleted(t *testing.T) {
) )
s.Engine.ExportRecoverWebhookDeliveries( s.Engine.ExportRecoverWebhookDeliveries(
context.Background(), s.WebhookID, atStart, context.Background(), s.WebhookID,
) )
iAssertStatus( iAssertStatus(
@@ -600,10 +604,10 @@ func TestRecoverPendingDeliveries_TargetDeleted(t *testing.T) {
) )
} }
// TestRecoverPendingDeliveries_TargetDeleted_LeavesAnOwnedDeliveryAlone: // TestRecoverPending_TargetDeleted_LeavesAnOwnedDeliveryAlone: the
// the terminal write takes ownership like every other recovery write, so // terminal write takes ownership like every other recovery write, so a
// a delivery the engine still holds is not failed underneath its worker. // delivery the engine still holds is not failed underneath its worker.
func TestRecoverPendingDeliveries_TargetDeleted_LeavesAnOwnedDeliveryAlone( func TestRecoverPending_TargetDeleted_LeavesAnOwnedDeliveryAlone(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
@@ -620,7 +624,7 @@ func TestRecoverPendingDeliveries_TargetDeleted_LeavesAnOwnedDeliveryAlone(
require.True(t, s.Engine.ExportRetainDelivery(deliveryID)) require.True(t, s.Engine.ExportRetainDelivery(deliveryID))
s.Engine.ExportRecoverWebhookDeliveries( s.Engine.ExportRecoverWebhookDeliveries(
context.Background(), s.WebhookID, atStart, context.Background(), s.WebhookID,
) )
iAssertStatus( iAssertStatus(
@@ -675,18 +679,18 @@ func TestFailMissingTarget_LeavesASettledDeliveryAlone(
assert.Zero(t, s.Engine.ExportInflightHeld()) assert.Zero(t, s.Engine.ExportInflightHeld())
} }
// TestRecoverPendingDeliveries_OnTick_TargetDeleted sweeps twice over a // TestSweepPending_TargetDeleted sweeps twice over a batch that also
// batch that also holds a healthy stranded delivery. The one whose // holds a healthy stranded delivery. The one whose target is gone is
// target is gone is failed once and then left alone; the healthy one is // failed once and then left alone; the healthy one is queued by the
// queued by the first sweep and not again by the second. // first sweep and not again by the second.
func TestRecoverPendingDeliveries_OnTick_TargetDeleted(t *testing.T) { func TestSweepPending_TargetDeleted(t *testing.T) {
t.Parallel() t.Parallel()
liveTargetID := uuid.New().String() liveTargetID := uuid.New().String()
s := fSweepSetup(t, liveTargetID, "still-there") s := fSweepSetup(t, liveTargetID, "still-there")
deliveryID := tSeedDeletedTarget( deliveryID := tSeedDeletedTarget(
t, s, "gone-while-pending-on-tick", "http://example.com/hook", t, s, "gone-on-pending-sweep", "http://example.com/hook",
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
rAgePending(t, s.WebhookDB, deliveryID) rAgePending(t, s.WebhookDB, deliveryID)
@@ -703,7 +707,7 @@ func TestRecoverPendingDeliveries_OnTick_TargetDeleted(t *testing.T) {
ctx := context.Background() ctx := context.Background()
s.Engine.ExportRecoverWebhookDeliveries(ctx, s.WebhookID, onTick) s.Engine.ExportSweepWebhookRetries(ctx, s.WebhookID)
tasks := fDrain(s.Engine) tasks := fDrain(s.Engine)
require.Len(t, tasks, 1, require.Len(t, tasks, 1,
@@ -711,7 +715,7 @@ func TestRecoverPendingDeliveries_OnTick_TargetDeleted(t *testing.T) {
) )
assert.Equal(t, healthy.ID, tasks[0].DeliveryID) assert.Equal(t, healthy.ID, tasks[0].DeliveryID)
s.Engine.ExportRecoverWebhookDeliveries(ctx, s.WebhookID, onTick) s.Engine.ExportSweepWebhookRetries(ctx, s.WebhookID)
assert.Empty(t, fDrain(s.Engine), assert.Empty(t, fDrain(s.Engine),
"the second sweep queued a delivery again", "the second sweep queued a delivery again",
@@ -724,7 +728,7 @@ func TestRecoverPendingDeliveries_OnTick_TargetDeleted(t *testing.T) {
last := tLastResult(t, s, deliveryID, 2) last := tLastResult(t, s, deliveryID, 2)
assert.Contains(t, last.Error, "gone-while-pending-on-tick") assert.Contains(t, last.Error, "gone-on-pending-sweep")
assert.Contains(t, last.Error, "was deleted") assert.Contains(t, last.Error, "was deleted")
} }
@@ -743,7 +747,7 @@ func TestSendRecoveredDeliveries_TargetMissingFromMap(
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "found-on-lookup", t, s.MainDB, targetID, s.WebhookID, "found-on-lookup",
database.TargetTypeLog, "", 1, database.TargetTypeLog, "", 0,
) )
event := iSeedEvent( event := iSeedEvent(
@@ -775,11 +779,11 @@ func TestSendRecoveredDeliveries_TargetMissingFromMap(
) )
} }
// TestRecoverPendingDeliveries_TargetUnreadable_LeavesDeliveryAlone: a // TestRecoverPending_TargetUnreadable_LeavesDeliveryAlone: a failed
// failed read of the main database is not a deleted target. At start the // read of the main database is not a deleted target. Restart recovery
// recovery routine holds every pending delivery of the webhook in one // holds every pending delivery of the webhook in one batch, so failing
// batch, so failing on this would fail all of them. // on this would fail all of them.
func TestRecoverPendingDeliveries_TargetUnreadable_LeavesDeliveryAlone( func TestRecoverPending_TargetUnreadable_LeavesDeliveryAlone(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
@@ -790,7 +794,7 @@ func TestRecoverPendingDeliveries_TargetUnreadable_LeavesDeliveryAlone(
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "healthy", t, s.MainDB, targetID, s.WebhookID, "healthy",
database.TargetTypeLog, "", 1, database.TargetTypeLog, "", 0,
) )
event := iSeedEvent( event := iSeedEvent(
@@ -807,7 +811,7 @@ func TestRecoverPendingDeliveries_TargetUnreadable_LeavesDeliveryAlone(
require.NoError(t, sqlDB.Close()) require.NoError(t, sqlDB.Close())
s.Engine.ExportRecoverPendingDeliveries( s.Engine.ExportRecoverPendingDeliveries(
context.Background(), s.WebhookDB, s.WebhookID, atStart, context.Background(), s.WebhookDB, s.WebhookID,
) )
iAssertStatus( iAssertStatus(
+16 -4
View File
@@ -2,6 +2,7 @@ package delivery_test
import ( import (
"context" "context"
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -70,6 +71,13 @@ func deliverSlackTo(
e := testEngine(t, 1) e := testEngine(t, 1)
targetID := uuid.New().String() targetID := uuid.New().String()
slackCfg, err := json.Marshal(
delivery.SlackTargetConfig{
WebhookURL: webhookURL,
},
)
require.NoError(t, err)
event := seedEvent(t, db, `{"test":true}`) event := seedEvent(t, db, `{"test":true}`)
dlv := seedDelivery( dlv := seedDelivery(
@@ -79,7 +87,7 @@ func deliverSlackTo(
d := buildSlackDelivery( d := buildSlackDelivery(
dlv, event, targetID, dlv, event, targetID,
"test-slack-mask", webhookURL, "test-slack-mask", string(slackCfg),
) )
e.ExportDeliverSlack(context.TODO(), db, d) e.ExportDeliverSlack(context.TODO(), db, d)
@@ -152,9 +160,13 @@ func TestDoHTTPRequest_TransportErrorMasksURL(t *testing.T) {
e := testEngine(t, 1) e := testEngine(t, 1)
cfg, err := e.ExportParseHTTPConfig(
newHTTPTargetConfig(host + maskSecretPath),
)
require.NoError(t, err)
statusCode, _, _, reqErr := e.ExportDoHTTPRequest( statusCode, _, _, reqErr := e.ExportDoHTTPRequest(
context.TODO(), context.TODO(), cfg,
&database.Target{URL: host + maskSecretPath},
&database.Event{Body: `{"test":true}`}, &database.Event{Body: `{"test":true}`},
) )
require.Error(t, reqErr) require.Error(t, reqErr)
@@ -176,7 +188,7 @@ func TestDoHTTPRequest_UnparsableURLIsMasked(t *testing.T) {
statusCode, _, _, reqErr := e.ExportDoHTTPRequest( statusCode, _, _, reqErr := e.ExportDoHTTPRequest(
context.TODO(), context.TODO(),
&database.Target{ &delivery.HTTPTargetConfig{
URL: "https://hooks.example.com" + maskSecretPath + "\n", URL: "https://hooks.example.com" + maskSecretPath + "\n",
}, },
&database.Event{}, &database.Event{},
+9 -3
View File
@@ -2,6 +2,7 @@ package delivery_test
import ( import (
"context" "context"
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/netip" "net/netip"
@@ -58,9 +59,9 @@ func TestUserAgent_IsTheBuildVersion(t *testing.T) {
statusCode, _, _, err := e.ExportDoHTTPRequest( statusCode, _, _, err := e.ExportDoHTTPRequest(
context.Background(), context.Background(),
&database.Target{ &delivery.HTTPTargetConfig{
URL: ts.URL, URL: ts.URL,
Headers: map[string]string{headerUserAgent: "configured/1"}, Headers: map[string]string{"User-Agent": "configured/1"},
}, },
&database.Event{Headers: `{"User-Agent":["curl/8"]}`}, &database.Event{Headers: `{"User-Agent":["curl/8"]}`},
) )
@@ -72,13 +73,18 @@ func TestUserAgent_IsTheBuildVersion(t *testing.T) {
db := testWebhookDB(t) db := testWebhookDB(t)
targetID := uuid.New().String() targetID := uuid.New().String()
slackCfg, err := json.Marshal(
delivery.SlackTargetConfig{WebhookURL: ts.URL},
)
require.NoError(t, err)
event := seedEvent(t, db, `{"action":"test"}`) event := seedEvent(t, db, `{"action":"test"}`)
dlv := seedDelivery( dlv := seedDelivery(
t, db, event.ID, targetID, database.DeliveryStatusPending, t, db, event.ID, targetID, database.DeliveryStatusPending,
) )
e.ExportDeliverSlack(context.Background(), db, buildSlackDelivery( e.ExportDeliverSlack(context.Background(), db, buildSlackDelivery(
dlv, event, targetID, "test-slack", ts.URL, dlv, event, targetID, "test-slack", string(slackCfg),
)) ))
require.Len(t, userAgents, 1, "the slack target sent no request") require.Len(t, userAgents, 1, "the slack target sent no request")
assert.Equal(t, want, <-userAgents, "slack target") assert.Equal(t, want, <-userAgents, "slack target")
+3 -5
View File
@@ -135,18 +135,16 @@ func openDB(
) )
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, gdb.Exec( require.NoError(t, gdb.AutoMigrate(&thing{}))
"CREATE TABLE things (id text PRIMARY KEY, name text)",
).Error)
// Creating the table is not what any of these cases is about. // Migration chatter is not what any of these cases is about.
buf.Reset() buf.Reset()
return gdb return gdb
} }
// assertBounded holds every line the adapter wrote to the stated // assertBounded holds every line the adapter wrote to the stated
// ceiling and proves no line carried a whole client value. // ceiling and proves each was cut rather than merely short.
func assertBounded(t *testing.T, out string) { func assertBounded(t *testing.T, out string) {
t.Helper() t.Helper()
+5 -10
View File
@@ -115,8 +115,8 @@ func skipDir(name string) bool {
} }
// walkNonTestGo parses every non-test .go file under root. It returns // walkNonTestGo parses every non-test .go file under root. It returns
// the directories, relative to root, it parsed a file in or below, // the directories, relative to root, it parsed a file in, along with
// along with every unguarded Scan it found. // every unguarded Scan it found.
func walkNonTestGo(t *testing.T, root string) (map[string]bool, []string) { func walkNonTestGo(t *testing.T, root string) (map[string]bool, []string) {
t.Helper() t.Helper()
@@ -155,12 +155,7 @@ func walkNonTestGo(t *testing.T, root string) (map[string]bool, []string) {
return err return err
} }
// The directories above count too, so that one holding
// only a nested package, as internal/db does, is not
// taken for one the walk skipped.
for ; dir != "."; dir = filepath.Dir(dir) {
walked[dir] = true walked[dir] = true
}
for _, pos := range unguardedScans(fset, file) { for _, pos := range unguardedScans(fset, file) {
hits = append(hits, relPosition(root, pos)) hits = append(hits, relPosition(root, pos))
@@ -210,9 +205,9 @@ func TestGormScanIsNeverCalledOutsideTests(t *testing.T) {
walked, offenders := walkNonTestGo(t, root) walked, offenders := walkNonTestGo(t, root)
// The module's packages are static, templates, and every directory // The module's packages are static, templates, and every directory
// directly under cmd and internal. Each holds non-test code, itself // directly under cmd and internal. Each holds non-test code, so one
// or in a package below it, so one the walk parsed nothing in or // the walk parsed nothing in was skipped, and a Scan there would
// below was skipped, and a Scan there would pass unseen. // pass unseen.
packages := []string{"static", "templates"} packages := []string{"static", "templates"}
for _, parent := range []string{"cmd", "internal"} { for _, parent := range []string{"cmd", "internal"} {
+1 -23
View File
@@ -1,14 +1,8 @@
package handlers package handlers
import (
"strconv"
"time"
)
const ( const (
// archiveExpiryNever is the archive expiry that keeps archived // archiveExpiryNever is the archive expiry that keeps archived
// events forever. It is stored as an ArchiveExpiry of 0, and an // events forever. A stored empty expiry means the same.
// empty expiry on a form means the same.
archiveExpiryNever = "never" archiveExpiryNever = "never"
// tmplKeyArchiveExpiryChoices is the template data key for the // tmplKeyArchiveExpiryChoices is the template data key for the
@@ -62,19 +56,3 @@ func archiveExpiryOptions(expiry string) []archiveChoice {
return append([]archiveChoice{own}, options...) return append([]archiveChoice{own}, options...)
} }
// formatArchiveExpiry writes a stored ArchiveExpiry the way the
// expiry select spells its values, so the target edit form starts on
// the stored choice: never for 0, a whole number of hours as "720h",
// and anything else as Go writes a duration, which the form accepts
// back unchanged.
func formatArchiveExpiry(expiry time.Duration) string {
switch {
case expiry == 0:
return archiveExpiryNever
case expiry%time.Hour == 0:
return strconv.FormatInt(int64(expiry/time.Hour), 10) + "h"
default:
return expiry.String()
}
}
+16 -20
View File
@@ -7,7 +7,6 @@ import (
"regexp" "regexp"
"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"
@@ -95,18 +94,14 @@ func TestArchiveExpiryChoices(t *testing.T) {
env := setupSourceTest(t) env := setupSourceTest(t)
choices := []struct { choices := []struct{ value, shown string }{
value string {expiryNever, expiryNever},
stored time.Duration {"1h", "1 hour"},
shown string {"12h", "12 hours"},
}{ {"24h", "1 day"},
{expiryNever, 0, expiryNever}, {"720h", "30 days"},
{"1h", time.Hour, "1 hour"}, {"2160h", "90 days"},
{"12h", 12 * time.Hour, "12 hours"}, {"8760h", "365 days"},
{"24h", 24 * time.Hour, "1 day"},
{"720h", 720 * time.Hour, "30 days"},
{"2160h", 2160 * time.Hour, "90 days"},
{"8760h", 8760 * time.Hour, "365 days"},
} }
for _, choice := range choices { for _, choice := range choices {
@@ -127,7 +122,9 @@ func TestArchiveExpiryChoices(t *testing.T) {
targets := targetsForWebhook(t, env.db, webhook.ID) targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1) require.Len(t, targets, 1)
assert.Equal(t, choice.stored, targets[0].ArchiveExpiry) assert.JSONEq(
t, `{"expiry":"`+choice.value+`"}`, targets[0].Config,
)
assert.Equal( assert.Equal(
t, []string{choice.shown}, t, []string{choice.shown},
@@ -141,7 +138,7 @@ func TestArchiveExpiryChoices(t *testing.T) {
} }
// TestArchiveExpiryEditStartsOnStoredValue checks the edit form of a // TestArchiveExpiryEditStartsOnStoredValue checks the edit form of a
// database target whose stored expiry is 0, which selects never, // database target whose stored expiry is empty, which selects never,
// and of one whose expiry is not one of the choices, which is listed // and of one whose expiry is not one of the choices, which is listed
// first as its own selected entry and saved unchanged. // first as its own selected entry and saved unchanged.
func TestArchiveExpiryEditStartsOnStoredValue(t *testing.T) { func TestArchiveExpiryEditStartsOnStoredValue(t *testing.T) {
@@ -151,8 +148,7 @@ func TestArchiveExpiryEditStartsOnStoredValue(t *testing.T) {
webhook := seedWebhookWithRetention(t, env.db, 30) webhook := seedWebhookWithRetention(t, env.db, 30)
empty := seedConfiguredTarget( empty := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase, t, env.db, webhook.ID, database.TargetTypeDatabase, "",
database.Target{},
) )
_, selected := expirySelected(t, env, webhook.ID, empty.ID) _, selected := expirySelected(t, env, webhook.ID, empty.ID)
@@ -161,7 +157,7 @@ func TestArchiveExpiryEditStartsOnStoredValue(t *testing.T) {
webhook = seedWebhookWithRetention(t, env.db, 30) webhook = seedWebhookWithRetention(t, env.db, 30)
unlisted := seedConfiguredTarget( unlisted := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase, t, env.db, webhook.ID, database.TargetTypeDatabase,
database.Target{ArchiveExpiry: 36 * time.Hour}, `{"expiry":"36h"}`,
) )
assert.Equal(t, []string{"36 hours"}, expiryShown(t, env, webhook.ID)) assert.Equal(t, []string{"36 hours"}, expiryShown(t, env, webhook.ID))
@@ -183,7 +179,7 @@ func TestArchiveExpiryEditStartsOnStoredValue(t *testing.T) {
w := submitTargetEdit(env, webhook.ID, unlisted.ID, form) w := submitTargetEdit(env, webhook.ID, unlisted.ID, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.Equal( assert.JSONEq(
t, 36*time.Hour, storedTarget(t, env, unlisted.ID).ArchiveExpiry, t, `{"expiry":"36h"}`, storedTarget(t, env, unlisted.ID).Config,
) )
} }
+25 -39
View File
@@ -12,16 +12,12 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
) )
// The archive rotations these tests store: none keeps one file. // rotationNone is the archive rotation that keeps one file.
const ( const rotationNone = "none"
rotationNone = "none"
rotationDaily = "daily"
rotationHourly = "hourly"
)
// rotationShown returns the archive rotations the webhook page's // rotationShown returns the archive rotations the webhook page's
// target list shows. // target list shows.
@@ -45,9 +41,7 @@ func TestArchiveRotationChoices(t *testing.T) {
env := setupSourceTest(t) env := setupSourceTest(t)
for _, rotation := range []string{ for _, rotation := range []string{rotationNone, "monthly", "daily", "hourly"} {
rotationNone, "monthly", rotationDaily, rotationHourly,
} {
t.Run(rotation, func(t *testing.T) { t.Run(rotation, func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -66,8 +60,10 @@ func TestArchiveRotationChoices(t *testing.T) {
targets := targetsForWebhook(t, env.db, webhook.ID) targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1) require.Len(t, targets, 1)
assert.Equal(t, 720*time.Hour, targets[0].ArchiveExpiry) assert.JSONEq(t,
assert.Equal(t, rotation, targets[0].ArchiveRotation) `{"expiry":"720h","rotation":"`+rotation+`"}`,
targets[0].Config,
)
assert.Equal(t, assert.Equal(t,
[]string{rotation}, rotationShown(t, env, webhook.ID)) []string{rotation}, rotationShown(t, env, webhook.ID))
@@ -82,37 +78,26 @@ func TestArchiveRotationChoices(t *testing.T) {
w = submitTargetEdit(env, webhook.ID, targets[0].ID, form) w = submitTargetEdit(env, webhook.ID, targets[0].ID, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.JSONEq(t,
stored := storedTarget(t, env, targets[0].ID) `{"expiry":"720h","rotation":"hourly"}`,
assert.Equal(t, 720*time.Hour, stored.ArchiveExpiry) storedTarget(t, env, targets[0].ID).Config,
assert.Equal(t, rotationHourly, stored.ArchiveRotation) )
}) })
} }
} }
// TestArchiveRotationEditStartsOnNone checks that a database target // TestArchiveRotationEditStartsOnNone checks the edit form of a
// added with no rotation is stored with none and shown with none, and // database target with no rotation stored starts on none.
// that its edit form starts on none.
func TestArchiveRotationEditStartsOnNone(t *testing.T) { func TestArchiveRotationEditStartsOnNone(t *testing.T) {
t.Parallel() t.Parallel()
env := setupSourceTest(t) env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30) webhook := seedWebhookWithRetention(t, env.db, 30)
target := seedConfiguredTarget(
form := url.Values{} t, env.db, webhook.ID, database.TargetTypeDatabase, "",
form.Set("name", "archive")
form.Set("type", string(database.TargetTypeDatabase))
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) page := targetEditPage(t, env, webhook.ID, target.ID)
require.Len(t, targets, 1)
assert.Equal(t, rotationNone, targets[0].ArchiveRotation)
page := targetEditPage(t, env, webhook.ID, targets[0].ID)
assert.Equal(t, []string{rotationNone}, selectedIn(page, "rotation")) assert.Equal(t, []string{rotationNone}, selectedIn(page, "rotation"))
assert.Equal(t, []string{rotationNone}, rotationShown(t, env, webhook.ID)) assert.Equal(t, []string{rotationNone}, rotationShown(t, env, webhook.ID))
} }
@@ -140,7 +125,7 @@ func TestArchiveRotationRefused(t *testing.T) {
target := seedConfiguredTarget( target := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase, t, env.db, webhook.ID, database.TargetTypeDatabase,
database.Target{ArchiveRotation: rotationDaily}, `{"rotation":"daily"}`,
) )
form.Del("type") form.Del("type")
@@ -148,8 +133,8 @@ func TestArchiveRotationRefused(t *testing.T) {
w = submitTargetEdit(env, webhook.ID, target.ID, form) w = submitTargetEdit(env, webhook.ID, target.ID, form)
assert.Equal(t, http.StatusBadRequest, w.Code) assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "Invalid archive rotation") assert.Contains(t, w.Body.String(), "Invalid archive rotation")
assert.Equal( assert.JSONEq(t,
t, rotationDaily, storedTarget(t, env, target.ID).ArchiveRotation, `{"rotation":"daily"}`, storedTarget(t, env, target.ID).Config,
) )
} }
@@ -176,8 +161,9 @@ func TestHandleSourceCreateSubmit_ArchiveRotation(t *testing.T) {
targets := targetsForWebhook(t, env.db, webhook.ID) targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1) require.Len(t, targets, 1)
assert.Equal(t, 720*time.Hour, targets[0].ArchiveExpiry) assert.JSONEq(t,
assert.Equal(t, rotationDaily, targets[0].ArchiveRotation) `{"expiry":"720h","rotation":"daily"}`, targets[0].Config,
)
} }
// TestArchiveFileView_Rotated describes a daily target's archive files // TestArchiveFileView_Rotated describes a daily target's archive files
@@ -192,10 +178,10 @@ func TestArchiveFileView_Rotated(t *testing.T) {
webhook := seedWebhookWithRetention(t, env.db, 30) webhook := seedWebhookWithRetention(t, env.db, 30)
target := seedConfiguredTarget( target := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase, t, env.db, webhook.ID, database.TargetTypeDatabase,
database.Target{ArchiveRotation: rotationDaily}, `{"rotation":"daily"}`,
) )
path := archive.Path(env.dbMgr, &webhook, target) path := delivery.ArchivePath(env.dbMgr, &webhook, target)
stem := strings.TrimSuffix(path, ".db") stem := strings.TrimSuffix(path, ".db")
written := time.Date(2026, 10, 2, 9, 0, 0, 0, time.UTC) written := time.Date(2026, 10, 2, 9, 0, 0, 0, time.UTC)
+1 -1
View File
@@ -43,7 +43,7 @@ func TestDeliveryAttempts_ReadInTheTargetTypesOwnTerms(t *testing.T) {
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(string(tc.targetType)+" "+tc.outcome, func(t *testing.T) { t.Run(string(tc.targetType), func(t *testing.T) {
t.Parallel() t.Parallel()
f := newRecentEventsFixture(t) f := newRecentEventsFixture(t)
+90 -12
View File
@@ -5,7 +5,9 @@ import (
"github.com/go-chi/chi" "github.com/go-chi/chi"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
) )
// The outcomes of a replay POST, as the notice codes its redirect // The outcomes of a replay POST, as the notice codes its redirect
@@ -44,12 +46,12 @@ const (
// HandleDeliveryReplay re-sends a finished delivery's event to its // HandleDeliveryReplay re-sends a finished delivery's event to its
// target. // target.
// //
// A replay never touches the delivery it repeats. The delivery engine // A replay never touches the delivery it repeats. It creates a NEW
// creates a NEW pending delivery row for the same event and target and // pending delivery row for the same event and target and hands it to
// queues it as it queues the receiver's, so the original's status, // the delivery engine through the same Notifier the receiver uses, so
// attempts and timestamps stand as the record of what actually // the original's status, attempts and timestamps stand as the record
// happened, and the replay is retried, SSRF-guarded and circuit-broken // of what actually happened, and the replay is retried, SSRF-guarded
// exactly as a first attempt is. // and circuit-broken exactly as a first attempt is.
// //
// What is re-sent is the stored EVENT body, never the response the // What is re-sent is the stored EVENT body, never the response the
// original delivery received. // original delivery received.
@@ -143,8 +145,7 @@ func (h *Handlers) loadReplaySource(
return &original, true return &original, true
} }
// queueReplay has the delivery engine create and queue the new // queueReplay writes the new delivery and hands it to the engine.
// delivery, unless an earlier replay is still in flight.
func (h *Handlers) queueReplay( func (h *Handlers) queueReplay(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
@@ -170,8 +171,18 @@ func (h *Handlers) queueReplay(
return return
} }
deliveryID, err := h.engine.CreateReplay( var event database.Event
webhook.ID, original.EventID, target,
err = webhookDB.
First(&event, "id = ?", original.EventID).Error
if err != nil {
h.serverError(w, r, "failed to load event for replay", err)
return
}
task, err := createReplayDelivery(
webhookDB, webhook.ID, &event, target,
) )
if err != nil { if err != nil {
h.serverError( h.serverError(
@@ -182,14 +193,15 @@ func (h *Handlers) queueReplay(
} }
h.mtr.DeliveryReplayed(target.Type) h.mtr.DeliveryReplayed(target.Type)
h.notifier.Notify([]delivery.Task{task})
h.log.Info( h.log.Info(
"delivery replay queued", "delivery replay queued",
"webhook_id", webhook.ID, "webhook_id", webhook.ID,
"event_id", original.EventID, "event_id", event.ID,
"target_id", target.ID, "target_id", target.ID,
"replayed_delivery_id", original.ID, "replayed_delivery_id", original.ID,
"delivery_id", deliveryID, "delivery_id", task.DeliveryID,
) )
redirectToEventLog(w, r, webhook, replayQueued) redirectToEventLog(w, r, webhook, replayQueued)
@@ -251,6 +263,72 @@ func countInFlightDeliveries(
return count, err return count, err
} }
// createReplayDelivery writes the new pending delivery row, adds it to
// its target's totals in the same transaction, and returns the task
// that carries it to the delivery engine.
//
// The row is written with associations omitted, and neither Event nor
// Target is populated on it: GORM's SaveBeforeAssociations would
// otherwise upsert the whole target row — plaintext config, which for a
// Slack target is the credential — into the per-webhook event database.
// See https://git.eeqj.de/sneak/webhooker/issues/206.
func createReplayDelivery(
webhookDB *gorm.DB,
webhookID string,
event *database.Event,
target *database.Target,
) (delivery.Task, error) {
dlv := &database.Delivery{
EventID: event.ID,
TargetID: target.ID,
Status: database.DeliveryStatusPending,
Replay: true,
}
err := webhookDB.Transaction(func(tx *gorm.DB) error {
err := tx.Omit(clause.Associations).Create(dlv).Error
if err != nil {
return err
}
return database.AddTargetTotals(tx, database.TargetTotals{
TargetID: dlv.TargetID, Deliveries: 1,
})
})
if err != nil {
return delivery.Task{}, err
}
return delivery.Task{
DeliveryID: dlv.ID,
EventID: event.ID,
WebhookID: webhookID,
EntrypointID: event.EntrypointID,
TargetID: target.ID,
TargetName: target.Name,
TargetType: target.Type,
TargetConfig: target.Config,
MaxRetries: target.MaxRetries,
Method: event.Method,
RawQuery: event.RawQuery,
Headers: event.Headers,
ContentType: event.ContentType,
Body: replayBody(event.Body),
AttemptNum: 1,
}, nil
}
// replayBody returns the stored event body for a replay task to carry
// inline, or nil when it is large enough that the engine should fetch
// it from the per-webhook database instead.
func replayBody(body string) *string {
if len(body) >= delivery.MaxInlineBodySize {
return nil
}
return &body
}
// redirectToEventLog redirects a replay or resubmit back to the event // redirectToEventLog redirects a replay or resubmit back to the event
// log it was triggered from, carrying the outcome as its notice. A // log it was triggered from, carrying the outcome as its notice. A
// Replay form carries the list it was pressed in as show, so a replay // Replay form carries the list it was pressed in as show, so a replay
+96 -59
View File
@@ -12,6 +12,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/session" "sneak.berlin/go/webhooker/internal/session"
) )
@@ -22,7 +23,7 @@ const paramDeliveryID = "deliveryID"
// replayTargetURL is a public destination, so a target configured with // replayTargetURL is a public destination, so a target configured with
// it is one the SSRF guard would accept. Nothing in these tests // it is one the SSRF guard would accept. Nothing in these tests
// dispatches to it: the delivery engine is never started. // dispatches to it: the notifier is recorded, not run.
const replayTargetURL = "http://93.184.216.34/hook" const replayTargetURL = "http://93.184.216.34/hook"
// replayEventQuery is the query string a seeded event arrived with. // replayEventQuery is the query string a seeded event arrived with.
@@ -162,8 +163,14 @@ func postReplay(
// TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal is the // TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal is the
// core requirement: replaying a failed delivery succeeds, appends a // core requirement: replaying a failed delivery succeeds, appends a
// new delivery marked as a replay, and leaves the original row and its // new delivery, and leaves the original row and its recorded attempt
// recorded attempt exactly as they were. // exactly as they were.
//
// It also pins the two things a replay would be wrong to get from the
// original: the task carries the target's CURRENT configuration, which
// this test changes between the failure and the replay, and it carries
// the stored EVENT body rather than anything the failed attempt
// received back.
func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal( func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
t *testing.T, t *testing.T,
) { ) {
@@ -174,9 +181,10 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
sess *session.Session sess *session.Session
db *database.Database db *database.Database
dbMgr *database.WebhookDBManager dbMgr *database.WebhookDBManager
notif *recordingNotifier
) )
app := newTestApp(t, &h, &sess, &db, &dbMgr) app := newTestApp(t, &h, &sess, &db, &dbMgr, &notif)
app.RequireStart() app.RequireStart()
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
@@ -184,7 +192,7 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
tgt := seedConfiguredTarget( tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP, t, db, wh.ID, database.TargetTypeHTTP,
database.Target{URL: replayTargetURL}, `{"url":"`+replayTargetURL+`"}`,
) )
event, original := seedFailedDelivery( event, original := seedFailedDelivery(
@@ -196,6 +204,15 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
before := loadDelivery(t, webhookDB, original.ID) before := loadDelivery(t, webhookDB, original.ID)
// The operator fixes the destination, which is the whole reason
// to replay. The replay must use this, not the config the
// original delivery ran against.
const fixedConfig = `{"url":"http://93.184.216.34/fixed"}`
require.NoError(t, db.DB().Model(&database.Target{}).
Where("id = ?", tgt.ID).
Update("config", fixedConfig).Error)
w := postReplay(t, h, sess, wh.ID, original.ID) w := postReplay(t, h, sess, wh.ID, original.ID)
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
@@ -217,9 +234,14 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
assert.Equal( assert.Equal(
t, database.DeliveryStatusPending, replayed.Status, t, database.DeliveryStatusPending, replayed.Status,
) )
assert.True(t, replayed.Replay)
assertDeliveryUntouched(t, webhookDB, before) assertDeliveryUntouched(t, webhookDB, before)
tasks := notif.Tasks()
require.Len(t, tasks, 1)
assertReplayTask(
t, tasks[0], wh.ID, event, tgt, replayed.ID, fixedConfig,
)
assertNoLeakedTarget(t, webhookDB) assertNoLeakedTarget(t, webhookDB)
} }
@@ -253,62 +275,67 @@ func assertDeliveryUntouched(
) )
} }
// assertNoLeakedTarget proves the per-webhook database holds no // assertReplayTask proves the task handed to the delivery engine is
// target: an association write would have upserted the whole target, // the one the receiver would build for this event and this target, and
// plaintext config and all, into the event database. See // that it carries wantConfig — the target's configuration as it stands
// now rather than as the original delivery ran against it.
func assertReplayTask(
t *testing.T,
task delivery.Task,
webhookID string,
event *database.Event,
target *database.Target,
wantDeliveryID, wantConfig string,
) {
t.Helper()
assert.Equal(t, wantDeliveryID, task.DeliveryID)
assert.Equal(t, event.ID, task.EventID)
assert.Equal(t, webhookID, task.WebhookID)
assert.Equal(t, event.EntrypointID, task.EntrypointID)
assert.Equal(t, target.ID, task.TargetID)
assert.Equal(t, target.Type, task.TargetType)
assert.JSONEq(
t, wantConfig, task.TargetConfig,
"replay must use the target's current configuration",
)
assert.Equal(t, event.Method, task.Method)
assert.Equal(
t, replayEventQuery, task.RawQuery,
"replay re-sends the stored query string",
)
assert.Equal(t, event.Headers, task.Headers)
assert.Equal(t, event.ContentType, task.ContentType)
assert.Equal(t, 1, task.AttemptNum)
require.NotNil(t, task.Body)
assert.Equal(
t, event.Body, *task.Body,
"replay re-sends the stored event body",
)
}
// assertNoLeakedTarget proves the per-webhook database holds no target
// rows. AutoMigrate creates the table there because Delivery declares
// the relation, so it is a ROW that signals a leak: an association
// write would have upserted the whole target, plaintext config and
// all, into the event database. See
// https://git.eeqj.de/sneak/webhooker/issues/206. // https://git.eeqj.de/sneak/webhooker/issues/206.
func assertNoLeakedTarget(t *testing.T, webhookDB *gorm.DB) { func assertNoLeakedTarget(t *testing.T, webhookDB *gorm.DB) {
t.Helper() t.Helper()
assert.False( var leaked int64
t, webhookDB.Migrator().HasTable(&database.Target{}),
require.NoError(t, webhookDB.Unscoped().
Model(&database.Target{}).Count(&leaked).Error)
assert.Zero(
t, leaked,
"replay must not write the target into the event database", "replay must not write the target into the event database",
) )
} }
// TestHandleDeliveryReplay_FailedStoreIsAnError proves a replay the
// engine cannot store answers with the 500 error page and adds no
// delivery.
func TestHandleDeliveryReplay_FailedStoreIsAnError(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,
database.Target{URL: replayTargetURL},
)
event, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
// Without its target totals table the webhook's database refuses
// the totals write, which comes after the delivery row.
webhookDB, err := dbMgr.GetDB(wh.ID)
require.NoError(t, err)
require.NoError(t,
webhookDB.Migrator().DropTable(&database.TargetTotals{}))
w := postReplay(t, h, sess, wh.ID, original.ID)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, w.Body.String(), "500 Internal Server Error")
assert.Len(t, listDeliveries(t, webhookDB, event.ID), 1)
}
// TestHandleDeliveryReplay_RefusesDeletedTarget proves the required // TestHandleDeliveryReplay_RefusesDeletedTarget proves the required
// refusal: a target deleted since the delivery ran is reported as // refusal: a target deleted since the delivery ran is reported as
// deleted rather than erroring, and nothing is created. // deleted rather than erroring, and nothing is created or queued.
func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) { func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
t.Parallel() t.Parallel()
@@ -317,9 +344,10 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
sess *session.Session sess *session.Session
db *database.Database db *database.Database
dbMgr *database.WebhookDBManager dbMgr *database.WebhookDBManager
notif *recordingNotifier
) )
app := newTestApp(t, &h, &sess, &db, &dbMgr) app := newTestApp(t, &h, &sess, &db, &dbMgr, &notif)
app.RequireStart() app.RequireStart()
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
@@ -327,7 +355,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
tgt := seedConfiguredTarget( tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP, t, db, wh.ID, database.TargetTypeHTTP,
database.Target{URL: replayTargetURL}, `{"url":"`+replayTargetURL+`"}`,
) )
event, original := seedFailedDelivery( event, original := seedFailedDelivery(
@@ -355,6 +383,10 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
t, listDeliveries(t, webhookDB, event.ID), 1, t, listDeliveries(t, webhookDB, event.ID), 1,
"a refused replay must create no delivery", "a refused replay must create no delivery",
) )
assert.Empty(
t, notif.Tasks(),
"a refused replay must queue nothing",
)
// The refusal is specific, which is why the target is looked up // The refusal is specific, which is why the target is looked up
// including soft-deleted rows: an id that never named a target // including soft-deleted rows: an id that never named a target
@@ -387,9 +419,10 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
sess *session.Session sess *session.Session
db *database.Database db *database.Database
dbMgr *database.WebhookDBManager dbMgr *database.WebhookDBManager
notif *recordingNotifier
) )
app := newTestApp(t, &h, &sess, &db, &dbMgr) app := newTestApp(t, &h, &sess, &db, &dbMgr, &notif)
app.RequireStart() app.RequireStart()
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
@@ -397,7 +430,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
tgt := seedConfiguredTarget( tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP, t, db, wh.ID, database.TargetTypeHTTP,
database.Target{URL: replayTargetURL}, `{"url":"`+replayTargetURL+`"}`,
) )
event, original := seedFailedDelivery( event, original := seedFailedDelivery(
@@ -427,6 +460,10 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
t, listDeliveries(t, webhookDB, event.ID), 2, t, listDeliveries(t, webhookDB, event.ID), 2,
"the refused second replay must add nothing", "the refused second replay must add nothing",
) )
assert.Len(
t, notif.Tasks(), 1,
"only the first replay reaches the delivery engine",
)
// A delivery the engine has not finished is not replayable // A delivery the engine has not finished is not replayable
// either, which is the same rule seen from the other side. // either, which is the same rule seen from the other side.
@@ -467,7 +504,7 @@ func TestHandleDeliveryReplay_ReturnsToTheListItWasPressedIn(
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
tgt := seedConfiguredTarget( tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP, t, db, wh.ID, database.TargetTypeHTTP,
database.Target{URL: replayTargetURL}, `{"url":"`+replayTargetURL+`"}`,
) )
_, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID) _, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
@@ -525,7 +562,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
tgt := seedConfiguredTarget( tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP, t, db, wh.ID, database.TargetTypeHTTP,
database.Target{URL: replayTargetURL}, `{"url":"`+replayTargetURL+`"}`,
) )
_, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID) _, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
@@ -584,7 +621,7 @@ func TestHandleDeliveryReplay_LabelsTheReplay(t *testing.T) {
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
tgt := seedConfiguredTarget( tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP, t, db, wh.ID, database.TargetTypeHTTP,
database.Target{URL: replayTargetURL}, `{"url":"`+replayTargetURL+`"}`,
) )
event, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID) event, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
+8 -6
View File
@@ -12,12 +12,13 @@ import (
// //
// The bound is enforced here and in SQL, because this page's // The bound is enforced here and in SQL, because this page's
// memory profile must not depend on a constant in another // memory profile must not depend on a constant in another
// package staying where it is, or on every stored row having // package staying where it is, and because rows predating the
// been written by the delivery engine under its own cap. // delivery engine's own cap or restored from an archive are
// not covered by it at all.
// //
// It happens to equal that engine cap, so a row written by the // It happens to equal that engine cap, so a row written by the
// engine reaches this one exactly and is never cut twice. // current engine reaches this one exactly and is never cut
// Nothing here may assume the two differ: see view. // twice. Nothing here may assume the two differ: see view.
const maxRenderedResponseBytes = 4096 const maxRenderedResponseBytes = 4096
// deliveryResultColumns is the delivery attempt projection. // deliveryResultColumns is the delivery attempt projection.
@@ -39,8 +40,9 @@ const deliveryResultColumns = "delivery_id, attempt_num, created_at, " +
// //
// Both Error and ResponseBody have been through the target's // Both Error and ResponseBody have been through the target's
// Redactor. The engine already masks the URL out of the // Redactor. The engine already masks the URL out of the
// errors it stores, so for errors this is a second line; for // errors it stores, so for errors this is a second line
// response bodies it is the only line, and its reach is what // covering rows written before it did; for response bodies it
// is the only line, and its reach is what
// delivery.Redactor documents. // delivery.Redactor documents.
type DeliveryResultView struct { type DeliveryResultView struct {
AttemptNum int AttemptNum int
+14 -13
View File
@@ -86,12 +86,12 @@ func seedFailedDeliveryWithResponse(
} }
// seedFailureAndRender seeds a failed delivery against a // seedFailureAndRender seeds a failed delivery against a
// target of the given type and URL, and returns the // target of the given type and config, and returns the
// rendered event log page. // rendered event log page.
func seedFailureAndRender( func seedFailureAndRender(
t *testing.T, t *testing.T,
targetType database.TargetType, targetType database.TargetType,
targetURL, responseBody string, config, responseBody string,
) string { ) string {
t.Helper() t.Helper()
@@ -109,7 +109,7 @@ func seedFailureAndRender(
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
tgt := seedConfiguredTarget( tgt := seedConfiguredTarget(
t, db, wh.ID, targetType, database.Target{URL: targetURL}, t, db, wh.ID, targetType, config,
) )
seedFailedDeliveryWithResponse( seedFailedDeliveryWithResponse(
@@ -129,7 +129,7 @@ func TestHandleSourceLogs_RendersFailedAttempt(t *testing.T) {
body := seedFailureAndRender( body := seedFailureAndRender(
t, t,
database.TargetTypeHTTP, database.TargetTypeHTTP,
"https://example.com/hook/abc", `{"url":"https://example.com/hook/abc"}`,
"upstream exploded", "upstream exploded",
) )
@@ -158,7 +158,8 @@ func TestHandleSourceLogs_RendersFailedAttempt(t *testing.T) {
// TestHandleSourceLogs_EscapesResponseBody proves the // TestHandleSourceLogs_EscapesResponseBody proves the
// response body is treated as the untrusted remote content it // response body is treated as the untrusted remote content it
// is. The remote chooses these bytes and the page is rendered // is. The remote chooses these bytes and the page is rendered
// inside the operator's authenticated origin. // inside the operator's authenticated origin, where the
// application's own CSP allows inline script from 'self'.
func TestHandleSourceLogs_EscapesResponseBody(t *testing.T) { func TestHandleSourceLogs_EscapesResponseBody(t *testing.T) {
t.Parallel() t.Parallel()
@@ -167,7 +168,7 @@ func TestHandleSourceLogs_EscapesResponseBody(t *testing.T) {
body := seedFailureAndRender( body := seedFailureAndRender(
t, t,
database.TargetTypeHTTP, database.TargetTypeHTTP,
"https://example.com/hook/abc", `{"url":"https://example.com/hook/abc"}`,
payload, payload,
) )
@@ -189,7 +190,7 @@ func TestHandleSourceLogs_RedactsCredentialEchoedInResponse(
body := seedFailureAndRender( body := seedFailureAndRender(
t, t,
database.TargetTypeSlack, database.TargetTypeSlack,
slackWebhookURL, `{"webhookUrl":"`+slackWebhookURL+`"}`,
"no_service: "+slackWebhookURL, "no_service: "+slackWebhookURL,
) )
@@ -228,7 +229,7 @@ func TestHandleSourceLogs_RedactsCredentialEchoedInError(
tgt := seedConfiguredTarget( tgt := seedConfiguredTarget(
t, db, wh.ID, t, db, wh.ID,
database.TargetTypeSlack, database.TargetTypeSlack,
database.Target{URL: slackWebhookURL}, `{"webhookUrl":"`+slackWebhookURL+`"}`,
) )
dlv := seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, "") dlv := seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, "")
@@ -300,7 +301,7 @@ func TestHandleSourceLogs_RedactsCredentialSeveredByTheEngineCut(
body := seedFailureAndRender( body := seedFailureAndRender(
t, t,
database.TargetTypeSlack, database.TargetTypeSlack,
slackWebhookURL, `{"webhookUrl":"`+slackWebhookURL+`"}`,
stored, stored,
) )
@@ -335,7 +336,7 @@ func TestHandleSourceLogs_RedactsCredentialSeveredBySQLCut(
body := seedFailureAndRender( body := seedFailureAndRender(
t, t,
database.TargetTypeSlack, database.TargetTypeSlack,
slackWebhookURL, `{"webhookUrl":"`+slackWebhookURL+`"}`,
stored, stored,
) )
@@ -373,7 +374,7 @@ func TestHandleSourceLogs_RedactsForSoftDeletedTarget(
tgt := seedConfiguredTarget( tgt := seedConfiguredTarget(
t, db, wh.ID, t, db, wh.ID,
database.TargetTypeSlack, database.TargetTypeSlack,
database.Target{URL: slackWebhookURL}, `{"webhookUrl":"`+slackWebhookURL+`"}`,
) )
seedFailedDeliveryWithResponse( seedFailedDeliveryWithResponse(
@@ -413,7 +414,7 @@ func TestHandleSourceLogs_BoundsRenderedAttempts(t *testing.T) {
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
tgt := seedConfiguredTarget( tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeLog, database.Target{}, t, db, wh.ID, database.TargetTypeLog, "",
) )
dlv := seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, "") dlv := seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, "")
@@ -480,7 +481,7 @@ func TestHandleSourceLogs_BoundsOversizeResponse(t *testing.T) {
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
tgt := seedConfiguredTarget( tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeLog, database.Target{}, t, db, wh.ID, database.TargetTypeLog, "",
) )
stored := strings.Repeat("A", responseCap*4) + tail stored := strings.Repeat("A", responseCap*4) + tail
+5 -4
View File
@@ -35,10 +35,11 @@ const eventBodyQuery = "SELECT cast(body as blob) " +
// response is deliberately not renderable. Content-Disposition // response is deliberately not renderable. Content-Disposition
// makes the browser download rather than display it, and the // makes the browser download rather than display it, and the
// octet-stream type plus nosniff stop it being interpreted as // octet-stream type plus nosniff stop it being interpreted as
// HTML or script. Without those a stored payload would be shown // HTML or script. Without those a stored payload would execute
// as a page of the operator's own origin. The application's CSP // as the logged-in operator. The application's CSP does not
// is a second defence behind them: it allows no inline script, // help here: script-src allows 'unsafe-inline' from 'self', so
// so such a page could not run script of its own. // a document served from this origin could run its own inline
// script.
func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc { func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
webhook, ok := h.ownedWebhook(w, r) webhook, ok := h.ownedWebhook(w, r)
+1 -1
View File
@@ -84,7 +84,7 @@ func TestHandleEventDetail_ShowsEventWholeWithDeliveries(t *testing.T) {
assert.Contains(t, page, strconv.Itoa(len(body))+" bytes") assert.Contains(t, page, strconv.Itoa(len(body))+" bytes")
assert.Contains(t, page, "{\n &#34;pad&#34;: &#34;xxx") assert.Contains(t, page, "{\n &#34;pad&#34;: &#34;xxx")
assert.Contains(t, page, "&#34;tail&#34;: &#34;"+sentinel+"&#34;\n}") assert.Contains(t, page, "&#34;tail&#34;: &#34;"+sentinel+"&#34;\n}")
assert.Contains(t, page, `break-all max-h-[32rem] overflow-y-auto"`) assert.Contains(t, page, `style="max-height: 32rem; overflow-y: auto"`)
assert.NotContains(t, page, "Showing the first") assert.NotContains(t, page, "Showing the first")
assert.Contains(t, page, target.Name) assert.Contains(t, page, target.Name)
assert.Contains(t, page, ">failed</span>") assert.Contains(t, page, ">failed</span>")
+4 -4
View File
@@ -13,8 +13,8 @@ import (
// DeliveryView is the display-safe projection of a delivery // DeliveryView is the display-safe projection of a delivery
// for the event log page. Its target is a TargetView, so the // for the event log page. Its target is a TargetView, so the
// target's URL and headers — which hold the target's // stored configuration blob — which holds the target's
// credential — have no path to the template. // credential — has no path to the template.
type DeliveryView struct { type DeliveryView struct {
ID string ID string
Status database.DeliveryStatus Status database.DeliveryStatus
@@ -126,7 +126,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
// loadTargetMap loads targets into a map of display-safe // loadTargetMap loads targets into a map of display-safe
// views keyed by target ID, each paired with its redactor. // views keyed by target ID, each paired with its redactor.
// The projection happens here so that no caller can hand a // The projection happens here so that no caller can hand a
// raw target, URL and headers and all, to a template: the // raw target, configuration blob and all, to a template: the
// raw rows do not leave this function. // raw rows do not leave this function.
// //
// The load is Unscoped because deleting a target only soft // The load is Unscoped because deleting a target only soft
@@ -496,7 +496,7 @@ const deliveryIDChunkSize = 500
// //
// Each response body is cut by SQLite rather than in Go, for // Each response body is cut by SQLite rather than in Go, for
// the reason deliveryResultColumns gives. How many attempts a // the reason deliveryResultColumns gives. How many attempts a
// delivery has is bounded by the target's MaxAttempts, which the // delivery has is the target's MaxRetries, which the
// authenticated operator sets; how many of them reach the page // authenticated operator sets; how many of them reach the page
// is bounded again by maxRenderedAttempts. // is bounded again by maxRenderedAttempts.
func (h *Handlers) loadDeliveryResults( func (h *Handlers) loadDeliveryResults(
+21 -13
View File
@@ -171,29 +171,37 @@ func loadResubmitSource(
return src, true, nil return src, true, nil
} }
// queueResubmit has the delivery engine store the copy, with a delivery // queueResubmit stores the copy and fans it out to the webhook's
// for each of the webhook's active targets as they stand now, through // active targets.
// the same StoreEvent the receiver uses. The one difference is that the
// copy leaves its entrypoint's last event as it is, since it did not
// arrive on that URL.
func (h *Handlers) queueResubmit( func (h *Handlers) queueResubmit(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
webhook database.Webhook, webhook database.Webhook,
src resubmitSource, src resubmitSource,
) { ) {
event := &database.Event{ // The receiver's own query, run now: an active target created
// after the original event arrived is included, and an
// inactive one is skipped rather than refused.
targets, err := h.loadActiveTargets(webhook.ID)
if err != nil {
h.serverError(w, r, "failed to query targets", err)
return
}
event, tasks, err := h.createAndFanOut(
eventSource{
WebhookID: webhook.ID, WebhookID: webhook.ID,
EntrypointID: src.EntrypointID, EntrypointID: src.EntrypointID,
Method: src.Method, Method: src.Method,
RawQuery: src.RawQuery, RawQuery: src.RawQuery,
Headers: src.Headers, HeadersJSON: src.Headers,
ContentType: src.ContentType, ContentType: src.ContentType,
Body: string(src.Body), Body: src.Body,
ResubmittedFromID: &src.ID, ResubmittedFromID: &src.ID,
} },
targets,
deliveries, err := h.engine.StoreEvent(event) )
if err != nil { if err != nil {
h.serverError(w, r, "failed to store resubmitted event", err) h.serverError(w, r, "failed to store resubmitted event", err)
@@ -207,11 +215,11 @@ func (h *Handlers) queueResubmit(
"webhook_id", webhook.ID, "webhook_id", webhook.ID,
"event_id", event.ID, "event_id", event.ID,
"resubmitted_from_id", src.ID, "resubmitted_from_id", src.ID,
"target_count", deliveries, "target_count", len(tasks),
) )
code := resubmitQueued code := resubmitQueued
if deliveries == 0 { if len(tasks) == 0 {
code = resubmitNoTargets code = resubmitNoTargets
} }
+100 -79
View File
@@ -19,7 +19,7 @@ import (
// resubmitTargetURL is a public destination, so a target configured // resubmitTargetURL is a public destination, so a target configured
// with it is one the SSRF guard would accept. Nothing in these tests // with it is one the SSRF guard would accept. Nothing in these tests
// dispatches to it: the delivery engine is never started. // dispatches to it: the notifier is recorded, not run.
const resubmitTargetURL = "http://93.184.216.34/hook" const resubmitTargetURL = "http://93.184.216.34/hook"
// resubmitEventHeaders and resubmitEventQuery are the stored header // resubmitEventHeaders and resubmitEventQuery are the stored header
@@ -134,9 +134,10 @@ func TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent(
sess *session.Session sess *session.Session
db *database.Database db *database.Database
dbMgr *database.WebhookDBManager dbMgr *database.WebhookDBManager
notif *recordingNotifier
) )
app := newTestApp(t, &h, &sess, &db, &dbMgr) app := newTestApp(t, &h, &sess, &db, &dbMgr, &notif)
app.RequireStart() app.RequireStart()
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
@@ -150,7 +151,7 @@ func TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent(
// captured. It has no prior delivery of anything. // captured. It has no prior delivery of anything.
tgt := seedConfiguredTarget( tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP, t, db, wh.ID, database.TargetTypeHTTP,
database.Target{URL: resubmitTargetURL}, `{"url":"`+resubmitTargetURL+`"}`,
) )
w := postResubmit(t, h, sess, wh.ID, original.ID) w := postResubmit(t, h, sess, wh.ID, original.ID)
@@ -188,6 +189,11 @@ func TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent(
t, database.DeliveryStatusPending, deliveries[0].Status, t, database.DeliveryStatusPending, deliveries[0].Status,
) )
tasks := notif.Tasks()
require.Len(t, tasks, 1)
assert.Equal(t, deliveries[0].ID, tasks[0].DeliveryID)
assertResubmitTask(t, tasks[0], wh.ID, &fresh, tgt)
assertNoLeakedTarget(t, webhookDB) assertNoLeakedTarget(t, webhookDB)
} }
@@ -219,6 +225,32 @@ func assertEventCopy(
) )
} }
// assertResubmitTask proves the task handed to the delivery engine is
// the one the receiver would build for the NEW event and this target.
func assertResubmitTask(
t *testing.T,
task delivery.Task,
webhookID string,
fresh *database.Event,
target *database.Target,
) {
t.Helper()
assert.Equal(t, fresh.ID, task.EventID)
assert.Equal(t, webhookID, task.WebhookID)
assert.Equal(t, fresh.EntrypointID, task.EntrypointID)
assert.Equal(t, target.ID, task.TargetID)
assert.Equal(t, target.Type, task.TargetType)
assert.Equal(t, fresh.Method, task.Method)
assert.Equal(t, fresh.RawQuery, task.RawQuery)
assert.Equal(t, fresh.Headers, task.Headers)
assert.Equal(t, fresh.ContentType, task.ContentType)
assert.Equal(t, 1, task.AttemptNum)
require.NotNil(t, task.Body)
assert.Equal(t, fresh.Body, *task.Body)
}
// TestHandleEventResubmit_IsRepeatable proves the requirement replay // TestHandleEventResubmit_IsRepeatable proves the requirement replay
// deliberately does not meet: firing the same captured event at a // deliberately does not meet: firing the same captured event at a
// backend over and over must work, with no in-flight refusal, and each // backend over and over must work, with no in-flight refusal, and each
@@ -231,9 +263,10 @@ func TestHandleEventResubmit_IsRepeatable(t *testing.T) {
sess *session.Session sess *session.Session
db *database.Database db *database.Database
dbMgr *database.WebhookDBManager dbMgr *database.WebhookDBManager
notif *recordingNotifier
) )
app := newTestApp(t, &h, &sess, &db, &dbMgr) app := newTestApp(t, &h, &sess, &db, &dbMgr, &notif)
app.RequireStart() app.RequireStart()
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
@@ -241,7 +274,7 @@ func TestHandleEventResubmit_IsRepeatable(t *testing.T) {
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
tgt := seedConfiguredTarget( tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP, t, db, wh.ID, database.TargetTypeHTTP,
database.Target{URL: resubmitTargetURL}, `{"url":"`+resubmitTargetURL+`"}`,
) )
original := seedStoredEvent(t, dbMgr, wh.ID, `{"fire":"again"}`) original := seedStoredEvent(t, dbMgr, wh.ID, `{"fire":"again"}`)
@@ -267,18 +300,30 @@ func TestHandleEventResubmit_IsRepeatable(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
events := listEvents(t, webhookDB) events := listEvents(t, webhookDB)
require.Len( require.Len(t, events, presses+1)
t, events, presses+1, "each resubmit creates its own event",
)
assert.Empty( tasks := notif.Tasks()
t, listDeliveries(t, webhookDB, original.ID), require.Len(t, tasks, presses)
seen := make(map[string]struct{}, presses)
for _, task := range tasks {
assert.Equal(t, tgt.ID, task.TargetID)
assert.NotEqual(
t, original.ID, task.EventID,
"each resubmit delivers its own new event", "each resubmit delivers its own new event",
) )
// Every copy has its own delivery, and names the same source _, dup := seen[task.EventID]
// event, so twenty presses stay traceable to the one captured assert.False(t, dup, "each resubmit creates its own event")
// request.
seen[task.EventID] = struct{}{}
require.Len(t, listDeliveries(t, webhookDB, task.EventID), 1)
}
// Every copy names the same source event, so twenty presses stay
// traceable to the one captured request.
for _, e := range events { for _, e := range events {
if e.ID == original.ID { if e.ID == original.ID {
continue continue
@@ -286,10 +331,6 @@ func TestHandleEventResubmit_IsRepeatable(t *testing.T) {
require.NotNil(t, e.ResubmittedFromID) require.NotNil(t, e.ResubmittedFromID)
assert.Equal(t, original.ID, *e.ResubmittedFromID) assert.Equal(t, original.ID, *e.ResubmittedFromID)
deliveries := listDeliveries(t, webhookDB, e.ID)
require.Len(t, deliveries, 1)
assert.Equal(t, tgt.ID, deliveries[0].TargetID)
} }
} }
@@ -305,9 +346,10 @@ func TestHandleEventResubmit_OversizeBodySurvivesIntact(t *testing.T) {
sess *session.Session sess *session.Session
db *database.Database db *database.Database
dbMgr *database.WebhookDBManager dbMgr *database.WebhookDBManager
notif *recordingNotifier
) )
app := newTestApp(t, &h, &sess, &db, &dbMgr) app := newTestApp(t, &h, &sess, &db, &dbMgr, &notif)
app.RequireStart() app.RequireStart()
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
@@ -326,7 +368,7 @@ func TestHandleEventResubmit_OversizeBodySurvivesIntact(t *testing.T) {
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
seedConfiguredTarget( seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP, t, db, wh.ID, database.TargetTypeHTTP,
database.Target{URL: resubmitTargetURL}, `{"url":"`+resubmitTargetURL+`"}`,
) )
original := seedStoredEvent(t, dbMgr, wh.ID, stored) original := seedStoredEvent(t, dbMgr, wh.ID, stored)
@@ -343,7 +385,22 @@ func TestHandleEventResubmit_OversizeBodySurvivesIntact(t *testing.T) {
t, stored, fresh.Body, t, stored, fresh.Body,
"the stored body must be copied byte for byte", "the stored body must be copied byte for byte",
) )
assert.Len(t, listDeliveries(t, webhookDB, fresh.ID), 1)
tasks := notif.Tasks()
require.Len(t, tasks, 1)
assert.Nil(
t, tasks[0].Body,
"a body over the inline limit is fetched from the new "+
"event row rather than carried on the task",
)
// The engine's own read of the body, against the new event id:
// what it would send is what was stored.
var loaded database.Event
require.NoError(t, webhookDB.Select("body").
First(&loaded, "id = ?", tasks[0].EventID).Error)
assert.Equal(t, stored, loaded.Body)
} }
// TestHandleEventResubmit_SkipsInactiveTarget proves a deactivated // TestHandleEventResubmit_SkipsInactiveTarget proves a deactivated
@@ -357,9 +414,10 @@ func TestHandleEventResubmit_SkipsInactiveTarget(t *testing.T) {
sess *session.Session sess *session.Session
db *database.Database db *database.Database
dbMgr *database.WebhookDBManager dbMgr *database.WebhookDBManager
notif *recordingNotifier
) )
app := newTestApp(t, &h, &sess, &db, &dbMgr) app := newTestApp(t, &h, &sess, &db, &dbMgr, &notif)
app.RequireStart() app.RequireStart()
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
@@ -367,11 +425,11 @@ func TestHandleEventResubmit_SkipsInactiveTarget(t *testing.T) {
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
active := seedConfiguredTarget( active := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP, t, db, wh.ID, database.TargetTypeHTTP,
database.Target{URL: resubmitTargetURL}, `{"url":"`+resubmitTargetURL+`"}`,
) )
off := seedConfiguredTarget( off := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP, t, db, wh.ID, database.TargetTypeHTTP,
database.Target{URL: resubmitTargetURL + "/off"}, `{"url":"`+resubmitTargetURL+`/off"}`,
) )
require.NoError(t, db.DB().Model(&database.Target{}). require.NoError(t, db.DB().Model(&database.Target{}).
@@ -390,19 +448,21 @@ func TestHandleEventResubmit_SkipsInactiveTarget(t *testing.T) {
"an inactive target is skipped, not an error", "an inactive target is skipped, not an error",
) )
tasks := notif.Tasks()
require.Len(t, tasks, 1)
assert.Equal(t, active.ID, tasks[0].TargetID)
webhookDB, err := dbMgr.GetDB(wh.ID) webhookDB, err := dbMgr.GetDB(wh.ID)
require.NoError(t, err) require.NoError(t, err)
fresh := theOtherEvent(t, listEvents(t, webhookDB), original.ID) fresh := theOtherEvent(t, listEvents(t, webhookDB), original.ID)
deliveries := listDeliveries(t, webhookDB, fresh.ID) require.Len(t, listDeliveries(t, webhookDB, fresh.ID), 1)
require.Len(t, deliveries, 1)
assert.Equal(t, active.ID, deliveries[0].TargetID)
} }
// TestHandleEventResubmit_NoActiveTargetsStillStoresEvent proves a // TestHandleEventResubmit_NoActiveTargetsStillStoresEvent proves a
// source with nothing to deliver to behaves as the receiver does: the // source with nothing to deliver to behaves as the receiver does: the
// event is stored with no delivery, and the operator is told so rather // event is stored, nothing is queued, and the operator is told so
// than being shown an error. // rather than being shown an error.
func TestHandleEventResubmit_NoActiveTargetsStillStoresEvent( func TestHandleEventResubmit_NoActiveTargetsStillStoresEvent(
t *testing.T, t *testing.T,
) { ) {
@@ -413,9 +473,10 @@ func TestHandleEventResubmit_NoActiveTargetsStillStoresEvent(
sess *session.Session sess *session.Session
db *database.Database db *database.Database
dbMgr *database.WebhookDBManager dbMgr *database.WebhookDBManager
notif *recordingNotifier
) )
app := newTestApp(t, &h, &sess, &db, &dbMgr) app := newTestApp(t, &h, &sess, &db, &dbMgr, &notif)
app.RequireStart() app.RequireStart()
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
@@ -432,14 +493,12 @@ func TestHandleEventResubmit_NoActiveTargetsStillStoresEvent(
w.Header().Get("Location"), w.Header().Get("Location"),
) )
assert.Empty(t, notif.Tasks())
webhookDB, err := dbMgr.GetDB(wh.ID) webhookDB, err := dbMgr.GetDB(wh.ID)
require.NoError(t, err) require.NoError(t, err)
events := listEvents(t, webhookDB) assert.Len(t, listEvents(t, webhookDB), 2)
assert.Len(t, events, 2)
fresh := theOtherEvent(t, events, original.ID)
assert.Empty(t, listDeliveries(t, webhookDB, fresh.ID))
} }
// TestHandleEventResubmit_RefusesEventOfAnotherWebhook proves the // TestHandleEventResubmit_RefusesEventOfAnotherWebhook proves the
@@ -455,9 +514,10 @@ func TestHandleEventResubmit_RefusesEventOfAnotherWebhook(
sess *session.Session sess *session.Session
db *database.Database db *database.Database
dbMgr *database.WebhookDBManager dbMgr *database.WebhookDBManager
notif *recordingNotifier
) )
app := newTestApp(t, &h, &sess, &db, &dbMgr) app := newTestApp(t, &h, &sess, &db, &dbMgr, &notif)
app.RequireStart() app.RequireStart()
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
@@ -468,7 +528,7 @@ func TestHandleEventResubmit_RefusesEventOfAnotherWebhook(
mine := seedWebhook(t, db) mine := seedWebhook(t, db)
seedConfiguredTarget( seedConfiguredTarget(
t, db, mine.ID, database.TargetTypeHTTP, t, db, mine.ID, database.TargetTypeHTTP,
database.Target{URL: resubmitTargetURL}, `{"url":"`+resubmitTargetURL+`"}`,
) )
seedStoredEvent(t, dbMgr, mine.ID, `{"mine":true}`) seedStoredEvent(t, dbMgr, mine.ID, `{"mine":true}`)
@@ -488,50 +548,11 @@ func TestHandleEventResubmit_RefusesEventOfAnotherWebhook(
w = postResubmit(t, h, sess, mine.ID, "not-a-uuid") w = postResubmit(t, h, sess, mine.ID, "not-a-uuid")
assert.Equal(t, http.StatusNotFound, w.Code) assert.Equal(t, http.StatusNotFound, w.Code)
for _, webhookID := range []string{theirs.ID, mine.ID} { assert.Empty(
webhookDB, err := dbMgr.GetDB(webhookID) t, notif.Tasks(),
require.NoError(t, err) "a refused resubmit must queue nothing",
assert.Len(
t, listEvents(t, webhookDB), 1,
"a refused resubmit must store nothing",
) )
} }
}
// TestHandleEventResubmit_FailedStoreIsAnError proves a copy the engine
// cannot store answers with the 500 error page and leaves no new event.
func TestHandleEventResubmit_FailedStoreIsAnError(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)
original := seedStoredEvent(t, dbMgr, wh.ID, `{"not":"stored"}`)
// Without its event totals table the webhook's database refuses
// the totals write, which comes after the event row.
webhookDB, err := dbMgr.GetDB(wh.ID)
require.NoError(t, err)
require.NoError(t,
webhookDB.Migrator().DropTable(&database.EventTotals{}))
w := postResubmit(t, h, sess, wh.ID, original.ID)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, w.Body.String(), "500 Internal Server Error")
assert.Len(t, listEvents(t, webhookDB), 1)
}
// TestHandleSourceLogs_ShowsResubmitProvenance proves the event log // TestHandleSourceLogs_ShowsResubmitProvenance proves the event log
// reports the relationship in both directions, which is what keeps it // reports the relationship in both directions, which is what keeps it
@@ -554,7 +575,7 @@ func TestHandleSourceLogs_ShowsResubmitProvenance(t *testing.T) {
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
seedConfiguredTarget( seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP, t, db, wh.ID, database.TargetTypeHTTP,
database.Target{URL: resubmitTargetURL}, `{"url":"`+resubmitTargetURL+`"}`,
) )
original := seedStoredEvent(t, dbMgr, wh.ID, `{"trace":"me"}`) original := seedStoredEvent(t, dbMgr, wh.ID, `{"trace":"me"}`)
+27 -19
View File
@@ -37,10 +37,10 @@ const MaxRenderedResponseBytesForTest = maxRenderedResponseBytes
// per-delivery attempt ceiling to the handlers_test package. // per-delivery attempt ceiling to the handlers_test package.
const MaxRenderedAttemptsForTest = maxRenderedAttempts const MaxRenderedAttemptsForTest = maxRenderedAttempts
// MaxTargetAttemptsForTest exposes the target max_attempts ceiling to // MaxTargetRetriesForTest exposes the target max_retries ceiling to
// the handlers_test package, so the tests assert against the constant // the handlers_test package, so the tests assert against the constant
// the handlers enforce rather than a number copied beside it. // the handlers enforce rather than a number copied beside it.
const MaxTargetAttemptsForTest = maxTargetAttempts const MaxTargetRetriesForTest = maxTargetRetries
// EventDBLeftMsgForTest and SidecarLeftMsgForTest expose the two // EventDBLeftMsgForTest and SidecarLeftMsgForTest expose the two
// messages the webhook delete handler logs when a file of the event // messages the webhook delete handler logs when a file of the event
@@ -133,28 +133,36 @@ func (s *Handlers) RenderTemplateForTest(
s.renderTemplate(w, r, pageTemplate, data) s.renderTemplate(w, r, pageTemplate, data)
} }
// SetSlackTargetSettingsForTest exposes setSlackTargetSettings for use // BuildSlackTargetConfigForTest exposes
// in the handlers_test package. // buildSlackTargetConfig for use in the handlers_test package.
func (s *Handlers) SetSlackTargetSettingsForTest( func (s *Handlers) BuildSlackTargetConfigForTest(
ctx context.Context, ctx context.Context,
target *database.Target,
targetURL string, targetURL string,
) string { ) (string, string, error) {
return s.setSlackTargetSettings(ctx, target, targetURL) return s.buildSlackTargetConfig(ctx, targetURL)
} }
// SetDatabaseTargetSettingsForTest exposes setDatabaseTargetSettings // BuildHTTPTargetConfigForTest exposes buildHTTPTargetConfig
// for use in the handlers_test package. // for use in the handlers_test package, taking the form fields
func SetDatabaseTargetSettingsForTest( // an HTTP target's configuration is built from.
target *database.Target, func (s *Handlers) BuildHTTPTargetConfigForTest(
ctx context.Context,
targetURL, headers, timeout string,
) (string, string, error) {
return s.buildHTTPTargetConfig(ctx, targetFormInput{
URL: targetURL,
Headers: headers,
Timeout: timeout,
})
}
// BuildDatabaseTargetConfigForTest exposes
// buildDatabaseTargetConfig for use in the handlers_test
// package.
func BuildDatabaseTargetConfigForTest(
expiry, rotation string, expiry, rotation string,
) string { ) (string, string, error) {
return setDatabaseTargetSettings(target, expiry, rotation) return buildDatabaseTargetConfig(expiry, rotation)
}
// FormatArchiveExpiryForTest exposes formatArchiveExpiry.
func FormatArchiveExpiryForTest(expiry time.Duration) string {
return formatArchiveExpiry(expiry)
} }
// ArchiveFileViewForTest exposes archiveFileView, which describes a // ArchiveFileViewForTest exposes archiveFileView, which describes a
+1 -1
View File
@@ -374,7 +374,7 @@ func assertFloodBounded(t *testing.T, label, out string) {
// this service's three gorm.Open sites: the main database behind the // this service's three gorm.Open sites: the main database behind the
// two unauthenticated lookups, and the per-webhook database the // two unauthenticated lookups, and the per-webhook database the
// WebhookDBManager opens. The third, the archive writer, is pinned in // WebhookDBManager opens. The third, the archive writer, is pinned in
// internal/archive, where its type lives. // internal/delivery, where its type lives.
// //
// What each assertion is worth, since two of the three would pass // What each assertion is worth, since two of the three would pass
// against a service that had never been fixed if the capture were set // against a service that had never been fixed if the capture were set
+17 -6
View File
@@ -10,11 +10,11 @@ import (
"html/template" "html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"sync"
"sync/atomic" "sync/atomic"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
@@ -64,8 +64,8 @@ type HandlersParams struct {
Healthcheck *healthcheck.Healthcheck Healthcheck *healthcheck.Healthcheck
Session *session.Session Session *session.Session
Middleware *middleware.Middleware Middleware *middleware.Middleware
Engine *delivery.Engine Notifier delivery.Notifier
Archives archive.Archives Archives delivery.Archives
CircuitBreakers delivery.CircuitBreakers CircuitBreakers delivery.CircuitBreakers
SSRFGuard *delivery.Guard SSRFGuard *delivery.Guard
Metrics *metrics.Set Metrics *metrics.Set
@@ -82,8 +82,8 @@ type Handlers struct {
dbMgr *database.WebhookDBManager dbMgr *database.WebhookDBManager
session *session.Session session *session.Session
mw *middleware.Middleware mw *middleware.Middleware
engine *delivery.Engine notifier delivery.Notifier
archives archive.Archives archives delivery.Archives
breakers delivery.CircuitBreakers breakers delivery.CircuitBreakers
mtr *metrics.Set mtr *metrics.Set
templates map[string]*template.Template templates map[string]*template.Template
@@ -93,6 +93,17 @@ type Handlers struct {
// is one delivery will actually attempt. // is one delivery will actually attempt.
ssrf *delivery.Guard ssrf *delivery.Guard
// renameMu makes the webhook edit, the target edit and target
// creation run one at a time, each held from loading the stored
// names through the archive rename, the save and any move back.
// Interleaved, one could rename an archive between another's
// rename and save, leaving the file named for one edit and the
// stored names from the other. An archive download holds it while
// 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
// dummyVerifications counts the equivalent-cost verifications // dummyVerifications counts the equivalent-cost verifications
// charged for usernames that do not exist. It exists so a test // charged for usernames that do not exist. It exists so a test
// can prove that path runs without measuring wall-clock time. // can prove that path runs without measuring wall-clock time.
@@ -138,7 +149,7 @@ func New(
s.dbMgr = params.WebhookDBMgr s.dbMgr = params.WebhookDBMgr
s.session = params.Session s.session = params.Session
s.mw = params.Middleware s.mw = params.Middleware
s.engine = params.Engine s.notifier = params.Notifier
s.archives = params.Archives s.archives = params.Archives
s.breakers = params.CircuitBreakers s.breakers = params.CircuitBreakers
s.mtr = params.Metrics s.mtr = params.Metrics

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