Author SHA1 Message Date
clawbot 6be031594f Add a Download button that exports a database target's archive as gzipped JSON (closes #374)
check / check (push) Waiting to run
Each database target on the webhook page links to
/hook/ID/targets/TARGETID/download, which streams the target's archive
as archive-WEBHOOK-TARGET-YYYYMMDDTHHMMSSZ.json.gz: the webhook, the
target, exported_at, and archived_events, one object per row keyed by
column, a body that is not valid UTF-8 in base64 with body_encoding.
The export reads through one cursor inside a read-only transaction:
one snapshot, no write lock. A download runs for as long as the client
keeps reading, and one that fails after it has started aborts the
connection. The request limit answers 504 only when the handler has
not started its response, so a long download is logged as its 200.

Model: opus-5-5
2026-10-02 16:04:11 +00:00
clawbot bf3df0312b Clear the environment in the cmd/webhooker tests that build a Config (closes #452)
check / check (push) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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
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
183 changed files with 13283 additions and 2941 deletions
+12 -8
View File
@@ -1,16 +1,20 @@
# .git is sent so the build can derive the version it stamps into the binary
# (script/version). Its config, which can hold a remote URL carrying a
# credential and which `git describe` does not need, is left out of a
# directory context. A context sent as a tar is not filtered by this file, so
# it carries .git/config unless its sender leaves it out.
.git/config
# No tracked file may be listed here: git in the build would see it as
# deleted and mark the version -dirty.
#
# .ci-fingerprint is deliberately NOT excluded: it is the CI cache barrier # .ci-fingerprint is deliberately NOT excluded: it is the CI cache barrier
# that keeps the check stages from replaying a cached pass. See the lint # that keeps the check stages from replaying a cached pass. See the lint
# stage of the Dockerfile. # stage of the Dockerfile.
.git/
bin/ bin/
# Third-party browser assets are fetched and hash-verified inside the build by # Extracted from 3p/ by `make assets` inside the build; a host copy is not
# script/fetch-assets. Excluding any host copy keeps a developer's working tree # needed. The tarball in 3p/ must stay in the context.
# from supplying the bytes that get shipped. The script and its
# static/vendor.sha256 manifest stay in the context.
static/js/alpine.min.js static/js/alpine.min.js
*.md
LICENSE
.editorconfig
.env .env
.env.* .env.*
*.db *.db
+8 -14
View File
@@ -12,9 +12,8 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23
with: with:
# The fingerprint step below needs history to find the last commit # The superseded-status step needs history to walk ancestors (it
# that touched the Docker build context, and the superseded-status # aborts on a shallow clone).
# step needs it to walk ancestors (it aborts on a shallow clone).
fetch-depth: 0 fetch-depth: 0
- name: Mark superseded run statuses - name: Mark superseded run statuses
@@ -28,16 +27,11 @@ jobs:
run: script/ci-mark-superseded run: script/ci-mark-superseded
- name: Fingerprint the build context - name: Fingerprint the build context
# `.dockerignore` keeps docs out of the build context, so a docs-only # Writes the hash of the commit being checked into the context, which
# commit legitimately replays the whole image from cache and stays # invalidates the `COPY . .` layer of both check stages: a commit
# cheap. Every other commit writes a new fingerprint into the context, # that was never linted, format-checked, tested and built cannot
# which invalidates the `COPY . .` layer of both check stages: a # report success from cache.
# commit that was never linted, formatted-checked, tested and built run: git rev-parse HEAD > .ci-fingerprint
# cannot report success from cache.
run: |
set -eu
fp="$(git log -1 --format=%H -- . ':!*.md' ':!LICENSE' ':!.editorconfig')"
printf '%s\n' "${fp:-$GITHUB_SHA}" > .ci-fingerprint
- name: Build Docker image (runs make check) - name: Build Docker image (runs make fmt-check, golangci-lint, make test, make build)
run: script/cibuild run: script/cibuild
+2 -3
View File
@@ -46,7 +46,6 @@ temp/
# CI cache barrier, written into the build context by the check workflow # CI cache barrier, written into the build context by the check workflow
.ci-fingerprint .ci-fingerprint
# Third-party browser assets, fetched and hash-verified by # Alpine.js, extracted by `make assets` from its tarball in 3p/, which is
# script/fetch-assets against static/vendor.sha256. Not committed: # what is committed.
# REPO_POLICIES.md forbids minified bundles in version control.
/static/js/alpine.min.js /static/js/alpine.min.js
Binary file not shown.
+36 -23
View File
@@ -12,8 +12,8 @@ WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
# Copy source code. In CI the context also carries .ci-fingerprint, whose # Copy source code. In CI the context also carries .ci-fingerprint, which
# value changes with every commit that touches the build context (see # holds the hash of the commit being checked (see
# .gitea/workflows/check.yml). That invalidates this layer, so the checks # .gitea/workflows/check.yml). That invalidates this layer, so the checks
# below cannot report success by replaying a cached pass. Do not add it to # below cannot report success by replaying a cached pass. Do not add it to
# .dockerignore. # .dockerignore.
@@ -26,7 +26,7 @@ COPY . .
# Dockerfile.lint, including --network=none (see its header for why). # Dockerfile.lint, including --network=none (see its header for why).
RUN make fmt-check RUN make fmt-check
RUN --network=none golangci-lint config verify --config .golangci.yml RUN --network=none golangci-lint config verify --config .golangci.yml
RUN --network=none golangci-lint run --config .golangci.yml ./... RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
# Build stage # Build stage
# golang:1.26.1-bookworm (Debian-based), 2026-03-17 # golang:1.26.1-bookworm (Debian-based), 2026-03-17
@@ -38,8 +38,13 @@ FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a349228
COPY --from=lint /src/go.sum /dev/null COPY --from=lint /src/go.sum /dev/null
# jq is a runtime dependency of script/ci-mark-superseded, which the test # jq is a runtime dependency of script/ci-mark-superseded, which the test
# suite executes. # suite executes. git is what script/version derives the version with.
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq git && rm -rf /var/lib/apt/lists/*
# A build context sent as a tar archive keeps its files' owners, and git
# refuses to read a checkout owned by another user. Trust this one
# whoever owns it.
RUN git config --system --add safe.directory /build
WORKDIR /build WORKDIR /build
@@ -51,25 +56,26 @@ RUN go mod download
# the lint stage above. # the lint stage above.
COPY . . COPY . .
# Fetch the third-party browser assets the UI serves. They are not committed # Run tests and build. Both first run script/assets, which extracts Alpine.js
# (REPO_POLICIES.md forbids minified bundles in version control) and # from its tarball in 3p/.
# .dockerignore keeps any host copy out of the build context, so this step is
# the only way they enter the image. Each download is checked against a
# hardcoded sha256 and the build fails on mismatch; make test re-checks the
# hashes against the bytes go:embed actually put in the binary.
RUN script/fetch-assets
# Run tests and build
RUN make test RUN make test
# Version stamped into the binary. .dockerignore excludes .git/, so # Version stamped into the binary: the VERSION build arg when one is
# nothing in this stage can derive it: script/docker resolves it on the # given, otherwise what script/version derives from the .git the build
# host and passes it in. The default is what a bare `docker build .` # context carries, so any `docker build .` of a clone stamps its commit.
# with no --build-arg gets, and it names no tag the tree may not be at. # With neither, as from a source tarball, it is "unknown".
# #
# Declared here, below the test and asset steps, so a changed version # Declared here, below the test step, so a changed version does not
# does not invalidate their cached layers. # invalidate its cached layer.
ARG VERSION=unknown ARG VERSION
# A context that carries .git must not stamp "unknown": that means git is
# missing here or could not read the checkout, and the image could not be
# traced back to its commit.
RUN if [ -d .git ] && [ "$(make version VERSION="$VERSION")" = unknown ]; then \
echo "version is unknown although the build context carries .git" >&2; \
exit 1; \
fi
RUN make build VERSION="$VERSION" RUN make build VERSION="$VERSION"
@@ -88,7 +94,9 @@ RUN CGO_ENABLED=1 make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static
# alpine:3.21, 2026-03-17 # alpine:3.21, 2026-03-17
FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
RUN apk --no-cache add ca-certificates # su-exec 0.2-r3 (Alpine 3.21), 2026-09-29: the entrypoint runs the app
# as webhooker with it.
RUN apk --no-cache add ca-certificates su-exec=0.2-r3
# Create non-root user # Create non-root user
RUN addgroup -g 1000 -S webhooker && \ RUN addgroup -g 1000 -S webhooker && \
@@ -99,13 +107,17 @@ WORKDIR /app
# Copy binary from builder # Copy binary from builder
COPY --from=builder /build/bin/webhooker /app/webhooker COPY --from=builder /build/bin/webhooker /app/webhooker
# Not under /app, which belongs to webhooker: this script runs as root.
COPY deploy/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
# Create data directory for all SQLite databases (main app DB + # Create data directory for all SQLite databases (main app DB +
# per-webhook event DBs). DATA_DIR defaults to /var/lib/webhooker. # per-webhook event DBs). DATA_DIR defaults to /var/lib/webhooker.
RUN mkdir -p /var/lib/webhooker RUN mkdir -p /var/lib/webhooker
RUN chown -R webhooker:webhooker /app /var/lib/webhooker RUN chown -R webhooker:webhooker /app /var/lib/webhooker
USER webhooker # No USER: the entrypoint starts as root to make the data directory
# webhooker's, then runs the app as webhooker.
EXPOSE 8080 EXPOSE 8080
@@ -124,4 +136,5 @@ ENV BIND_ADDRESS=0.0.0.0
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/.well-known/healthcheck || exit 1 CMD wget --no-verbose --tries=1 --spider http://localhost:8080/.well-known/healthcheck || exit 1
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["/app/webhooker"] CMD ["/app/webhooker"]
+29
View File
@@ -0,0 +1,29 @@
# Browser test image, built by script/test-browser (make test-browser). It
# runs the test in internal/server that loads the pages in a headless
# browser under the real Content-Security-Policy. That test is built only
# with the browser build tag, so make test leaves it out. Here the browser
# comes from a digest-pinned image, and if it is missing the test fails.
# golang:1.26.1-bookworm, 2026-03-17: the builder stage's image in Dockerfile.
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# The test binary embeds the templates and static files, so the browser
# stage needs nothing else. -p 4 keeps the compile's memory down, as in
# script/test.
RUN make assets && go test -c -p 4 -tags browser -o /browser.test ./internal/server
# chromedp/headless-shell:151.0.7922.109 (Debian trixie), 2026-08-11. The
# browser is on PATH as headless-shell, where the test's browser library
# looks for it.
FROM chromedp/headless-shell:151.0.7922.109@sha256:2d349b544a1ea6b5b5fd7c0fe99215ff662339c57407ee2e8c0a11af93516b04 AS browser
COPY --from=build /browser.test /browser.test
RUN /browser.test -test.v -test.timeout 90s -test.run '^TestAlpineRunsUnderTheSecurityPolicy$'
+3 -1
View File
@@ -34,4 +34,6 @@ COPY . .
# `run` silently ignores config keys it does not recognize, so a typo would # `run` silently ignores config keys it does not recognize, so a typo would
# disable a setting without a word. `config verify` is what catches that. # disable a setting without a word. `config verify` is what catches that.
RUN --network=none golangci-lint config verify --config .golangci.yml RUN --network=none golangci-lint config verify --config .golangci.yml
RUN --network=none golangci-lint run --config .golangci.yml ./... # --build-tags browser also lints the browser test, which is built only with
# that tag (make test-browser).
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
+11 -8
View File
@@ -1,15 +1,15 @@
.PHONY: bootstrap setup assets test lint fmt fmt-check check build run dev deps docker clean hooks css version .PHONY: bootstrap setup assets test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css version
# Default target # Default target
.DEFAULT_GOAL := check .DEFAULT_GOAL := check
# Version stamped into the binary. Derived from git by script/version; # Version stamped into the binary. Derived from git by script/version;
# override it (`make build VERSION=v1.2.3`) where git metadata is # override it (`make build VERSION=v1.2.3`) to stamp a given value, which is
# unavailable, which is how the Dockerfile passes its build arg in. # how the Dockerfile passes its build arg in.
VERSION ?= $(shell script/version) VERSION ?= $(shell script/version)
# An empty override (`make build VERSION=`, or a `--build-arg VERSION=` # An empty override (`make build VERSION=`, or the Dockerfile's `make build
# landing on the Dockerfile's `make build VERSION="$VERSION"`) means unset, # VERSION="$VERSION"` when no VERSION build arg was given) means unset,
# exactly as it does in script/version -- stamping "" would leave the binary # exactly as it does in script/version -- stamping "" would leave the binary
# reporting no version and the footer back on its "dev" fallback. `override` # reporting no version and the footer back on its "dev" fallback. `override`
# is required: a plain assignment loses to the command-line definition it # is required: a plain assignment loses to the command-line definition it
@@ -28,11 +28,14 @@ setup:
@script/setup @script/setup
assets: assets:
@script/fetch-assets @script/assets
test: test:
@script/test @script/test
test-browser:
@script/test-browser
lint: lint:
@script/lint @script/lint
@@ -45,13 +48,13 @@ fmt-check:
check: check:
@script/check @script/check
build: build: assets
go build -ldflags '$(strip -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker go build -ldflags '$(strip -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker
run: build run: build
./bin/webhooker ./bin/webhooker
dev: dev: assets
go run ./cmd/webhooker go run ./cmd/webhooker
deps: deps:
+587 -401
View File
File diff suppressed because it is too large Load Diff
+1 -7
View File
@@ -40,12 +40,6 @@ duplicate. That is deliberate — the alternative is a silent lost
delivery — and the README says so under Rationale. It is not a defect delivery — and the README says so under Rationale. It is not a defect
to re-file. to re-file.
One caveat on reading a green check: a docs-only commit deliberately
replays from the layer cache
(https://git.eeqj.de/sneak/webhooker/issues/119), so a green status on
such a commit evidences a replay rather than an executed run. A code
commit invalidates the `COPY` layer and genuinely executes.
# Next Step # Next Step
Clear the rest of the open 1.0.0 milestone Clear the rest of the open 1.0.0 milestone
@@ -387,7 +381,7 @@ point of the branch.
- 2026-03-05 security headers middleware, session regeneration on - 2026-03-05 security headers middleware, session regeneration on
login, request body size limits (#41) login, request body size limits (#41)
- 2026-03-04 tests for delivery, middleware, and session packages - 2026-03-04 tests for delivery, middleware, and session packages
(#32); removed globals.Buildarch (#31) (#32); removed the build-architecture global (#31)
- 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core - 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core
delivery engine with bounded worker pool and circuit breaker, delivery engine with bounded worker pool and circuit breaker,
parallel fan-out, per-webhook event databases, management UI (#16) parallel fan-out, per-webhook event databases, management UI (#16)
+23 -5
View File
@@ -8,6 +8,7 @@ import (
"time" "time"
"go.uber.org/fx" "go.uber.org/fx"
"go.uber.org/fx/fxevent"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/datadir" "sneak.berlin/go/webhooker/internal/datadir"
@@ -16,6 +17,7 @@ import (
"sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/healthcheck" "sneak.berlin/go/webhooker/internal/healthcheck"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/metrics"
"sneak.berlin/go/webhooker/internal/middleware" "sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/resetpw" "sneak.berlin/go/webhooker/internal/resetpw"
"sneak.berlin/go/webhooker/internal/server" "sneak.berlin/go/webhooker/internal/server"
@@ -167,6 +169,19 @@ func run(stderr io.Writer) int {
func newApp() *fx.App { func newApp() *fx.App {
return fx.New( return fx.New(
fx.StopTimeout(stopTimeout), fx.StopTimeout(stopTimeout),
// fx's own events go through the service's logger, not fx's
// console logger on standard error. The exception is a failure
// before this logger is built, such as an invalid configuration
// value, which fx's console logger still prints there. fx holds
// its events back until this logger is built and then replays
// them, so it takes the configuration, which sets the level
// DEBUG=true asks for: without it the replay would run at INFO
// and drop every record of how the graph was built.
fx.WithLogger(
func(l *logger.Logger, _ *config.Config) fxevent.Logger {
return logger.NewFxLogger(l.Get())
},
),
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -177,6 +192,10 @@ func newApp() *fx.App {
healthcheck.New, healthcheck.New,
session.New, session.New,
handlers.New, handlers.New,
// The registry /metrics serves, and the delivery
// collectors registered on it.
metrics.NewRegistry,
metrics.New,
middleware.New, middleware.New,
// The one SSRF guard both target-creation validation // The one SSRF guard both target-creation validation
// and the delivery dialer consult, so they cannot // and the delivery dialer consult, so they cannot
@@ -187,11 +206,10 @@ func newApp() *fx.App {
// Wire *delivery.Engine as delivery.Notifier so the // Wire *delivery.Engine as delivery.Notifier so the
// webhook handler can notify the engine of new deliveries. // webhook handler can notify the engine of new deliveries.
func(e *delivery.Engine) delivery.Notifier { return e }, func(e *delivery.Engine) delivery.Notifier { return e },
// Wire *delivery.Engine as delivery.WebhookEvictor so // Wire *delivery.Engine as delivery.Archives so deleting
// deleting a webhook releases its archive writer. // or renaming a webhook or target reaches its archive
func(e *delivery.Engine) delivery.WebhookEvictor { // files.
return e func(e *delivery.Engine) delivery.Archives { return e },
},
server.New, server.New,
), ),
fx.Invoke( fx.Invoke(
+102
View File
@@ -2,12 +2,19 @@ package main
import ( import (
"bytes" "bytes"
"encoding/json"
"io"
"log/slog"
"net"
"os"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/datadir" "sneak.berlin/go/webhooker/internal/datadir"
"sneak.berlin/go/webhooker/internal/resetpw" "sneak.berlin/go/webhooker/internal/resetpw"
"sneak.berlin/go/webhooker/internal/server" "sneak.berlin/go/webhooker/internal/server"
@@ -30,6 +37,7 @@ const dockerStopGrace = 10 * time.Second
// fx.New applies options before it executes invokes, so the timeout // fx.New applies options before it executes invokes, so the timeout
// is set whether or not the graph itself can be constructed here. // is set whether or not the graph itself can be constructed here.
func TestNewApp_StopTimeout(t *testing.T) { func TestNewApp_StopTimeout(t *testing.T) {
config.ClearEnvForTest(t)
t.Setenv("DATA_DIR", t.TempDir()) t.Setenv("DATA_DIR", t.TempDir())
got := newApp().StopTimeout() got := newApp().StopTimeout()
@@ -38,6 +46,100 @@ func TestNewApp_StopTimeout(t *testing.T) {
require.Less(t, got, dockerStopGrace) require.Less(t, got, dockerStopGrace)
} }
// freePort returns a loopback TCP port that was free a moment ago, by
// taking one and releasing it.
func freePort(t *testing.T) int {
t.Helper()
var listenCfg net.ListenConfig
l, err := listenCfg.Listen(t.Context(), "tcp", "127.0.0.1:0")
require.NoError(t, err)
addr, ok := l.Addr().(*net.TCPAddr)
require.True(t, ok, "listener is not TCP")
require.NoError(t, l.Close())
return addr.Port
}
// TestNewApp_SendsFxEventsToTheLogger starts and stops the app main
// runs, with DEBUG=true, and reads back what reached the service's
// logger. fx's own events must arrive there as structured records:
// the start at INFO, and at DEBUG the records of how the graph was
// built.
//
// fx holds its events back until its logger is built and then replays
// them all at once, so the earliest of them arriving shows the replay
// ran at DEBUG: that globals.New was provided, which fx records before
// anything is built, and the run of logger.New, which happens before
// the configuration sets the level.
func TestNewApp_SendsFxEventsToTheLogger(t *testing.T) {
config.ClearEnvForTest(t)
t.Setenv("DATA_DIR", t.TempDir())
t.Setenv("PORT", strconv.Itoa(freePort(t)))
t.Setenv("DEBUG", "true")
// internal/logger writes to whatever os.Stdout is when it builds
// its handler. A file is not a terminal, so that handler is the
// JSON one the service uses in production.
out, err := os.CreateTemp(t.TempDir(), "stdout")
require.NoError(t, err)
stdout := os.Stdout
os.Stdout = out
t.Cleanup(func() {
os.Stdout = stdout
_ = out.Close()
})
app := newApp()
require.NoError(t, app.Start(t.Context()))
require.NoError(t, app.Stop(t.Context()))
_, err = out.Seek(0, io.SeekStart)
require.NoError(t, err)
written, err := io.ReadAll(out)
require.NoError(t, err)
type record struct {
Level string `json:"level"`
Msg string `json:"msg"`
Name string `json:"name"`
Constructor string `json:"constructor"`
}
var records []record
for line := range strings.Lines(string(written)) {
var r record
// The first-boot banner is plain text, not a record.
if json.Unmarshal([]byte(line), &r) == nil {
records = append(records, r)
}
}
const pkg = "sneak.berlin/go/webhooker/internal/"
info := slog.LevelInfo.String()
debug := slog.LevelDebug.String()
assert.Contains(t, records, record{Level: info, Msg: "started"})
assert.Contains(t, records, record{
Level: debug, Msg: "provided", Constructor: pkg + "globals.New()",
})
assert.Contains(t, records, record{
Level: debug, Msg: "run", Name: pkg + "logger.New()",
})
assert.Contains(t, records, record{Level: debug, Msg: "invoking"})
assert.Contains(t, records, record{
Level: debug, Msg: "initialized custom fxevent.Logger",
})
}
// TestRunRefusesLockedDataDir pins what an operator's second start // TestRunRefusesLockedDataDir pins what an operator's second start
// does. The entry point must refuse before it builds the fx graph — // does. The entry point must refuse before it builds the fx graph —
// nothing may open a database in a DATA_DIR another process holds — // nothing may open a database in a DATA_DIR another process holds —
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
# deploy/docker-entrypoint.sh: the image's ENTRYPOINT. A bind-mounted
# data directory keeps its owner from the host, often root, and the app
# could not write to it. Started as root, this creates DATA_DIR if
# needed, gives it and everything in it to webhooker, sets its mode, and
# runs the command as webhooker, so the app never runs as root. Started
# as another user, it only runs the command.
set -eu
main() {
if [ "$(id -u)" != 0 ]; then
exec "$@"
fi
dir="${DATA_DIR:-/var/lib/webhooker}"
mkdir -p "$dir"
find "$dir" ! -user webhooker -exec chown -h webhooker:webhooker {} +
chmod 750 "$dir"
exec su-exec webhooker "$@"
}
main "$@"
+13 -8
View File
@@ -4,6 +4,9 @@ go 1.26.1
require ( require (
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f
github.com/chromedp/chromedp v0.16.0
github.com/dustin/go-humanize v1.0.1
github.com/getsentry/sentry-go v0.25.0 github.com/getsentry/sentry-go v0.25.0
github.com/go-chi/chi v1.5.5 github.com/go-chi/chi v1.5.5
github.com/go-chi/cors v1.2.1 github.com/go-chi/cors v1.2.1
@@ -17,7 +20,7 @@ require (
github.com/prometheus/client_model v0.5.0 github.com/prometheus/client_model v0.5.0
github.com/slok/go-http-metrics v0.11.0 github.com/slok/go-http-metrics v0.11.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
go.uber.org/fx v1.20.1 go.uber.org/fx v1.24.0
golang.org/x/crypto v0.38.0 golang.org/x/crypto v0.38.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/sqlite v1.5.4 gorm.io/driver/sqlite v1.5.4
@@ -28,14 +31,17 @@ require (
require ( require (
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.17 // indirect github.com/mattn/go-sqlite3 v1.14.17 // indirect
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
@@ -44,13 +50,12 @@ require (
github.com/prometheus/procfs v0.12.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect
go.uber.org/atomic v1.9.0 // indirect go.uber.org/dig v1.19.0 // indirect
go.uber.org/dig v1.17.0 // indirect go.uber.org/multierr v1.10.0 // indirect
go.uber.org/multierr v1.9.0 // indirect go.uber.org/zap v1.26.0 // indirect
go.uber.org/zap v1.23.0 // indirect
golang.org/x/mod v0.17.0 // indirect golang.org/x/mod v0.17.0 // indirect
golang.org/x/sync v0.14.0 // indirect golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.37.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.25.0 // indirect golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
google.golang.org/protobuf v1.31.0 // indirect google.golang.org/protobuf v1.31.0 // indirect
+30 -22
View File
@@ -1,14 +1,15 @@
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 h1:nMpu1t4amK3vJWBibQ5X/Nv0aXL+b69TQf2uK5PH7Go= github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 h1:nMpu1t4amK3vJWBibQ5X/Nv0aXL+b69TQf2uK5PH7Go=
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8/go.mod h1:3cARGAK9CfW3HoxCy1a0G4TKrdiKke8ftOMEOHyySYs= github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8/go.mod h1:3cARGAK9CfW3HoxCy1a0G4TKrdiKke8ftOMEOHyySYs=
github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f h1:8PK9FM4bE0C8GMoWBW5lVsef3U7sPICjDg6JqngyYhk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f/go.mod h1:3v4FIp5njIUyPDvqXsxEOxnB34lijG0up98/5kM1KaE=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk=
github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -23,6 +24,14 @@ github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 h1:UADEEmDKgfXbtnGJZ97beY5XLo9ZechG1nlU4KnRrkE=
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
@@ -55,17 +64,20 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk= github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
@@ -82,28 +94,24 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/slok/go-http-metrics v0.11.0 h1:ABJUpekCZSkQT1wQrFvS4kGbhea/w6ndFJaWJeh3zL0= github.com/slok/go-http-metrics v0.11.0 h1:ABJUpekCZSkQT1wQrFvS4kGbhea/w6ndFJaWJeh3zL0=
github.com/slok/go-http-metrics v0.11.0/go.mod h1:ZGKeYG1ET6TEJpQx18BqAJAvxw9jBAZXCHU7bWQqqAc= github.com/slok/go-http-metrics v0.11.0/go.mod h1:ZGKeYG1ET6TEJpQx18BqAJAvxw9jBAZXCHU7bWQqqAc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
go.uber.org/dig v1.17.0 h1:5Chju+tUvcC+N7N6EV08BJz41UZuO3BmHcN4A287ZLI= go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg=
go.uber.org/dig v1.17.0/go.mod h1:rTxpf7l5I0eBTlE6/9RL+lDybC7WFwY2QH55ZSjy1mU= go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo=
go.uber.org/fx v1.20.1 h1:zVwVQGS8zYvhh9Xxcu4w1M6ESyeMzebzj2NbSayZ4Mk= go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/fx v1.20.1/go.mod h1:iSYNbHf2y55acNCwCXKx7LbWb5WG1Bnue5RDXz1OREg= go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
go.uber.org/zap v1.23.0 h1:OjGQ5KQDEUawVHxNwQgPpiypGHOxo2mNZsOqTak4fFY=
go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
@@ -111,8 +119,8 @@ golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
+48 -84
View File
@@ -75,8 +75,12 @@ const (
// internet-exposed endpoint. // internet-exposed endpoint.
defaultReceiverRateLimit = 120 defaultReceiverRateLimit = 120
// maxPort is the highest valid TCP port number. The lower // defaultTrustedProxies is TRUSTED_PROXIES when it is unset: the
// bound (at least 1) is enforced by envPositiveInt. // RFC 1918 private ranges, which a reverse proxy reaching the
// process over a Docker network or a private LAN connects from.
defaultTrustedProxies = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
// maxPort is the highest valid TCP port number.
maxPort = 65535 maxPort = 65535
// mappedV4Offset is the number of leading bits an IPv4-mapped // mappedV4Offset is the number of leading bits an IPv4-mapped
@@ -100,7 +104,7 @@ var ErrInvalidEnvironment = errors.New("invalid environment")
var ErrNonPositiveValue = errors.New("value must be positive") var ErrNonPositiveValue = errors.New("value must be positive")
// ErrInvalidPort is returned when an environment variable holding a // ErrInvalidPort is returned when an environment variable holding a
// TCP port number is set above the valid port range. // TCP port number is set to a number outside 1 to 65535.
var ErrInvalidPort = errors.New("invalid port") var ErrInvalidPort = errors.New("invalid port")
// ErrInvalidCIDR is returned when an environment variable holding a // ErrInvalidCIDR is returned when an environment variable holding a
@@ -144,7 +148,6 @@ type ConfigParams struct {
type Config struct { type Config struct {
DataDir string DataDir string
Debug bool Debug bool
MaintenanceMode bool
Environment string Environment string
MetricsPassword string MetricsPassword string
MetricsUsername string MetricsUsername string
@@ -172,13 +175,14 @@ type Config struct {
// TrustedProxies is the set of networks whose members are // TrustedProxies is the set of networks whose members are
// allowed to speak for the client with X-Forwarded-For, the // allowed to speak for the client with X-Forwarded-For, the
// only forwarded header read. It is empty unless // only forwarded header read. Unless TRUSTED_PROXIES is set it
// TRUSTED_PROXIES is set, and empty means no peer is // is the RFC 1918 private ranges (defaultTrustedProxies); a set
// trusted: forwarded headers are then ignored entirely and // value replaces them. If any client can reach the process, or
// clients are identified by the connection's own address. // the proxy in front of it, from an RFC 1918 source address
// Members can choose their own rate-limit key, so this must // (directly, or through anything that can rewrite source
// name proxy hosts only, never a block that also covers // addresses, such as NAT or a published container port), it
// clients. // must be set to the proxy's address alone, or every rate limit
// can be bypassed by those clients.
TrustedProxies []netip.Prefix TrustedProxies []netip.Prefix
// AllowedEgressCIDRs is the set of networks a delivery target // AllowedEgressCIDRs is the set of networks a delivery target
@@ -190,12 +194,13 @@ type Config struct {
// otherwise refuse. The guard itself is always on: there is no // otherwise refuse. The guard itself is always on: there is no
// setting that disables SSRF protection, and delivery's // setting that disables SSRF protection, and delivery's
// alwaysBlockedNetworks stays blocked no matter what is listed // alwaysBlockedNetworks stays blocked no matter what is listed
// here. That set is link-local plus the cloud metadata // here. That set is link-local, the unspecified addresses
// endpoints outside it that disclose credentials or user data // 0.0.0.0 and ::, and the cloud metadata endpoints outside
// at a provider-fixed, non-public address; it is not // link-local that disclose credentials or user data at a
// exhaustive of every cloud's metadata address. See // provider-fixed, non-public address; it is not exhaustive of
// alwaysBlockedNetworks for the authoritative list and the // every cloud's metadata address. See
// criterion it is built from. // alwaysBlockedNetworks for the authoritative list and why
// each entry is on it.
AllowedEgressCIDRs []netip.Prefix AllowedEgressCIDRs []netip.Prefix
params *ConfigParams params *ConfigParams
@@ -357,17 +362,27 @@ func envPositiveInt(
// envPort returns the value of the named environment variable parsed // envPort returns the value of the named environment variable parsed
// as a TCP port number. Returns defaultValue if not set. A set value // as a TCP port number. Returns defaultValue if not set. A set value
// that is unparseable, below 1, or above maxPort is a hard error // that is unparseable, below 1, or above maxPort is a hard error
// naming the key and the bad value. // naming the key and the bad value; every out-of-range value wraps
// ErrInvalidPort, including one too large or too small for an int.
func envPort(key string, defaultValue int) (int, error) { func envPort(key string, defaultValue int) (int, error) {
port, err := envPositiveInt(key, defaultValue) v := os.Getenv(key)
if err != nil { if v == "" {
return 0, err return defaultValue, nil
} }
if port > maxPort { // strconv.ErrRange means a number too large or too small for an
// int, which is outside the port range as well.
port, err := strconv.Atoi(v)
if err != nil && !errors.Is(err, strconv.ErrRange) {
return 0, fmt.Errorf( return 0, fmt.Errorf(
"%w: %s must be at most %d, got %d", "invalid integer for %s: %q: %w", key, v, err,
ErrInvalidPort, key, maxPort, port, )
}
if err != nil || port < 1 || port > maxPort {
return 0, fmt.Errorf(
"%w: %s must be from 1 to %d, got %q",
ErrInvalidPort, key, maxPort, v,
) )
} }
@@ -460,14 +475,15 @@ func parseCIDR(entry string) (netip.Prefix, error) {
// envPrefixList returns the value of the named environment variable // envPrefixList returns the value of the named environment variable
// parsed as a comma-separated list of CIDR blocks (bare addresses // parsed as a comma-separated list of CIDR blocks (bare addresses
// allowed). An unset, empty, or blank value yields an empty list. A // allowed). An unset, empty, or blank value is read as defaultValue
// set value containing an unparseable entry is a hard error naming // instead. A set value containing an unparseable entry is a hard
// the key and the bad entry, so startup fails loudly rather than // error naming the key and the bad entry, so startup fails loudly
// silently running with a list the operator did not intend. // rather than silently running with a list the operator did not
func envPrefixList(key string) ([]netip.Prefix, error) { // intend.
func envPrefixList(key, defaultValue string) ([]netip.Prefix, error) {
v := strings.TrimSpace(os.Getenv(key)) v := strings.TrimSpace(os.Getenv(key))
if v == "" { if v == "" {
return nil, nil v = defaultValue
} }
var prefixes []netip.Prefix var prefixes []netip.Prefix
@@ -650,11 +666,6 @@ func loadFromEnv() (*Config, error) {
return nil, err return nil, err
} }
maintenanceMode, err := envBool("MAINTENANCE_MODE", false)
if err != nil {
return nil, err
}
retentionSweepInterval, err := envPositiveDuration( retentionSweepInterval, err := envPositiveDuration(
"RETENTION_SWEEP_INTERVAL", "RETENTION_SWEEP_INTERVAL",
defaultRetentionSweepInterval, defaultRetentionSweepInterval,
@@ -681,12 +692,12 @@ func loadFromEnv() (*Config, error) {
return nil, err return nil, err
} }
trustedProxies, err := envPrefixList("TRUSTED_PROXIES") trustedProxies, err := envPrefixList("TRUSTED_PROXIES", defaultTrustedProxies)
if err != nil { if err != nil {
return nil, err return nil, err
} }
allowedEgressCIDRs, err := envPrefixList("ALLOWED_EGRESS_CIDRS") allowedEgressCIDRs, err := envPrefixList("ALLOWED_EGRESS_CIDRS", "")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -704,7 +715,6 @@ func loadFromEnv() (*Config, error) {
return &Config{ return &Config{
DataDir: DataDir(), DataDir: DataDir(),
Debug: debug, Debug: debug,
MaintenanceMode: maintenanceMode,
Environment: environment, Environment: environment,
MetricsUsername: metricsUsername, MetricsUsername: metricsUsername,
MetricsPassword: metricsPassword, MetricsPassword: metricsPassword,
@@ -760,50 +770,6 @@ func (c *Config) warnEgressAllowlist(log *slog.Logger) {
) )
} }
// warnSharedRateLimitBucket logs a startup warning whenever
// TRUSTED_PROXIES is empty, in any environment.
//
// With no trusted proxies every rate limiter keys on the connecting
// peer's address. Whether that is harmless or dangerous depends on
// what is in front of the process, which this code cannot observe:
// with nothing in front, the peer is the client and the limits are
// per-client as intended; behind a reverse proxy the peer is the proxy
// for every request, so all clients share one bucket per limiter.
//
// The login endpoint no longer spends budget on arrival — it verifies
// credentials first and charges only failures — so a shared bucket
// cannot deny the operator a correct password. What it does collapse
// is the failure counting: one client's wrong passwords throttle
// everyone else's wrong passwords, and the receiver's limits become
// service-wide ceilings.
//
// The warning is deliberately not gated on WEBHOOKER_ENVIRONMENT:
// behind a proxy every client shares one bucket in dev and prod alike.
//
// The default of trusting nobody is deliberate — trusting forwarded
// headers from arbitrary peers lets any client choose its own bucket —
// so this warns rather than failing startup or changing the key.
func (c *Config) warnSharedRateLimitBucket(log *slog.Logger) {
if len(c.TrustedProxies) > 0 {
return
}
log.Warn(
"TRUSTED_PROXIES is empty: every rate limit keys on the "+
"connecting peer's address. With nothing proxying to "+
"this process that is the client itself and the limits "+
"are per-client as intended. Behind a reverse proxy the "+
"peer is the proxy on every request, so all clients "+
"share one bucket per limit: the receiver limits become "+
"service-wide ceilings, and one client's failed logins "+
"throttle every other client's failed logins — a "+
"correct password still gets in. If anything proxies to "+
"this process, set TRUSTED_PROXIES to its address.",
"environment", c.Environment,
"trustedProxies", len(c.TrustedProxies),
)
}
// New creates a Config by reading environment variables. // New creates a Config by reading environment variables.
// //
//nolint:revive // lc parameter is required by fx even if unused. //nolint:revive // lc parameter is required by fx even if unused.
@@ -835,7 +801,6 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
// host can reach the admin UI. // host can reach the admin UI.
"bindAddress", s.BindAddress, "bindAddress", s.BindAddress,
"debug", s.Debug, "debug", s.Debug,
"maintenanceMode", s.MaintenanceMode,
"dataDir", s.DataDir, "dataDir", s.DataDir,
"retentionSweepInterval", s.RetentionSweepInterval.String(), "retentionSweepInterval", s.RetentionSweepInterval.String(),
// Logged because a perfectly valid non-positive value here // Logged because a perfectly valid non-positive value here
@@ -849,7 +814,6 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
"hasMetricsAuth", s.MetricsAuthEnabled(), "hasMetricsAuth", s.MetricsAuthEnabled(),
) )
s.warnSharedRateLimitBucket(log)
s.warnEgressAllowlist(log) s.warnEgressAllowlist(log)
return s, nil return s, nil
+42 -146
View File
@@ -3,7 +3,6 @@ package config_test
import ( import (
"bytes" "bytes"
"log/slog" "log/slog"
"os"
"testing" "testing"
"time" "time"
@@ -71,14 +70,12 @@ func TestEnvironmentConfig(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
if tt.envValue != "" { if tt.envValue != "" {
t.Setenv( t.Setenv(
"WEBHOOKER_ENVIRONMENT", tt.envValue, "WEBHOOKER_ENVIRONMENT", tt.envValue,
) )
} else {
require.NoError(t, os.Unsetenv(
"WEBHOOKER_ENVIRONMENT",
))
} }
for k, v := range tt.envVars { for k, v := range tt.envVars {
@@ -124,6 +121,11 @@ func testEnvironmentConfigSuccess(
app := fxtest.New( app := fxtest.New(
t, t,
// fx's own log is discarded, not sent to t.Logf: a hook still
// running after a start or stop timeout would write there after
// the test has returned. The same holds for every fxtest.New
// below.
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -194,14 +196,11 @@ func TestRetentionSweepInterval(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set { if tt.set {
t.Setenv("RETENTION_SWEEP_INTERVAL", tt.value) t.Setenv("RETENTION_SWEEP_INTERVAL", tt.value)
} else {
require.NoError(t, os.Unsetenv(
"RETENTION_SWEEP_INTERVAL",
))
} }
if tt.expectError { if tt.expectError {
@@ -272,6 +271,7 @@ func testRetentionSweepIntervalSuccess(
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -335,14 +335,11 @@ func TestSessionIdleTimeout(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set { if tt.set {
t.Setenv("SESSION_IDLE_TIMEOUT", tt.value) t.Setenv("SESSION_IDLE_TIMEOUT", tt.value)
} else {
require.NoError(t, os.Unsetenv(
"SESSION_IDLE_TIMEOUT",
))
} }
if tt.expectError { if tt.expectError {
@@ -364,6 +361,7 @@ func testSessionIdleTimeoutSuccess(
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -390,20 +388,17 @@ func TestDefaultDataDir(t *testing.T) {
t.Run("env="+name, func(t *testing.T) { t.Run("env="+name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
if env != "" { if env != "" {
t.Setenv("WEBHOOKER_ENVIRONMENT", env) t.Setenv("WEBHOOKER_ENVIRONMENT", env)
} else {
require.NoError(t, os.Unsetenv(
"WEBHOOKER_ENVIRONMENT",
))
} }
require.NoError(t, os.Unsetenv("DATA_DIR"))
var cfg *config.Config var cfg *config.Config
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -438,9 +433,9 @@ func TestDataDirHelper(t *testing.T) {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
if set == "" { config.ClearEnvForTest(t)
require.NoError(t, os.Unsetenv("DATA_DIR"))
} else { if set != "" {
t.Setenv("DATA_DIR", set) t.Setenv("DATA_DIR", set)
} }
@@ -503,14 +498,11 @@ func TestReceiverRateLimit(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set { if tt.set {
t.Setenv("RECEIVER_RATE_LIMIT", tt.value) t.Setenv("RECEIVER_RATE_LIMIT", tt.value)
} else {
require.NoError(t, os.Unsetenv(
"RECEIVER_RATE_LIMIT",
))
} }
if tt.expectError { if tt.expectError {
@@ -534,6 +526,7 @@ func testReceiverRateLimitSuccess(
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -551,6 +544,11 @@ func testReceiverRateLimitSuccess(
} }
func TestTrustedProxies(t *testing.T) { func TestTrustedProxies(t *testing.T) {
// Unset, the RFC 1918 private ranges are trusted, so a reverse
// proxy on a Docker network or a private LAN is covered without
// configuration.
defaultProxies := []string{cidrPrivateV4, "172.16.0.0/12", "192.168.0.0/16"}
tests := []struct { tests := []struct {
name string name string
set bool set bool
@@ -559,18 +557,21 @@ func TestTrustedProxies(t *testing.T) {
expected []string expected []string
}{ }{
{ {
// The default must be "trust nobody": an empty list
// means forwarded headers are ignored, never that
// every peer may speak for the client.
name: caseUnsetUsesDefault, name: caseUnsetUsesDefault,
set: false, set: false,
expected: []string{}, expected: defaultProxies,
}, },
{ {
name: "blank value trusts nothing", name: "blank value uses default",
set: true, set: true,
value: " ", value: " ",
expected: []string{}, expected: defaultProxies,
},
{
name: "set value replaces the default entirely",
set: true,
value: "203.0.113.7",
expected: []string{"203.0.113.7/32"},
}, },
{ {
name: caseValidValueParsed, name: caseValidValueParsed,
@@ -613,12 +614,11 @@ func TestTrustedProxies(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set { if tt.set {
t.Setenv("TRUSTED_PROXIES", tt.value) t.Setenv("TRUSTED_PROXIES", tt.value)
} else {
require.NoError(t, os.Unsetenv("TRUSTED_PROXIES"))
} }
if tt.expectError { if tt.expectError {
@@ -642,6 +642,7 @@ func testTrustedProxiesSuccess(
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -724,14 +725,11 @@ func TestAllowedEgressCIDRs(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set { if tt.set {
t.Setenv("ALLOWED_EGRESS_CIDRS", tt.value) t.Setenv("ALLOWED_EGRESS_CIDRS", tt.value)
} else {
require.NoError(
t, os.Unsetenv("ALLOWED_EGRESS_CIDRS"),
)
} }
if tt.expectError { if tt.expectError {
@@ -755,6 +753,7 @@ func testAllowedEgressCIDRsSuccess(
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -798,13 +797,10 @@ func TestEgressAllowlistWarning(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", config.EnvironmentDev) t.Setenv("WEBHOOKER_ENVIRONMENT", config.EnvironmentDev)
if tt.allowed == "" { if tt.allowed != "" {
require.NoError(
t, os.Unsetenv("ALLOWED_EGRESS_CIDRS"),
)
} else {
t.Setenv("ALLOWED_EGRESS_CIDRS", tt.allowed) t.Setenv("ALLOWED_EGRESS_CIDRS", tt.allowed)
} }
@@ -845,101 +841,6 @@ func TestEgressAllowlistWarning(t *testing.T) {
} }
} }
// TestSharedRateLimitBucketWarning covers the startup warning that
// tells an operator a deployment behind a reverse proxy shares one
// rate-limit bucket between every client, which turns the receiver
// limits into service-wide ceilings and collapses login failure
// counting. It must fire whenever TRUSTED_PROXIES is empty, in any
// environment, because behind a proxy every client shares one bucket
// in dev and prod alike. It stays quiet once proxies are named.
func TestSharedRateLimitBucketWarning(t *testing.T) {
tests := []struct {
name string
environment string
trustedProxies string
expectWarning bool
}{
{
name: "prod without trusted proxies warns",
environment: config.EnvironmentProd,
expectWarning: true,
},
{
name: "prod with trusted proxies is quiet",
environment: config.EnvironmentProd,
trustedProxies: cidrPrivateV4,
expectWarning: false,
},
{
name: "dev without trusted proxies warns",
environment: config.EnvironmentDev,
expectWarning: true,
},
{
name: "dev with trusted proxies is quiet",
environment: config.EnvironmentDev,
trustedProxies: cidrPrivateV4,
expectWarning: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
t.Setenv("WEBHOOKER_ENVIRONMENT", tt.environment)
if tt.trustedProxies == "" {
require.NoError(
t, os.Unsetenv("TRUSTED_PROXIES"),
)
} else {
t.Setenv("TRUSTED_PROXIES", tt.trustedProxies)
}
var buf bytes.Buffer
log := slog.New(slog.NewJSONHandler(
&buf, &slog.HandlerOptions{
Level: slog.LevelDebug,
},
))
require.NoError(
t,
config.WarnSharedRateLimitBucketForTest(log),
)
if !tt.expectWarning {
assert.Empty(t, buf.String())
return
}
logged := buf.String()
assert.Contains(t, logged, `"level":"WARN"`)
assert.Contains(t, logged, "TRUSTED_PROXIES")
assert.Contains(t, logged, "share one bucket")
assert.Contains(
t, logged, "throttle every other client's failed logins",
)
// The warning must not claim a lockout the login
// endpoint no longer permits: credentials are verified
// before any budget is spent.
assert.Contains(
t, logged, "a correct password still gets in",
)
// The text must stay accurate for a developer with
// nothing in front of the process, where an empty
// list costs nothing.
assert.Contains(
t, logged, "nothing proxying to this process",
)
})
}
}
// metricsEnv describes what one subtest below puts in the // metricsEnv describes what one subtest below puts in the
// environment for a single METRICS_ variable. A variable that is // environment for a single METRICS_ variable. A variable that is
// set to the empty string and one that is not set at all are // set to the empty string and one that is not set at all are
@@ -1032,20 +933,14 @@ func TestMetricsAuthConfig(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
if tt.username.set { if tt.username.set {
t.Setenv("METRICS_USERNAME", tt.username.value) t.Setenv("METRICS_USERNAME", tt.username.value)
} else {
require.NoError(
t, os.Unsetenv("METRICS_USERNAME"),
)
} }
if tt.password.set { if tt.password.set {
t.Setenv("METRICS_PASSWORD", tt.password.value) t.Setenv("METRICS_PASSWORD", tt.password.value)
} else {
require.NoError(
t, os.Unsetenv("METRICS_PASSWORD"),
)
} }
if tt.expectError { if tt.expectError {
@@ -1093,6 +988,7 @@ func assertMetricsAuthAccepted(t *testing.T, expectAuth bool) {
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide(globals.New, logger.New, config.New), fx.Provide(globals.New, logger.New, config.New),
fx.Populate(&cfg), fx.Populate(&cfg),
) )
+7 -18
View File
@@ -22,17 +22,6 @@ const malformedDotEnv = "PORT 19615\n" +
"this is not = valid ! syntax\n" + "this is not = valid ! syntax\n" +
"\"unclosed\n" "\"unclosed\n"
// unsetDotEnvKey makes dotEnvKey genuinely absent for the duration of
// the test and restores it afterwards. t.Setenv registers the restore;
// the Unsetenv that follows is what the test actually needs, because a
// variable set to the empty string is still present in os.Environ and
// godotenv would refuse to overwrite it.
func unsetDotEnvKey(t *testing.T) {
t.Helper()
t.Setenv(dotEnvKey, "placeholder")
require.NoError(t, os.Unsetenv(dotEnvKey))
}
// writeDotEnv writes contents to a .env file in a fresh temporary // writeDotEnv writes contents to a .env file in a fresh temporary
// directory and returns its path. // directory and returns its path.
func writeDotEnv(t *testing.T, contents string) string { func writeDotEnv(t *testing.T, contents string) string {
@@ -50,9 +39,9 @@ func writeDotEnv(t *testing.T, contents string) string {
// normally rather than be refused for a file it was never meant to // normally rather than be refused for a file it was never meant to
// have. // have.
// //
//nolint:paralleltest // unsetDotEnvKey uses t.Setenv. //nolint:paralleltest // ClearEnvForTest uses t.Setenv.
func TestLoadDotEnv_MissingFileIsFine(t *testing.T) { func TestLoadDotEnv_MissingFileIsFine(t *testing.T) {
unsetDotEnvKey(t) config.ClearEnvForTest(t)
absent := filepath.Join(t.TempDir(), config.DotEnvPath) absent := filepath.Join(t.TempDir(), config.DotEnvPath)
require.NoError(t, config.LoadDotEnvFileForTest(absent)) require.NoError(t, config.LoadDotEnvFileForTest(absent))
@@ -65,9 +54,9 @@ func TestLoadDotEnv_MissingFileIsFine(t *testing.T) {
// reaches the environment, which is the whole reason the file is read // reaches the environment, which is the whole reason the file is read
// at all. // at all.
// //
//nolint:paralleltest // unsetDotEnvKey uses t.Setenv. //nolint:paralleltest // ClearEnvForTest uses t.Setenv.
func TestLoadDotEnv_AppliesValues(t *testing.T) { func TestLoadDotEnv_AppliesValues(t *testing.T) {
unsetDotEnvKey(t) config.ClearEnvForTest(t)
path := writeDotEnv(t, "# a comment\n"+dotEnvKey+"=from-dot-env\n") path := writeDotEnv(t, "# a comment\n"+dotEnvKey+"=from-dot-env\n")
@@ -93,9 +82,9 @@ func TestLoadDotEnv_RealEnvironmentWins(t *testing.T) {
// reverts to its default; the process used to start that way with no // reverts to its default; the process used to start that way with no
// log line naming the file at all. // log line naming the file at all.
// //
//nolint:paralleltest // unsetDotEnvKey uses t.Setenv. //nolint:paralleltest // ClearEnvForTest uses t.Setenv.
func TestLoadDotEnv_MalformedFileAborts(t *testing.T) { func TestLoadDotEnv_MalformedFileAborts(t *testing.T) {
unsetDotEnvKey(t) config.ClearEnvForTest(t)
path := writeDotEnv( path := writeDotEnv(
t, malformedDotEnv+dotEnvKey+"=from-dot-env\n", t, malformedDotEnv+dotEnvKey+"=from-dot-env\n",
@@ -143,7 +132,7 @@ func TestLoadDotEnv_UnreadableFileAborts(t *testing.T) {
// //
//nolint:paralleltest // t.Chdir moves the whole process. //nolint:paralleltest // t.Chdir moves the whole process.
func TestLoadDotEnv_ReadsTheWorkingDirectory(t *testing.T) { func TestLoadDotEnv_ReadsTheWorkingDirectory(t *testing.T) {
unsetDotEnvKey(t) config.ClearEnvForTest(t)
dir := t.TempDir() dir := t.TempDir()
require.NoError(t, os.WriteFile( require.NoError(t, os.WriteFile(
+73 -95
View File
@@ -1,7 +1,6 @@
package config_test package config_test
import ( import (
"os"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -20,7 +19,6 @@ const testEnvKey = "WEBHOOKER_TEST_VALUE"
const ( const (
envKeyPort = "PORT" envKeyPort = "PORT"
envKeyDebug = "DEBUG" envKeyDebug = "DEBUG"
envKeyMaintenanceMode = "MAINTENANCE_MODE"
envKeyBindAddress = "BIND_ADDRESS" envKeyBindAddress = "BIND_ADDRESS"
) )
@@ -122,10 +120,10 @@ func TestEnvBool(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
if tt.set { if tt.set {
t.Setenv(testEnvKey, tt.value) t.Setenv(testEnvKey, tt.value)
} else {
require.NoError(t, os.Unsetenv(testEnvKey))
} }
got, err := config.EnvBoolForTest( got, err := config.EnvBoolForTest(
@@ -146,17 +144,62 @@ func TestEnvBool(t *testing.T) {
} }
} }
func TestEnvPositiveInt(t *testing.T) { // envIntCase is one row of the envPositiveInt and envPort tables.
const defaultValue = 7 type envIntCase struct {
tests := []struct {
name string name string
set bool set bool
value string value string
expectError bool expectError bool
errIs error errIs error
expected int expected int
}{ }
// runEnvIntCases runs each row through parse, which is
// envPositiveInt or envPort, with testEnvKey set to the row's value
// or left unset.
func runEnvIntCases(
t *testing.T,
parse func(key string, defaultValue int) (int, error),
defaultValue int,
tests []envIntCase,
) {
t.Helper()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
config.ClearEnvForTest(t)
if tt.set {
t.Setenv(testEnvKey, tt.value)
}
got, err := parse(testEnvKey, defaultValue)
if tt.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), testEnvKey)
assert.Contains(t, err.Error(), tt.value)
if tt.errIs != nil {
require.ErrorIs(t, err, tt.errIs)
}
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, got)
})
}
}
//nolint:paralleltest // runEnvIntCases uses t.Setenv.
func TestEnvPositiveInt(t *testing.T) {
const defaultValue = 7
runEnvIntCases(t, config.EnvPositiveIntForTest, defaultValue, []envIntCase{
{ {
name: "unset returns the default integer", name: "unset returns the default integer",
expected: defaultValue, expected: defaultValue,
@@ -193,51 +236,14 @@ func TestEnvPositiveInt(t *testing.T) {
expectError: true, expectError: true,
errIs: config.ErrNonPositiveValue, errIs: config.ErrNonPositiveValue,
}, },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
if tt.set {
t.Setenv(testEnvKey, tt.value)
} else {
require.NoError(t, os.Unsetenv(testEnvKey))
}
got, err := config.EnvPositiveIntForTest(
testEnvKey, defaultValue,
)
if tt.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), testEnvKey)
assert.Contains(t, err.Error(), tt.value)
if tt.errIs != nil {
require.ErrorIs(t, err, tt.errIs)
}
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, got)
}) })
}
} }
//nolint:paralleltest // runEnvIntCases uses t.Setenv.
func TestEnvPort(t *testing.T) { func TestEnvPort(t *testing.T) {
const defaultValue = 8080 const defaultValue = 8080
tests := []struct { runEnvIntCases(t, config.EnvPortForTest, defaultValue, []envIntCase{
name string
set bool
value string
expectError bool
errIs error
expected int
}{
{ {
name: "unset returns the default port", name: "unset returns the default port",
expected: defaultValue, expected: defaultValue,
@@ -265,7 +271,14 @@ func TestEnvPort(t *testing.T) {
set: true, set: true,
value: "0", value: "0",
expectError: true, expectError: true,
errIs: config.ErrNonPositiveValue, errIs: config.ErrInvalidPort,
},
{
name: "negative is rejected",
set: true,
value: "-1",
expectError: true,
errIs: config.ErrInvalidPort,
}, },
{ {
name: "above the port range is rejected", name: "above the port range is rejected",
@@ -274,37 +287,14 @@ func TestEnvPort(t *testing.T) {
expectError: true, expectError: true,
errIs: config.ErrInvalidPort, errIs: config.ErrInvalidPort,
}, },
} {
name: "too large for an int is rejected",
for _, tt := range tests { set: true,
t.Run(tt.name, func(t *testing.T) { value: "99999999999999999999",
// Cannot use t.Parallel() here because t.Setenv expectError: true,
// is incompatible with parallel subtests. errIs: config.ErrInvalidPort,
if tt.set { },
t.Setenv(testEnvKey, tt.value)
} else {
require.NoError(t, os.Unsetenv(testEnvKey))
}
got, err := config.EnvPortForTest(
testEnvKey, defaultValue,
)
if tt.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), testEnvKey)
if tt.errIs != nil {
require.ErrorIs(t, err, tt.errIs)
}
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, got)
}) })
}
} }
// TestEnvBindAddress covers BIND_ADDRESS parsing. // TestEnvBindAddress covers BIND_ADDRESS parsing.
@@ -320,10 +310,10 @@ func TestEnvBindAddress(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
if tt.set { if tt.set {
t.Setenv(testEnvKey, tt.value) t.Setenv(testEnvKey, tt.value)
} else {
require.NoError(t, os.Unsetenv(testEnvKey))
} }
got, err := config.EnvBindAddressForTest( got, err := config.EnvBindAddressForTest(
@@ -486,6 +476,7 @@ func TestNewRejectsBadEnvValues(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
t.Setenv(tt.key, tt.value) t.Setenv(tt.key, tt.value)
@@ -604,12 +595,6 @@ func flagEnvValueCases() []badEnvValueCase {
value: "ture", value: "ture",
expectError: true, expectError: true,
}, },
{
name: "unparseable MAINTENANCE_MODE aborts startup",
key: envKeyMaintenanceMode,
value: "sometimes",
expectError: true,
},
} }
} }
@@ -653,22 +638,15 @@ func sentryEnvValueCases() []badEnvValueCase {
// break the legitimate unset case: absent variables still get their // break the legitimate unset case: absent variables still get their
// documented defaults. // documented defaults.
func TestNewUsesDefaultsWhenUnset(t *testing.T) { func TestNewUsesDefaultsWhenUnset(t *testing.T) {
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
for _, key := range []string{
envKeyPort, envKeyDebug, envKeyMaintenanceMode,
envKeyBindAddress, envKeySentryDSN,
} {
require.NoError(t, os.Unsetenv(key))
}
cfg, err := buildConfig(t) cfg, err := buildConfig(t)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, cfg) require.NotNil(t, cfg)
assert.Equal(t, 8080, cfg.Port) assert.Equal(t, 8080, cfg.Port)
assert.False(t, cfg.Debug) assert.False(t, cfg.Debug)
assert.False(t, cfg.MaintenanceMode)
// Loopback, not the wildcard: the default must not publish the // Loopback, not the wildcard: the default must not publish the
// cleartext admin UI and the unauthenticated receiver on every // cleartext admin UI and the unauthenticated receiver on every
-15
View File
@@ -6,21 +6,6 @@ import "log/slog"
// the external config_test package so each helper can be covered by // the external config_test package so each helper can be covered by
// its own table-driven test without weakening the package API. // its own table-driven test without weakening the package API.
// WarnSharedRateLimitBucketForTest loads a Config from the current
// environment and emits its startup warnings to log. The real logger
// writes to stdout, so this lets the warning's firing condition be
// asserted against a handler the test controls.
func WarnSharedRateLimitBucketForTest(log *slog.Logger) error {
c, err := loadFromEnv()
if err != nil {
return err
}
c.warnSharedRateLimitBucket(log)
return nil
}
// WarnEgressAllowlistForTest loads a Config from the current // WarnEgressAllowlistForTest loads a Config from the current
// environment and emits its egress-allowlist startup warning to // environment and emits its egress-allowlist startup warning to
// log, so a test can assert both that the warning fires only when // log, so a test can assert both that the warning fires only when
+2 -3
View File
@@ -1,7 +1,6 @@
package config_test package config_test
import ( import (
"os"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -101,10 +100,10 @@ func TestEnvSentryDSN(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
if tt.set { if tt.set {
t.Setenv(envKeySentryDSN, tt.value) t.Setenv(envKeySentryDSN, tt.value)
} else {
require.NoError(t, os.Unsetenv(envKeySentryDSN))
} }
got, err := config.EnvSentryDSNForTest(envKeySentryDSN) got, err := config.EnvSentryDSNForTest(envKeySentryDSN)
+50
View File
@@ -0,0 +1,50 @@
package config
import (
"os"
"strings"
"testing"
)
// ClearEnvForTest unsets every variable in the process environment
// for the rest of the test, so a test sees only the variables it sets
// itself, not whatever the developer's shell exports. When the test
// ends it leaves the environment exactly as it found it: each variable
// it unset is put back, and any variable added since is removed.
func ClearEnvForTest(t *testing.T) {
t.Helper()
present := make(map[string]bool)
for _, entry := range os.Environ() {
key, _, _ := strings.Cut(entry, "=")
present[key] = true
// t.Setenv registers the restore; the Unsetenv after it is
// what makes the key absent, since a key set to the empty
// string is still present, and godotenv will not overwrite a
// present key.
t.Setenv(key, "")
err := os.Unsetenv(key)
if err != nil {
t.Fatalf("unsetting %s: %v", key, err)
}
}
// A variable the test adds other than through t.Setenv, as loading
// a .env file does, has no restore of its own.
t.Cleanup(func() {
for _, entry := range os.Environ() {
key, _, _ := strings.Cut(entry, "=")
if present[key] {
continue
}
err := os.Unsetenv(key)
if err != nil {
t.Errorf("unsetting %s: %v", key, err)
}
}
})
}
+36
View File
@@ -0,0 +1,36 @@
package config_test
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
)
// TestClearEnvForTest_RemovesAddedVariables pins that a variable set
// after the clear other than through t.Setenv, as a test's .env file
// sets one, is gone once the test ends, so it cannot reach the tests
// that run after it.
//
//nolint:paralleltest // ClearEnvForTest uses t.Setenv.
func TestClearEnvForTest_RemovesAddedVariables(t *testing.T) {
// The outer clear keeps a value of the key exported in the shell
// from making it a variable the inner clear has to put back.
config.ClearEnvForTest(t)
t.Run("loads a .env file after the clear", func(t *testing.T) {
config.ClearEnvForTest(t)
path := writeDotEnv(t, dotEnvKey+"=from-dot-env\n")
require.NoError(t, config.LoadDotEnvFileForTest(path))
require.Equal(t, "from-dot-env", os.Getenv(dotEnvKey))
})
_, present := os.LookupEnv(dotEnvKey)
assert.False(
t, present,
"a variable set after the clear must not outlive the test",
)
}
@@ -3,6 +3,8 @@ package database_test
import ( import (
"bytes" "bytes"
"context" "context"
"log/slog"
"path/filepath"
"strings" "strings"
"testing" "testing"
@@ -83,3 +85,37 @@ func TestFirstBoot_PrintsTheAdminPasswordAsABanner(t *testing.T) {
t, ok, "the printed password must open the seeded account", t, ok, "the printed password must open the seeded account",
) )
} }
// TestNewDatabase_IsLoggedWithItsPath is the log half of
// https://git.eeqj.de/sneak/webhooker/issues/359. A DATA_DIR that is
// unexpectedly empty boots exactly like a first start, so the start
// that creates the database must say so, and where. Opening that
// database again must not.
func TestNewDatabase_IsLoggedWithItsPath(t *testing.T) {
t.Parallel()
dir := t.TempDir()
open := func() string {
var out bytes.Buffer
db, err := database.Open(dir, slog.New(slog.NewTextHandler(&out, nil)))
require.NoError(t, err)
require.NoError(t, db.Close())
return out.String()
}
const created = `level=WARN msg="created a new, empty database"`
first := open()
second := open()
assert.Contains(
t, first,
created+" path="+filepath.Join(dir, database.MainDBFileName),
)
assert.NotContains(
t, second, created, "an existing database is not new",
)
}
+12
View File
@@ -8,6 +8,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"io/fs"
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
@@ -199,6 +200,12 @@ func (d *Database) connectTo(dataDir string) error {
// Construct the main application database path inside DATA_DIR. // Construct the main application database path inside DATA_DIR.
dbPath := filepath.Join(dataDir, MainDBFileName) dbPath := filepath.Join(dataDir, MainDBFileName)
// Checked before opening, which creates the file. A DATA_DIR that
// is unexpectedly empty -- its volume not mounted, say -- looks
// exactly like a first start, so a new database is a warning.
_, statErr := os.Stat(dbPath)
created := errors.Is(statErr, fs.ErrNotExist)
// Opened through OpenSQLite so this handle carries the same WAL // Opened through OpenSQLite so this handle carries the same WAL
// journaling, busy timeout, immediate-transaction locking, and pool // journaling, busy timeout, immediate-transaction locking, and pool
// bounds as every other database file. See sqlite_open.go. // bounds as every other database file. See sqlite_open.go.
@@ -229,7 +236,12 @@ func (d *Database) connectTo(dataDir string) error {
} }
d.db = db d.db = db
if created {
d.log.Warn("created a new, empty database", "path", dbPath)
} else {
d.log.Info("connected to database", "path", dbPath) d.log.Info("connected to database", "path", dbPath)
}
// Run migrations // Run migrations
return d.migrate() return d.migrate()
+72 -17
View File
@@ -93,11 +93,11 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
deliveries []database.Delivery deliveries []database.Delivery
results []database.DeliveryResult results []database.DeliveryResult
depths []struct{ Depth int } depths []struct{ Depth int }
removed []database.TargetTotals
) )
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=?)"
byAge := "idx_events_deleted_at_created_at (deleted_at=? AND created_at<?)"
// The delivery engine: recovery and the retry sweep, the sweep for // The delivery engine: recovery and the retry sweep, the sweep for
// stranded pending deliveries, and the queue depth count. // stranded pending deliveries, and the queue depth count.
@@ -123,25 +123,80 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
Order("attempt_num ASC").Find(&results), Order("attempt_num ASC").Find(&results),
"idx_delivery_results_delivery_id (delivery_id=? AND deleted_at=?)") "idx_delivery_results_delivery_id (delivery_id=? AND deleted_at=?)")
// Retention's three deletes (reapExpired), whose subqueries are built // Retention (reapExpired, deleteEvents): one batch of expired
// afresh for each statement as it builds them. // events, then their attempts, deliveries and the events.
expiredEventIDs := func() *gorm.DB { var expired []string
return dry.Model(&database.Event{}).Select("id").
Where("created_at < ?", cutoff)
}
assertPlanUses(t, db, dry.Unscoped().Model(&database.Event{}).
Where("created_at < ?", cutoff).
Limit(database.ExportReapBatchSize).Pluck("id", &expired),
"idx_events_created_at (created_at<?)")
assertPlanUses(t, db, dry.Unscoped().Where( assertPlanUses(t, db, dry.Unscoped().Where(
"delivery_id IN (?)", dry.Model(&database.Delivery{}). "delivery_id IN (?)", dry.Unscoped().Model(&database.Delivery{}).
Select("id").Where("event_id IN (?)", expiredEventIDs()), Select("id").Where("event_id IN ?", ids),
).Delete(&database.DeliveryResult{}), ).Delete(&database.DeliveryResult{}),
"idx_delivery_results_delivery_id (delivery_id=?)", byEvent, byAge) "idx_delivery_results_delivery_id (delivery_id=?)",
assertPlanUses(t, db, dry.Unscoped().Where( "idx_deliveries_event_id (event_id=?)")
"event_id IN (?)", expiredEventIDs(), assertPlanUses(t, db, dry.Unscoped().Model(&database.Delivery{}).
).Delete(&database.Delivery{}), Select("target_id, count(*) AS deliveries_removed, "+
"idx_deliveries_event_id (event_id=?)", byAge) "count(CASE WHEN status = ? THEN 1 END) AS failed_removed",
assertPlanUses(t, db, dry.Unscoped().Where( database.DeliveryStatusFailed).
"created_at < ?", cutoff, Where("event_id IN ?", ids).Group("target_id").Find(&removed),
).Delete(&database.Event{}), "idx_events_created_at (created_at<?)") "idx_deliveries_event_id (event_id=?)")
assertPlanUses(t, db, dry.Unscoped().Where("event_id IN ?", ids).
Delete(&database.Delivery{}), "idx_deliveries_event_id (event_id=?)")
assertPlanUses(t, db, dry.Unscoped().Where("id IN ?", ids).
Delete(&database.Event{}), "sqlite_autoindex_events_1 (id=?)")
}
// TestStatisticsQueriesUseTheirIndexes does the same for the webhook
// page's statistics (readEventStats in the handlers): deliveries in
// progress, each target's deliveries finished since a time, which must
// come from the index alone, and events received since a time.
func TestStatisticsQueriesUseTheirIndexes(t *testing.T) {
t.Parallel()
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
db, err := mgr.GetDB(uuid.New().String())
require.NoError(t, err)
dry := db.Session(&gorm.Session{DryRun: true})
since := time.Now()
var (
count int64
byTarget []struct{ TargetID string }
)
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
Where("status IN ?", []database.DeliveryStatus{
database.DeliveryStatusPending,
database.DeliveryStatusRetrying,
}).Count(&count),
"idx_deliveries_status (status=? AND deleted_at=?)")
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
Select("target_id, "+
"count(CASE WHEN status = ? THEN 1 END) AS delivered, "+
"count(CASE WHEN status = ? THEN 1 END) AS failed",
database.DeliveryStatusDelivered,
database.DeliveryStatusFailed).
Where("status IN ? AND finished_at >= ?",
[]database.DeliveryStatus{
database.DeliveryStatusDelivered,
database.DeliveryStatusFailed,
}, since).
Group("target_id").Find(&byTarget),
"COVERING INDEX idx_deliveries_status "+
"(status=? AND deleted_at=? AND finished_at>?)")
assertPlanUses(t, db, dry.Model(&database.Event{}).
Where("created_at >= ?", since).Count(&count),
"idx_events_deleted_at_created_at "+
"(deleted_at=? AND created_at>?)")
} }
// assertPlanUses asserts that SQLite's plan for a statement GORM built // assertPlanUses asserts that SQLite's plan for a statement GORM built
+16
View File
@@ -5,6 +5,7 @@ import (
"io" "io"
"log/slog" "log/slog"
"os" "os"
"testing"
"time" "time"
"go.uber.org/fx" "go.uber.org/fx"
@@ -28,6 +29,10 @@ func NewTestRetentionReaper(
} }
} }
// ExportReapBatchSize exposes how many expired events one retention
// transaction deletes.
const ExportReapBatchSize = reapBatchSize
// ExportSweep runs a single retention sweep synchronously for tests. // ExportSweep runs a single retention sweep synchronously for tests.
func (r *RetentionReaper) ExportSweep(ctx context.Context) { func (r *RetentionReaper) ExportSweep(ctx context.Context) {
r.sweep(ctx) r.sweep(ctx)
@@ -79,3 +84,14 @@ func (d *Database) ExportSetBannerOut(w io.Writer) {
func DummyPasswordHashForTest() string { func DummyPasswordHashForTest() string {
return dummyPasswordHash() return dummyPasswordHash()
} }
// HashAtShippedCostForTest makes HashPassword hash at the shipped
// memory cost until t ends. t must not run in parallel with other
// tests, which would hash at that cost alongside it.
func HashAtShippedCostForTest(t *testing.T) {
t.Helper()
hashAtShippedCostInTest = true
t.Cleanup(func() { hashAtShippedCostInTest = false })
}
+3 -2
View File
@@ -15,6 +15,7 @@ type APIKey struct {
Description string `json:"description"` Description string `json:"description"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
// Relations // Relations. No model marshals the record it belongs to:
User User `json:"user,omitzero"` // User.APIKeys leads back here, and the JSON could loop.
User User `json:"-"`
} }
+18 -5
View File
@@ -1,6 +1,10 @@
package database package database
import "gorm.io/gorm" import (
"time"
"gorm.io/gorm"
)
// DeliveryStatus represents the status of a delivery // DeliveryStatus represents the status of a delivery
type DeliveryStatus string type DeliveryStatus string
@@ -37,7 +41,7 @@ type Delivery struct {
BaseModel BaseModel
EventID string `gorm:"type:uuid;not null;index:idx_deliveries_event_id,priority:1" json:"eventId"` EventID string `gorm:"type:uuid;not null;index:idx_deliveries_event_id,priority:1" json:"eventId"`
TargetID string `gorm:"type:uuid;not null" json:"targetId"` TargetID string `gorm:"type:uuid;not null;index:idx_deliveries_status,priority:4" json:"targetId"`
Status DeliveryStatus `gorm:"not null;default:'pending';index:idx_deliveries_status,priority:1" json:"status"` Status DeliveryStatus `gorm:"not null;default:'pending';index:idx_deliveries_status,priority:1" json:"status"`
// DeletedAt repeats the BaseModel field only to be the second column // DeletedAt repeats the BaseModel field only to be the second column
@@ -45,8 +49,17 @@ type Delivery struct {
// gives. // gives.
DeletedAt gorm.DeletedAt `gorm:"index:idx_deliveries_event_id,priority:2;index:idx_deliveries_status,priority:2" json:"deletedAt,omitzero"` DeletedAt gorm.DeletedAt `gorm:"index:idx_deliveries_event_id,priority:2;index:idx_deliveries_status,priority:2" json:"deletedAt,omitzero"`
// Relations // FinishedAt is when the delivery became delivered or failed, and
Event Event `json:"event,omitzero"` // nil while it is pending or retrying. It and then TargetID end the
Target Target `json:"target,omitzero"` // status index, so the webhook page counts each target's deliveries
// that finished in a recent window by reading just that window from
// the index.
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
// Relations. No model marshals the record it belongs to:
// Event.Deliveries and Target.Deliveries lead back here, and the
// JSON could loop.
Event Event `json:"-"`
Target Target `json:"-"`
DeliveryResults []DeliveryResult `json:"deliveryResults,omitempty"` DeliveryResults []DeliveryResult `json:"deliveryResults,omitempty"`
} }
+3 -2
View File
@@ -23,6 +23,7 @@ type DeliveryResult struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
Duration int64 `json:"durationMs"` // Duration in milliseconds Duration int64 `json:"durationMs"` // Duration in milliseconds
// Relations // Relations. No model marshals the record it belongs to:
Delivery Delivery `json:"delivery,omitzero"` // Delivery.DeliveryResults leads back here, and the JSON could loop.
Delivery Delivery `json:"-"`
} }
+3 -2
View File
@@ -15,6 +15,7 @@ type Entrypoint struct {
Description string `json:"description"` Description string `json:"description"`
Active bool `gorm:"default:true" json:"active"` Active bool `gorm:"default:true" json:"active"`
// Relations // Relations. No model marshals the record it belongs to:
Webhook Webhook `json:"webhook,omitzero"` // Webhook.Entrypoints leads back here, and the JSON could loop.
Webhook Webhook `json:"-"`
} }
+9 -3
View File
@@ -31,6 +31,11 @@ type Event struct {
Body string `gorm:"type:text" json:"body"` Body string `gorm:"type:text" json:"body"`
ContentType string `json:"contentType"` ContentType string `json:"contentType"`
// BodyBytes is the size of Body in bytes, recorded when the event
// is stored so the recent events list can show it without reading
// the body.
BodyBytes int64 `gorm:"not null" json:"bodyBytes"`
// ResubmittedFromID names the event this one was copied from by // ResubmittedFromID names the event this one was copied from by
// 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, which is every event created before the column // the receiver, which is every event created before the column
@@ -39,8 +44,9 @@ type Event struct {
// 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" json:"resubmittedFromId,omitempty"` ResubmittedFromID *string `gorm:"type:uuid;index" json:"resubmittedFromId,omitempty"`
// Relations // Relations. No model marshals the record it belongs to, so
Webhook Webhook `json:"webhook,omitzero"` // Webhook and Entrypoint are left out of the JSON.
Entrypoint Entrypoint `json:"entrypoint,omitzero"` Webhook Webhook `json:"-"`
Entrypoint Entrypoint `json:"-"`
Deliveries []Delivery `json:"deliveries,omitempty"` Deliveries []Delivery `json:"deliveries,omitempty"`
} }
+126
View File
@@ -0,0 +1,126 @@
package database_test
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
)
// TestPreloadedModelsMarshalWithoutTheirParent pins that a child's
// reference to the record it belongs to is left out of the JSON, so a
// webhook and its targets cannot marshal each other in a loop, and that
// GORM still preloads that reference, since it ignores json tags.
func TestPreloadedModelsMarshalWithoutTheirParent(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
stored := database.Webhook{
UserID: uuid.New().String(),
Name: testWebhookName,
Entrypoints: []database.Entrypoint{{Path: uuid.New().String()}},
Targets: []database.Target{{
Name: "log",
Type: database.TargetTypeLog,
}},
}
require.NoError(t, db.Create(&stored).Error)
entrypointID := stored.Entrypoints[0].ID
targetID := stored.Targets[0].ID
var webhook database.Webhook
require.NoError(t, db.
Preload("Entrypoints.Webhook").
Preload("Targets.Webhook").
First(&webhook, "id = ?", stored.ID).Error)
require.Len(t, webhook.Entrypoints, 1)
require.Len(t, webhook.Targets, 1)
assert.Equal(t, stored.ID, webhook.Entrypoints[0].Webhook.ID)
assert.Equal(t, stored.ID, webhook.Targets[0].Webhook.ID)
encoded := marshalModel(t, webhook)
assert.Contains(t, encoded, entrypointID)
assert.Contains(t, encoded, targetID)
// Each child holds the parent's id as its webhookId, so the parent
// is looked for by its own id field.
parentIDField := `"id":"` + stored.ID + `"`
assert.NotContains(t, marshalModel(t, webhook.Entrypoints[0]), parentIDField)
assert.NotContains(t, marshalModel(t, webhook.Targets[0]), parentIDField)
var target database.Target
require.NoError(t, db.
Preload("Webhook").
First(&target, "id = ?", targetID).Error)
assert.Equal(t, stored.ID, target.Webhook.ID)
encoded = marshalModel(t, target)
assert.Contains(t, encoded, stored.ID)
assert.NotContains(t, encoded, parentIDField)
}
// TestModelsMarshalWithoutTheirParent covers the other references to a
// parent: each model is built with its parent set, and the parent's id
// must not appear in the JSON.
func TestModelsMarshalWithoutTheirParent(t *testing.T) {
t.Parallel()
parent := database.BaseModel{ID: uuid.New().String()}
cases := []struct {
name string
model any
}{
{
name: "Webhook.User",
model: database.Webhook{User: database.User{BaseModel: parent}},
},
{
name: "APIKey.User",
model: database.APIKey{User: database.User{BaseModel: parent}},
},
{
name: "Delivery.Event",
model: database.Delivery{Event: database.Event{BaseModel: parent}},
},
{
name: "Delivery.Target",
model: database.Delivery{Target: database.Target{BaseModel: parent}},
},
{
name: "DeliveryResult.Delivery",
model: database.DeliveryResult{
Delivery: database.Delivery{BaseModel: parent},
},
},
{
name: "Event.Webhook",
model: database.Event{Webhook: database.Webhook{BaseModel: parent}},
},
{
name: "Event.Entrypoint",
model: database.Event{
Entrypoint: database.Entrypoint{BaseModel: parent},
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assert.NotContains(t, marshalModel(t, tc.model), parent.ID)
})
}
}
+3 -2
View File
@@ -34,7 +34,8 @@ type Target struct {
MaxRetries int `json:"maxRetries,omitempty"` MaxRetries int `json:"maxRetries,omitempty"`
MaxQueueSize int `json:"maxQueueSize,omitempty"` MaxQueueSize int `json:"maxQueueSize,omitempty"`
// Relations // Relations. No model marshals the record it belongs to:
Webhook Webhook `json:"webhook,omitzero"` // Webhook.Targets leads back here, and the JSON could loop.
Webhook Webhook `json:"-"`
Deliveries []Delivery `json:"deliveries,omitempty"` Deliveries []Delivery `json:"deliveries,omitempty"`
} }
+99
View File
@@ -0,0 +1,99 @@
package database
import (
"fmt"
"time"
"gorm.io/gorm"
)
// The running totals in a webhook's event database keep the webhook
// page's lifetime figures right after retention has removed the rows
// they count, and let the page show them without counting every row.
// Each total changes in the transaction that writes or deletes the
// rows it counts.
// EventTotals is the single row counting a webhook's events: every
// event ever stored, how many of them retention has deleted, and when
// the newest arrived, which retention leaves as it is.
type EventTotals struct {
ID int64 `gorm:"primaryKey"`
Events int64 `gorm:"not null"`
EventsRemoved int64 `gorm:"not null"`
// LastEventAt is when the newest event arrived, or nil before the
// first.
LastEventAt *time.Time
}
// TableName names the table AddEventTotals updates.
func (EventTotals) TableName() string {
return "event_totals"
}
// TargetTotals is one row per target counting its deliveries: every
// delivery ever created, how many became delivered and how many
// failed, and how many deliveries and failed deliveries retention has
// deleted. The webhook's delivery figures are these rows summed.
type TargetTotals struct {
TargetID string `gorm:"type:uuid;primaryKey"`
Deliveries int64 `gorm:"not null"`
Delivered int64 `gorm:"not null"`
Failed int64 `gorm:"not null"`
DeliveriesRemoved int64 `gorm:"not null"`
FailedRemoved int64 `gorm:"not null"`
}
// TableName names the table AddTargetTotals updates.
func (TargetTotals) TableName() string {
return "target_totals"
}
// AddEventTotals adds each count in add to the webhook's event totals,
// and records add.LastEventAt as when the newest event arrived if it is
// set. Call it on the transaction that writes or deletes the events it
// counts.
func AddEventTotals(tx *gorm.DB, add EventTotals) error {
err := tx.Exec(
`UPDATE event_totals SET
events = events + ?,
events_removed = events_removed + ?,
last_event_at = coalesce(?, last_event_at)`,
add.Events, add.EventsRemoved, add.LastEventAt,
).Error
if err != nil {
return fmt.Errorf("adding to event totals: %w", err)
}
return nil
}
// AddTargetTotals adds each count in add to the totals of the target
// add.TargetID names, creating its row the first time. Call it on the
// transaction that writes or deletes the deliveries it counts.
func AddTargetTotals(tx *gorm.DB, add TargetTotals) error {
err := tx.Exec(
`INSERT INTO target_totals (target_id, deliveries, delivered,
failed, deliveries_removed, failed_removed)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (target_id) DO UPDATE SET
deliveries = deliveries + excluded.deliveries,
delivered = delivered + excluded.delivered,
failed = failed + excluded.failed,
deliveries_removed =
deliveries_removed + excluded.deliveries_removed,
failed_removed = failed_removed + excluded.failed_removed`,
add.TargetID, add.Deliveries, add.Delivered,
add.Failed, add.DeliveriesRemoved, add.FailedRemoved,
).Error
if err != nil {
return fmt.Errorf(
"adding to totals of target %s: %w", add.TargetID, err,
)
}
return nil
}
+46 -1
View File
@@ -1,13 +1,58 @@
package database package database
import (
"errors"
"fmt"
"gorm.io/gorm"
)
// MaxUsernameBytes is the longest username, in bytes, that a user may
// have. The same number appears in the check constraint on
// User.Username, because a struct tag cannot reference a constant.
//
// A login stores the username in the session cookie, and both
// securecookie and browsers refuse a cookie value past about 4096
// bytes. That value is the session base64-encoded twice, so it holds
// 4096 × 3/4 × 3/4 = 2304 bytes of session, and the signature,
// timestamp and the session's other values take about 270 of those: a
// username longer than about 2030 bytes can never log in. The limit is
// about half that, so the session can carry more values later without
// locking out an account whose username is already at the limit.
const MaxUsernameBytes = 1024
// ErrUsernameTooLong is returned when a user is saved with a username
// longer than MaxUsernameBytes.
var ErrUsernameTooLong = errors.New("username is too long")
// User represents a user of the webhooker service // User represents a user of the webhooker service
//
//nolint:lll // a struct tag cannot wrap
type User struct { type User struct {
BaseModel BaseModel
Username string `gorm:"uniqueIndex;not null" json:"username"` Username string `gorm:"uniqueIndex;not null;check:length(CAST(username AS BLOB)) <= 1024" json:"username"`
Password string `gorm:"not null" json:"-"` // Argon2 hashed Password string `gorm:"not null" json:"-"` // Argon2 hashed
// Relations // Relations
Webhooks []Webhook `json:"webhooks,omitempty"` Webhooks []Webhook `json:"webhooks,omitempty"`
APIKeys []APIKey `json:"apiKeys,omitempty"` APIKeys []APIKey `json:"apiKeys,omitempty"`
} }
// BeforeSave rejects a username longer than MaxUsernameBytes when a whole
// User is created or saved, so those calls get ErrUsernameTooLong rather
// than the database's constraint error. A column update such as
// Update("username", ...) is caught only by the check constraint, as is
// any path that writes the table without this model.
func (u *User) BeforeSave(_ *gorm.DB) error {
if len(u.Username) > MaxUsernameBytes {
return fmt.Errorf(
"%w: %d bytes, limit is %d",
ErrUsernameTooLong,
len(u.Username),
MaxUsernameBytes,
)
}
return nil
}
+65
View File
@@ -0,0 +1,65 @@
package database_test
import (
"strings"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
)
// usernameAtLimit is exactly MaxUsernameBytes long, built from a
// two-byte character. A check that counted characters rather than bytes
// would see half the length and let the one-byte-longer name through.
func usernameAtLimit() string {
return strings.Repeat("é", database.MaxUsernameBytes/2)
}
func TestUserCreate_RejectsOverlongUsername(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
err := db.Create(&database.User{
Username: usernameAtLimit() + "x",
Password: "hash",
}).Error
require.ErrorIs(t, err, database.ErrUsernameTooLong)
}
func TestUserCreate_AcceptsUsernameAtLimit(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
require.NoError(t, db.Create(&database.User{
Username: usernameAtLimit(),
Password: "hash",
}).Error)
}
// TestUsersTable_EnforcesUsernameLimitWithoutTheModel inserts with raw
// SQL, as a path that bypassed User.BeforeSave would, so only the
// table's check constraint stands between it and an over-long
// username. Accepting the name at the limit and refusing the next byte
// also pins the constraint's number to MaxUsernameBytes.
func TestUsersTable_EnforcesUsernameLimitWithoutTheModel(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
insert := "INSERT INTO users (id, username, password) VALUES (?, ?, ?)"
require.NoError(t, db.Exec(
insert, uuid.New().String(), usernameAtLimit(), "hash",
).Error)
err := db.Exec(
insert, uuid.New().String(), usernameAtLimit()+"x", "hash",
).Error
require.Error(t, err)
assert.Contains(t, err.Error(), "CHECK constraint failed")
}
+3 -2
View File
@@ -66,8 +66,9 @@ type Webhook struct {
// must equal DefaultRetentionDays. // must equal DefaultRetentionDays.
RetentionDays int `gorm:"default:30" json:"retentionDays"` RetentionDays int `gorm:"default:30" json:"retentionDays"`
// Relations // Relations. No model marshals the record it belongs to:
User User `json:"user,omitzero"` // User.Webhooks leads back here, and the JSON could loop.
User User `json:"-"`
Entrypoints []Entrypoint `json:"entrypoints,omitempty"` Entrypoints []Entrypoint `json:"entrypoints,omitempty"`
Targets []Target `json:"targets,omitempty"` Targets []Target `json:"targets,omitempty"`
} }
+2 -1
View File
@@ -2,7 +2,8 @@ package database
// Migrate runs database migrations for the main application database. // Migrate runs database migrations for the main application database.
// Only configuration-tier models are stored in the main database. // Only configuration-tier models are stored in the main database.
// Event-tier models (Event, Delivery, DeliveryResult) live in // Event-tier models (Event, Delivery, DeliveryResult, EventTotals,
// TargetTotals) live in
// per-webhook dedicated databases managed by WebhookDBManager. // per-webhook dedicated databases managed by WebhookDBManager.
func (d *Database) Migrate() error { func (d *Database) Migrate() error {
return d.db.AutoMigrate( return d.db.AutoMigrate(
+22 -1
View File
@@ -9,6 +9,7 @@ import (
"math/big" "math/big"
"strings" "strings"
"sync" "sync"
"testing"
"golang.org/x/crypto/argon2" "golang.org/x/crypto/argon2"
) )
@@ -63,10 +64,30 @@ func DefaultPasswordConfig() *PasswordConfig {
} }
} }
// HashPassword generates an Argon2id hash of the password // testArgon2Memory is the Argon2id memory cost, in KiB, that a test
// binary hashes with: 1 MB instead of the shipped 64 MB. Every test
// that starts a database hashes the bootstrap admin password, dozens
// of them run in parallel, and under the race detector each 64 MB hash
// holds about 150 MB. VerifyPassword reads the cost from the hash it
// checks, so verification follows.
const testArgon2Memory = 1024
// hashAtShippedCostInTest makes a test binary hash at the shipped
// memory cost. Only TestHashPassword_ShippedParameters sets it.
//
//nolint:gochecknoglobals // set by one test, see above
var hashAtShippedCostInTest bool
// HashPassword generates an Argon2id hash of the password. A binary
// built by go test hashes at testArgon2Memory; one built by go build
// always hashes at the defaults.
func HashPassword(password string) (string, error) { func HashPassword(password string) (string, error) {
config := DefaultPasswordConfig() config := DefaultPasswordConfig()
if testing.Testing() && !hashAtShippedCostInTest {
config.Memory = testArgon2Memory
}
// Generate a salt // Generate a salt
salt := make([]byte, config.SaltLen) salt := make([]byte, config.SaltLen)
+33
View File
@@ -192,6 +192,39 @@ func TestHashPasswordUniqueness(t *testing.T) {
} }
} }
// TestHashPassword_ShippedParameters hashes and verifies through
// HashPassword at the shipped Argon2id parameters. Every other test
// hashes at the lower memory cost a test binary uses, so this is the
// one that keeps production hashing covered. One hash and one
// verification: each costs 64 MB.
//
//nolint:paralleltest // changes the hashing cost for the whole binary
func TestHashPassword_ShippedParameters(t *testing.T) {
database.HashAtShippedCostForTest(t)
password := "correct horse battery staple"
hash, err := database.HashPassword(password)
if err != nil {
t.Fatalf("hashing with the shipped parameters: %v", err)
}
const shipped = "$argon2id$v=19$m=65536,t=1,p=4$"
if !strings.HasPrefix(hash, shipped) {
t.Errorf("hash = %q, want prefix %q", hash, shipped)
}
valid, err := database.VerifyPassword(password, hash)
if err != nil {
t.Fatalf("VerifyPassword() error = %v", err)
}
if !valid {
t.Error("VerifyPassword() returned false for correct password")
}
}
// TestVerifyDummyPassword_DoesRealWork covers the anti-enumeration // TestVerifyDummyPassword_DoesRealWork covers the anti-enumeration
// path. Login charges an unknown username a verification against a // path. Login charges an unknown username a verification against a
// dummy hash so that a nonexistent account is not answered in // dummy hash so that a nonexistent account is not answered in
+123 -66
View File
@@ -18,6 +18,19 @@ import (
// computation. // computation.
const hoursPerDay = 24 const hoursPerDay = 24
// reapBatchSize is how many expired events one retention transaction
// deletes. A transaction holds the event database's write lock, which
// the receiver and the delivery workers wait for, so a large prune is
// split into transactions each short enough to finish well inside the
// busy timeout.
const reapBatchSize = 1000
// reapBatchPause is how long retention waits after one batch before
// starting the next. A writer waiting for the write lock checks for it
// again after at most 100 ms, so a longer pause lets it in between two
// batches instead of only after the whole prune.
const reapBatchPause = 200 * time.Millisecond
// RetentionReaperParams holds the fx dependencies for the // RetentionReaperParams holds the fx dependencies for the
// RetentionReaper. // RetentionReaper.
type RetentionReaperParams struct { type RetentionReaperParams struct {
@@ -171,32 +184,31 @@ func (r *RetentionReaper) sweep(ctx context.Context) {
wh := webhooks[i] wh := webhooks[i]
// Skip retain-forever webhooks before building any query.
// RetainsForever covers both the RetentionForeverDays
// sentinel and the non-positive values that predate it: the
// sentinel is a positive number, so without this the reaper
// would compute a cutoff a thousand years in the past and
// issue a DELETE matching nothing on every single sweep.
if wh.RetainsForever() {
continue
}
// Nothing to reap if the per-webhook database has never // Nothing to reap if the per-webhook database has never
// been created. // been created.
if !r.dbManager.DBExists(wh.ID) { if !r.dbManager.DBExists(wh.ID) {
continue continue
} }
r.reapWebhook(wh.ID, wh.RetentionDays) r.reapWebhook(ctx, wh.ID, wh.RetentionDays)
} }
} }
// reapWebhook removes every expired event (and its dependents) from a // reapWebhook removes every expired event (and its dependents) from a
// single webhook's database. // single webhook's database, or as many as it reaches before ctx is
// cancelled.
func (r *RetentionReaper) reapWebhook( func (r *RetentionReaper) reapWebhook(
ctx context.Context,
webhookID string, webhookID string,
retentionDays int, retentionDays int,
) { ) {
// A retain-forever webhook has no cutoff, so its database is not
// even opened.
cutoff, ok := retentionCutoff(time.Now(), retentionDays)
if !ok {
return
}
db, err := r.dbManager.GetDB(webhookID) db, err := r.dbManager.GetDB(webhookID)
if err != nil { if err != nil {
r.log.Error( r.log.Error(
@@ -208,12 +220,7 @@ func (r *RetentionReaper) reapWebhook(
return return
} }
cutoff, ok := retentionCutoff(time.Now(), retentionDays) deleted, err := reapExpired(ctx, db, cutoff)
if !ok {
return
}
deleted, err := reapExpired(db, cutoff)
if err != nil { if err != nil {
r.log.Error( r.log.Error(
"retention sweep: failed to reap expired events", "retention sweep: failed to reap expired events",
@@ -265,57 +272,107 @@ func retentionCutoff(
), true ), true
} }
// reapExpired hard-deletes, in foreign-key-safe order, the delivery // reapExpired hard-deletes the events older than cutoff, with their
// results, deliveries, and events associated with events older than // deliveries and delivery results, reapBatchSize events per
// cutoff. Deletes are unscoped so rows are physically removed rather // transaction with reapBatchPause between transactions, until none is
// than soft-deleted, reclaiming disk. It returns the number of events // left. Once ctx is cancelled it returns after the batch in hand,
// deleted. // leaving the rest to the next sweep, so stopping the app does not
func reapExpired(db *gorm.DB, cutoff time.Time) (int64, error) { // wait for a long prune. It returns the number of events deleted.
// Fresh subqueries are built per statement to avoid reusing a func reapExpired(
// mutated builder across executions. ctx context.Context, db *gorm.DB, cutoff time.Time,
expiredEventIDs := func() *gorm.DB { ) (int64, error) {
return db.Model(&Event{}). var total int64
Select("id").
Where("created_at < ?", cutoff)
}
expiredDeliveryIDs := func() *gorm.DB {
return db.Model(&Delivery{}).
Select("id").
Where("event_id IN (?)", expiredEventIDs())
}
// 1. Delivery results whose delivery belongs to an expired event. for {
res := db.Unscoped(). var eventIDs []string
Where("delivery_id IN (?)", expiredDeliveryIDs()).
Delete(&DeliveryResult{})
if res.Error != nil {
return 0, fmt.Errorf(
"deleting expired delivery results: %w",
res.Error,
)
}
// 2. Deliveries belonging to an expired event. err := db.Transaction(func(tx *gorm.DB) error {
del := db.Unscoped(). err := tx.Unscoped().Model(&Event{}).
Where("event_id IN (?)", expiredEventIDs()).
Delete(&Delivery{})
if del.Error != nil {
return 0, fmt.Errorf(
"deleting expired deliveries: %w",
del.Error,
)
}
// 3. The expired events themselves.
ev := db.Unscoped().
Where("created_at < ?", cutoff). Where("created_at < ?", cutoff).
Delete(&Event{}) Limit(reapBatchSize).
if ev.Error != nil { Pluck("id", &eventIDs).Error
return 0, fmt.Errorf( if err != nil {
"deleting expired events: %w", return fmt.Errorf("selecting expired events: %w", err)
ev.Error,
)
} }
return ev.RowsAffected, nil if len(eventIDs) == 0 {
return nil
}
return deleteEvents(tx, eventIDs)
})
if err != nil {
return total, err
}
total += int64(len(eventIDs))
if len(eventIDs) < reapBatchSize {
return total, nil
}
select {
case <-ctx.Done():
return total, nil
case <-time.After(reapBatchPause):
}
}
}
// deleteEvents hard-deletes the given events and, in foreign-key-safe
// order before them, their delivery results and deliveries, then adds
// what it deleted to the running totals. It runs on reapExpired's
// transaction, so the totals change exactly when the rows do. Deletes
// are unscoped so rows are physically removed rather than
// soft-deleted, reclaiming disk.
func deleteEvents(tx *gorm.DB, eventIDs []string) error {
// 1. The delivery results of the events' deliveries.
err := tx.Unscoped().
Where("delivery_id IN (?)", tx.Unscoped().Model(&Delivery{}).
Select("id").
Where("event_id IN ?", eventIDs)).
Delete(&DeliveryResult{}).Error
if err != nil {
return fmt.Errorf("deleting expired delivery results: %w", err)
}
// 2. The events' deliveries, after counting them, and the failed
// ones among them, per target. The status is tested in the select
// list rather than the WHERE clause: there, SQLite would read every
// failed delivery the webhook has through the status index,
// instead of only these through the event_id index.
var removed []TargetTotals
err = tx.Unscoped().Model(&Delivery{}).
Select("target_id, count(*) AS deliveries_removed, "+
"count(CASE WHEN status = ? THEN 1 END) AS failed_removed",
DeliveryStatusFailed).
Where("event_id IN ?", eventIDs).
Group("target_id").
Find(&removed).Error
if err != nil {
return fmt.Errorf("counting expired deliveries: %w", err)
}
err = tx.Unscoped().
Where("event_id IN ?", eventIDs).
Delete(&Delivery{}).Error
if err != nil {
return fmt.Errorf("deleting expired deliveries: %w", err)
}
// 3. The events themselves.
ev := tx.Unscoped().Where("id IN ?", eventIDs).Delete(&Event{})
if ev.Error != nil {
return fmt.Errorf("deleting expired events: %w", ev.Error)
}
for i := range removed {
err = AddTargetTotals(tx, removed[i])
if err != nil {
return err
}
}
return AddEventTotals(tx, EventTotals{EventsRemoved: ev.RowsAffected})
} }
+1 -1
View File
@@ -362,7 +362,7 @@ func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
t, t,
overflowingRetentionDays, overflowingRetentionDays,
database.RetentionForeverDays, database.RetentionForeverDays,
"the test value must not be rescued by the forever skip", "the test value must not be treated as retain-forever",
) )
webhookID := createWebhook( webhookID := createWebhook(
+417
View File
@@ -0,0 +1,417 @@
package database_test
import (
"context"
"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"
)
// readEventTotals reads a webhook database's row of event totals,
// asserting that it has exactly one.
func readEventTotals(t *testing.T, db *gorm.DB) database.EventTotals {
t.Helper()
var rows []database.EventTotals
require.NoError(t, db.Find(&rows).Error)
require.Len(t, rows, 1)
return rows[0]
}
// readTargetTotals reads a webhook database's target totals, keyed by
// target.
func readTargetTotals(
t *testing.T, db *gorm.DB,
) map[string]database.TargetTotals {
t.Helper()
var rows []database.TargetTotals
require.NoError(t, db.Find(&rows).Error)
byTarget := make(map[string]database.TargetTotals, len(rows))
for _, row := range rows {
byTarget[row.TargetID] = row
}
return byTarget
}
// TestWebhookDBManager_TotalsSurviveReopen verifies that a new event
// database starts with one row of zero event totals and no target
// totals, that adding to a target twice adds to the one row, and that
// opening the database again keeps everything added.
func TestWebhookDBManager_TotalsSurviveReopen(t *testing.T) {
t.Parallel()
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
webhookID := uuid.New().String()
db, err := mgr.GetDB(webhookID)
require.NoError(t, err)
fresh := readEventTotals(t, db)
assert.Equal(t, database.EventTotals{ID: fresh.ID}, fresh)
assert.Empty(t, readTargetTotals(t, db))
first, second := uuid.New().String(), uuid.New().String()
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
Events: 2,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: first, Deliveries: 2, Delivered: 1,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: first, Failed: 1,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: second, Deliveries: 1,
}))
// Drop the cached connection so the next open reopens the file,
// as a restart would.
require.NoError(t, mgr.CloseAll())
db, err = mgr.GetDB(webhookID)
require.NoError(t, err)
assert.Equal(t, database.EventTotals{ID: fresh.ID, Events: 2},
readEventTotals(t, db))
assert.Equal(t, map[string]database.TargetTotals{
first: {
TargetID: first, Deliveries: 2, Delivered: 1, Failed: 1,
},
second: {TargetID: second, Deliveries: 1},
}, readTargetTotals(t, db))
}
// seedExpiredEvents stores count events created at the given time,
// each with a delivered delivery to one target and a failed delivery
// to the other, and one attempt for each delivery.
//
// It and seedBareEvents insert 50 rows per statement, not more. The
// SQLite driver looks up each parameter's value by scanning the
// statement's arguments from the first until it reaches that
// parameter's, so the time to bind a statement grows with the square of
// its parameter count: at 500 rows, several thousand parameters, the
// seeding took most of these tests' time under -race.
func seedExpiredEvents(
t *testing.T,
db *gorm.DB,
webhookID string,
count int,
createdAt time.Time,
delivered, failed string,
) {
t.Helper()
events := make([]database.Event, count)
deliveries := make([]database.Delivery, 0, 2*count)
for i := range events {
events[i] = database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: http.MethodPost,
}
events[i].ID = uuid.New().String()
events[i].CreatedAt = createdAt
deliveries = append(deliveries,
database.Delivery{
EventID: events[i].ID,
TargetID: delivered,
Status: database.DeliveryStatusDelivered,
},
database.Delivery{
EventID: events[i].ID,
TargetID: failed,
Status: database.DeliveryStatusFailed,
},
)
}
require.NoError(t, db.CreateInBatches(events, 50).Error)
require.NoError(t, db.CreateInBatches(deliveries, 50).Error)
results := make([]database.DeliveryResult, len(deliveries))
for i := range deliveries {
results[i] = database.DeliveryResult{
DeliveryID: deliveries[i].ID, AttemptNum: 1,
}
}
require.NoError(t, db.CreateInBatches(results, 50).Error)
}
// seedBareEvents stores count events created at the given time, with
// no deliveries.
func seedBareEvents(
t *testing.T,
db *gorm.DB,
webhookID string,
count int,
createdAt time.Time,
) {
t.Helper()
events := make([]database.Event, count)
for i := range events {
events[i] = database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: http.MethodPost,
}
events[i].CreatedAt = createdAt
}
require.NoError(t, db.CreateInBatches(events, 50).Error)
}
// TestRetentionReaper_PrunesMoreThanOneBatch verifies that a prune
// larger than one transaction's batch removes every expired event with
// its deliveries and delivery results, keeps the recent event, and
// adds what it removed to the event and target totals, so the totals
// within retention match the rows still stored.
func TestRetentionReaper_PrunesMoreThanOneBatch(t *testing.T) {
t.Parallel()
env := setupRetentionTest(t)
webhookID := createWebhook(t, env.mainDB.DB(), 30)
db, err := env.mgr.GetDB(webhookID)
require.NoError(t, err)
expired := database.ExportReapBatchSize + 1
delivered, failed := uuid.New().String(), uuid.New().String()
seedExpiredEvents(t, db, webhookID, expired,
time.Now().Add(-40*24*time.Hour), delivered, failed)
// One recent event, delivered to the first target.
recent := seedEventChain(t, db, webhookID, time.Now())
require.NoError(t, db.Model(&database.Delivery{}).
Where("id = ?", recent.deliveryID).
Update("target_id", delivered).Error)
// The totals storing those rows would have left.
n := int64(expired)
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
Events: n + 1,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: delivered, Deliveries: n + 1, Delivered: n + 1,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: failed, Deliveries: n, Failed: n,
}))
env.reaper.ExportSweep(context.Background())
// Only the recent event's rows are left.
for _, model := range []any{
&database.Event{}, &database.Delivery{}, &database.DeliveryResult{},
} {
var count int64
require.NoError(t, db.Model(model).Count(&count).Error)
assert.Equal(t, int64(1), count, "%T rows left", model)
}
assertChainPresent(t, db, recent)
eventTotals := readEventTotals(t, db)
assert.Equal(t, database.EventTotals{
ID: eventTotals.ID, Events: n + 1, EventsRemoved: n,
}, eventTotals)
targetTotals := readTargetTotals(t, db)
assert.Equal(t, map[string]database.TargetTotals{
delivered: {
TargetID: delivered, Deliveries: n + 1, Delivered: n + 1,
DeliveriesRemoved: n,
},
failed: {
TargetID: failed, Deliveries: n, Failed: n,
DeliveriesRemoved: n, FailedRemoved: n,
},
}, targetTotals)
// A sweep with nothing left to remove changes nothing.
env.reaper.ExportSweep(context.Background())
assert.Equal(t, eventTotals, readEventTotals(t, db))
assert.Equal(t, targetTotals, readTargetTotals(t, db))
}
// TestRetentionReaper_WriteDuringPruneSucceeds verifies that a prune
// of several batches lets other writers in between its batches: an
// event stored once the first batch is deleted is stored while expired
// events are still left, not only after the prune has finished.
func TestRetentionReaper_WriteDuringPruneSucceeds(t *testing.T) {
t.Parallel()
env := setupRetentionTest(t)
webhookID := createWebhook(t, env.mainDB.DB(), 30)
db, err := env.mgr.GetDB(webhookID)
require.NoError(t, err)
// Three batches of expired events, with nothing else stored: only
// the number of batches matters here.
expired := 3 * database.ExportReapBatchSize
seedBareEvents(t, db, webhookID, expired,
time.Now().Add(-40*24*time.Hour))
cutoff := time.Now().Add(-30 * 24 * time.Hour)
countExpired := func() int64 {
var count int64
require.NoError(t, db.Model(&database.Event{}).
Where("created_at < ?", cutoff).
Count(&count).Error)
return count
}
pruned := make(chan struct{})
go func() {
defer close(pruned)
env.reaper.ExportSweep(context.Background())
}()
t.Cleanup(func() { <-pruned })
// Every stored event is expired until the write below.
require.Eventually(t, func() bool {
var count int64
err := db.Model(&database.Event{}).Count(&count).Error
return err == nil && count < int64(expired)
}, 10*time.Second, 10*time.Millisecond)
event := &database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: http.MethodPost,
}
require.NoError(t, db.Create(event).Error)
assert.Positive(t, countExpired(),
"the event was stored only after the whole prune")
<-pruned
assert.Zero(t, countExpired())
var stored database.Event
require.NoError(t, db.First(&stored, "id = ?", event.ID).Error)
}
// TestRetentionReaper_StopDuringPruneLeavesTheRest verifies that
// stopping the reaper during a prune of several batches returns
// between two batches, well inside the stop timeout, leaving the
// remaining expired events for the next sweep, and that the totals
// match the rows left.
func TestRetentionReaper_StopDuringPruneLeavesTheRest(t *testing.T) {
t.Parallel()
env := setupRetentionTest(t)
webhookID := createWebhook(t, env.mainDB.DB(), 30)
db, err := env.mgr.GetDB(webhookID)
require.NoError(t, err)
// Two batches and one more of expired events, a few of them with a
// delivered and a failed delivery for the target totals to count.
// Most carry nothing else, to keep the test quick.
const withDeliveries = 10
expiredAt := time.Now().Add(-40 * 24 * time.Hour)
delivered, failed := uuid.New().String(), uuid.New().String()
seedExpiredEvents(t, db, webhookID, withDeliveries, expiredAt,
delivered, failed)
seedBareEvents(t, db, webhookID,
2*database.ExportReapBatchSize+1-withDeliveries, expiredAt)
n := int64(2*database.ExportReapBatchSize + 1)
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
Events: n,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: delivered, Deliveries: withDeliveries,
Delivered: withDeliveries,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: failed, Deliveries: withDeliveries,
Failed: withDeliveries,
}))
env.reaper.ExportSetInterval(time.Millisecond)
env.reaper.ExportStart()
// Stop once the first batch is deleted. The stop lands in the pause
// after it, or at worst during the second batch, so at least the
// last event is left.
require.Eventually(t, func() bool {
var count int64
err := db.Model(&database.Event{}).Count(&count).Error
return err == nil && count < n
}, 10*time.Second, 10*time.Millisecond)
// The app's stop timeout.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
require.NoError(t, env.reaper.ExportStop(ctx))
var events int64
require.NoError(t, db.Model(&database.Event{}).Count(&events).Error)
assert.Positive(t, events, "the stop waited for the whole prune")
eventTotals := readEventTotals(t, db)
assert.Equal(t, events, eventTotals.Events-eventTotals.EventsRemoved)
targetTotals := readTargetTotals(t, db)
require.Len(t, targetTotals, 2)
for target, totals := range targetTotals {
var deliveries, failures int64
require.NoError(t, db.Model(&database.Delivery{}).
Where("target_id = ?", target).
Count(&deliveries).Error)
require.NoError(t, db.Model(&database.Delivery{}).
Where("target_id = ? AND status = ?",
target, database.DeliveryStatusFailed).
Count(&failures).Error)
assert.Equal(t, deliveries,
totals.Deliveries-totals.DeliveriesRemoved, target)
assert.Equal(t, failures, totals.Failed-totals.FailedRemoved,
target)
}
}
+15 -1
View File
@@ -35,7 +35,8 @@ var errInvalidCachedDBType = errors.New(
// WebhookDBManager manages per-webhook SQLite database files // WebhookDBManager manages per-webhook SQLite database files
// for event storage. Each webhook gets its own dedicated // for event storage. Each webhook gets its own dedicated
// database containing Events, Deliveries, and DeliveryResults. // database containing Events, Deliveries, DeliveryResults and the
// running totals of them (EventTotals, TargetTotals).
// Database connections are opened lazily and cached. // Database connections are opened lazily and cached.
type WebhookDBManager struct { type WebhookDBManager struct {
dataDir string dataDir string
@@ -295,6 +296,7 @@ func (m *WebhookDBManager) openDB(
// Run migrations for event-tier models only // Run migrations for event-tier models only
err = db.AutoMigrate( err = db.AutoMigrate(
&Event{}, &Delivery{}, &DeliveryResult{}, &Event{}, &Delivery{}, &DeliveryResult{},
&EventTotals{}, &TargetTotals{},
) )
if err != nil { if err != nil {
_ = sqlDB.Close() _ = sqlDB.Close()
@@ -305,6 +307,18 @@ func (m *WebhookDBManager) openDB(
) )
} }
// A new database gets its row of event totals, all zero. Target
// totals rows are created by the first delivery to each target.
err = db.FirstOrCreate(&EventTotals{}).Error
if err != nil {
_ = sqlDB.Close()
return nil, fmt.Errorf(
"creating event totals for webhook database %s: %w",
webhookID, err,
)
}
m.log.Info( m.log.Info(
"opened per-webhook database", "opened per-webhook database",
"webhook_id", webhookID, "webhook_id", webhookID,
+14 -12
View File
@@ -8,6 +8,7 @@ import (
"time" "time"
"go.uber.org/fx" "go.uber.org/fx"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/lifecycle" "sneak.berlin/go/webhooker/internal/lifecycle"
@@ -25,14 +26,14 @@ type ArchiveSweeperParams struct {
Logger *logger.Logger Logger *logger.Logger
} }
// ArchiveSweeper periodically prunes expired rows from // ArchiveSweeper periodically prunes expired rows from the
// per-webhook archive databases whose database target carries a // archive databases of database targets that carry a positive
// positive expiry. // expiry.
// //
// Without it, pruning happens only when an archive is // Without it, pruning happens only when an archive is
// (re)opened, and archives are only ever reopened by writes: an // (re)opened, and archives are only ever reopened by writes: an
// archive belonging to a webhook that has stopped receiving // archive whose target has stopped receiving events would keep
// events would keep its expired rows forever. The sweep closes // its expired rows forever. The sweep closes
// that gap without changing anything for archives whose expiry // that gap without changing anything for archives whose expiry
// is unset or "never". // is unset or "never".
// //
@@ -155,7 +156,7 @@ func (s *ArchiveSweeper) run(ctx context.Context) {
// soft-deleted along with it, so GORM's default scope already // soft-deleted along with it, so GORM's default scope already
// excludes them. // excludes them.
// //
// A failure for one webhook is logged and the sweep continues, // A failure for one target is logged and the sweep continues,
// matching how the write path already treats a prune error as // matching how the write path already treats a prune error as
// non-fatal. // non-fatal.
func (s *ArchiveSweeper) sweep(ctx context.Context) { func (s *ArchiveSweeper) sweep(ctx context.Context) {
@@ -210,19 +211,20 @@ func (s *ArchiveSweeper) sweepTarget(target *database.Target) {
return return
} }
err = s.eng.dbTarget.sweepWebhook(target.WebhookID, expiry) err = s.eng.dbTarget.sweepArchive(target.ID, expiry)
if err == nil { if err == nil {
return return
} }
// A writer evicted underneath the sweep means the operator // A writer evicted, or a target row gone, underneath the sweep
// deleted the webhook (or its last database target) while the // means the operator deleted the target or its webhook while
// sweep was walking the target list. That is an ordinary // the sweep was walking the target list. That is an ordinary
// interleaving, not a failure, so it must not produce an // interleaving, not a failure, so it must not produce an
// error line. // error line.
if errors.Is(err, errArchiveWriterEvicted) { if errors.Is(err, errArchiveWriterEvicted) ||
errors.Is(err, gorm.ErrRecordNotFound) {
s.log.Debug( s.log.Debug(
"archive sweep: writer evicted mid-sweep", "archive sweep: target deleted mid-sweep",
"webhook_id", target.WebhookID, "webhook_id", target.WebhookID,
"target_id", target.ID, "target_id", target.ID,
) )
+143 -133
View File
@@ -34,18 +34,23 @@ const (
sweepConcurrentWrites = 20 sweepConcurrentWrites = 20
) )
// sweeperEnv bundles the pieces an archive sweep test drives: // archiveTestWebhookName is the name of every webhook
// a main configuration database holding webhooks and targets, a // seedDatabaseTarget creates. It is not safe in a file name as it
// delivery engine owning the archive writer registry, and the // stands, so every archive test goes through archiveNamePart.
// data directory the archive files live in. const archiveTestWebhookName = "Sweep Test!"
type sweeperEnv struct {
// archiveEnv bundles the pieces an archive test drives: a main
// configuration database holding webhooks and targets, a delivery
// engine owning the archive writer registry, the archive sweeper,
// and the data directory the archive files live in.
type archiveEnv struct {
sweeper *delivery.ArchiveSweeper sweeper *delivery.ArchiveSweeper
eng *delivery.Engine eng *delivery.Engine
mainDB *database.Database mainDB *database.Database
dataDir string dataDir string
} }
func setupSweeperTest(t *testing.T) *sweeperEnv { func setupArchiveTest(t *testing.T) *archiveEnv {
t.Helper() t.Helper()
dataDir := t.TempDir() dataDir := t.TempDir()
@@ -78,7 +83,7 @@ func setupSweeperTest(t *testing.T) *sweeperEnv {
1, 1,
) )
return &sweeperEnv{ return &archiveEnv{
sweeper: delivery.NewTestArchiveSweeper( sweeper: delivery.NewTestArchiveSweeper(
mainDB, eng, log, mainDB, eng, log,
), ),
@@ -88,25 +93,27 @@ func setupSweeperTest(t *testing.T) *sweeperEnv {
} }
} }
// archivePath returns where the engine keeps a webhook's // archivePath returns where the engine keeps a database target's
// archive file. // archive file, for the names seedDatabaseTarget gave it.
func (env *sweeperEnv) archivePath(webhookID string) string { func (env *archiveEnv) archivePath(tgt *database.Target) string {
return filepath.Join( return filepath.Join(
env.dataDir, fmt.Sprintf("archive-%s.db", webhookID), env.dataDir,
delivery.ArchiveFileName(
archiveTestWebhookName, tgt.Name, tgt.ID,
),
) )
} }
// seedDatabaseTarget creates a webhook with one database target // seedDatabaseTarget creates a webhook with one database target
// carrying the given target config JSON, and returns the // carrying the given target config JSON, and returns the target.
// webhook id. func (env *archiveEnv) seedDatabaseTarget(
func (env *sweeperEnv) seedDatabaseTarget(
t *testing.T, configJSON string, t *testing.T, configJSON string,
) string { ) *database.Target {
t.Helper() t.Helper()
wh := &database.Webhook{ wh := &database.Webhook{
UserID: uuid.New().String(), UserID: uuid.New().String(),
Name: "sweep-test", Name: archiveTestWebhookName,
} }
require.NoError( require.NoError(
t, t,
@@ -115,9 +122,19 @@ func (env *sweeperEnv) seedDatabaseTarget(
Create(wh).Error, Create(wh).Error,
) )
return env.addDatabaseTarget(t, wh.ID, configJSON)
}
// addDatabaseTarget creates one more database target on an
// existing webhook and returns it.
func (env *archiveEnv) addDatabaseTarget(
t *testing.T, webhookID, configJSON string,
) *database.Target {
t.Helper()
tgt := &database.Target{ tgt := &database.Target{
WebhookID: wh.ID, WebhookID: webhookID,
Name: "archive", Name: "Archive",
Type: database.TargetTypeDatabase, Type: database.TargetTypeDatabase,
Active: true, Active: true,
Config: configJSON, Config: configJSON,
@@ -129,19 +146,19 @@ func (env *sweeperEnv) seedDatabaseTarget(
Create(tgt).Error, Create(tgt).Error,
) )
return wh.ID return tgt
} }
// seedArchiveRows creates the archive file for a webhook and // seedArchiveRows creates the archive file for a target and
// inserts one row per supplied archived-at timestamp, returning // inserts one row per supplied archived-at timestamp, returning
// the archive path. The handle is closed before returning, so // the archive path. The handle is closed before returning, so
// the archive is idle exactly as it would be with no traffic. // the archive is idle exactly as it would be with no traffic.
func (env *sweeperEnv) seedArchiveRows( func (env *archiveEnv) seedArchiveRows(
t *testing.T, webhookID string, archivedAt ...time.Time, t *testing.T, tgt *database.Target, archivedAt ...time.Time,
) string { ) string {
t.Helper() t.Helper()
path := env.archivePath(webhookID) path := env.archivePath(tgt)
sqlDB, err := sql.Open( sqlDB, err := sql.Open(
"sqlite", fmt.Sprintf("file:%s?mode=rwc", path), "sqlite", fmt.Sprintf("file:%s?mode=rwc", path),
@@ -160,7 +177,7 @@ func (env *sweeperEnv) seedArchiveRows(
for i, at := range archivedAt { for i, at := range archivedAt {
row := delivery.ExportArchivedEvent{ row := delivery.ExportArchivedEvent{
EventID: fmt.Sprintf("ev-%d", i), EventID: fmt.Sprintf("ev-%d", i),
WebhookID: webhookID, WebhookID: tgt.WebhookID,
Method: http.MethodPost, Method: http.MethodPost,
Body: `{"seeded":true}`, Body: `{"seeded":true}`,
ArchivedAt: at, ArchivedAt: at,
@@ -243,13 +260,13 @@ func TestArchiveSweeper_LoopOutlivesStartHookContext(
) { ) {
t.Parallel() t.Parallel()
env := setupSweeperTest(t) env := setupArchiveTest(t)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
now := time.Now() now := time.Now()
path := env.seedArchiveRows( path := env.seedArchiveRows(
t, webhookID, t, tgt,
now.Add(-48*time.Hour), now.Add(-48*time.Hour),
now.Add(-time.Minute), now.Add(-time.Minute),
) )
@@ -287,60 +304,60 @@ func TestArchiveSweeper_LoopOutlivesStartHookContext(
} }
// TestArchiveSweep_DoesNotResurrectEvictedWriter covers the // TestArchiveSweep_DoesNotResurrectEvictedWriter covers the
// interleaving where a sweep tick has already listed a webhook's // interleaving where a sweep tick has already listed a target
// target when the webhook is deleted and its writer evicted. The // when the target is deleted and its writer evicted. The sweep
// sweep must not put a writer back into the registry: nothing // must not put a writer back into the registry: nothing would
// would ever evict it again, which is precisely the leak this // ever evict it again, which is precisely the leak this change
// change exists to close. // exists to close.
func TestArchiveSweep_DoesNotResurrectEvictedWriter( func TestArchiveSweep_DoesNotResurrectEvictedWriter(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
env := setupSweeperTest(t) env := setupArchiveTest(t)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows( env.seedArchiveRows(
t, webhookID, time.Now().Add(-48*time.Hour), t, tgt, time.Now().Add(-48*time.Hour),
) )
// Prime the registry the way a delivery would, then evict as // Prime the registry the way a delivery would, then evict as
// the deletion path does. The target row is deliberately left // the deletion path does. The target row is deliberately left
// in place: this is the tick that listed the webhook before // in place: this is the tick that listed the target before
// the deletion committed. // the deletion committed.
_, err := env.eng.ExportEnsureArchiveWriter(webhookID) _, err := env.eng.ExportEnsureArchiveWriter(tgt.ID)
require.NoError(t, err) require.NoError(t, err)
env.eng.EvictWebhook(webhookID) env.eng.EvictTarget(tgt.ID)
require.False(t, env.eng.ExportHasArchiveWriter(webhookID)) require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID))
env.sweeper.ExportSweep(context.Background()) env.sweeper.ExportSweep(context.Background())
assert.False( assert.False(
t, env.eng.ExportHasArchiveWriter(webhookID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"a sweep must never re-register a writer for a webhook "+ "a sweep must never re-register a writer for a target "+
"whose registry entry has already been released", "whose registry entry has already been released",
) )
} }
// TestArchiveSweep_LeavesNoRegistryEntry states the same // TestArchiveSweep_LeavesNoRegistryEntry states the same
// invariant in its general form: sweeping an archive whose // invariant in its general form: sweeping an archive whose
// webhook has no cached writer must not leave one behind, so the // target has no cached writer must not leave one behind, so the
// registry keeps holding only writers a delivery created and an // registry keeps holding only writers a delivery created and an
// eviction can reach. // eviction can reach.
func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) { func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) {
t.Parallel() t.Parallel()
env := setupSweeperTest(t) env := setupArchiveTest(t)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.seedArchiveRows( path := env.seedArchiveRows(
t, webhookID, t, tgt,
time.Now().Add(-48*time.Hour), time.Now().Add(-48*time.Hour),
time.Now().Add(-time.Minute), time.Now().Add(-time.Minute),
) )
require.False(t, env.eng.ExportHasArchiveWriter(webhookID)) require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID))
env.sweeper.ExportSweep(context.Background()) env.sweeper.ExportSweep(context.Background())
@@ -349,7 +366,7 @@ func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) {
"the sweep must still prune an idle archive", "the sweep must still prune an idle archive",
) )
assert.False( assert.False(
t, env.eng.ExportHasArchiveWriter(webhookID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"the sweep must release the registry entry it created", "the sweep must release the registry entry it created",
) )
} }
@@ -364,34 +381,31 @@ func TestArchiveSweep_KeepsWriterAdoptedByDelivery(
) { ) {
t.Parallel() t.Parallel()
env := setupSweeperTest(t) env := setupArchiveTest(t)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows( env.seedArchiveRows(
t, webhookID, time.Now().Add(-48*time.Hour), t, tgt, time.Now().Add(-48*time.Hour),
) )
webhookDB := testWebhookDB(t) webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`) event := seedEvent(t, webhookDB, `{"n":1}`)
event.WebhookID = webhookID d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
d := seedDatabaseTargetDelivery(
t, webhookDB, event, `{"expiry":"1h"}`,
)
env.sweeper.ExportSweep(context.Background()) env.sweeper.ExportSweep(context.Background())
require.False(t, env.eng.ExportHasArchiveWriter(webhookID)) require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID))
env.eng.ExportDeliverDatabase(webhookDB, d) env.eng.ExportDeliverDatabase(webhookDB, d)
assert.True( assert.True(
t, env.eng.ExportHasArchiveWriter(webhookID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"a delivery's writer must stay registered", "a delivery's writer must stay registered",
) )
env.sweeper.ExportSweep(context.Background()) env.sweeper.ExportSweep(context.Background())
assert.True( assert.True(
t, env.eng.ExportHasArchiveWriter(webhookID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"a sweep must not drop a writer a delivery owns", "a sweep must not drop a writer a delivery owns",
) )
} }
@@ -423,15 +437,15 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
) { ) {
t.Parallel() t.Parallel()
env := setupSweeperTest(t) env := setupArchiveTest(t)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows( env.seedArchiveRows(
t, webhookID, time.Now().Add(-48*time.Hour), t, tgt, time.Now().Add(-48*time.Hour),
) )
sweepWriter, created, err := env.eng.ExportSweepWriterFor( sweepWriter, created, err := env.eng.ExportSweepWriterFor(
webhookID, tgt.ID,
) )
require.NoError(t, err) require.NoError(t, err)
require.True( require.True(
@@ -442,37 +456,34 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
// The delivery lands mid-sweep and adopts the entry. // The delivery lands mid-sweep and adopts the entry.
webhookDB := testWebhookDB(t) webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`) event := seedEvent(t, webhookDB, `{"n":1}`)
event.WebhookID = webhookID d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
d := seedDatabaseTargetDelivery(
t, webhookDB, event, `{"expiry":"1h"}`,
)
env.eng.ExportDeliverDatabase(webhookDB, d) env.eng.ExportDeliverDatabase(webhookDB, d)
adopted := env.eng.ExportArchiveWriterFor(webhookID) adopted := env.eng.ExportArchiveWriterFor(tgt.ID)
require.NotNil(t, adopted) require.NotNil(t, adopted)
require.True( require.True(
t, sweepWriter.Same(adopted), t, sweepWriter.Same(adopted),
"the delivery must have adopted the sweep's writer", "the delivery must have adopted the sweep's writer",
) )
require.True( require.True(
t, env.eng.ExportArchiveHandleOpen(webhookID), t, env.eng.ExportArchiveHandleOpen(tgt.ID),
"the delivery leaves the archive handle open", "the delivery leaves the archive handle open",
) )
// The sweep finishes. // The sweep finishes.
env.eng.ExportReleaseSweepWriter(webhookID, sweepWriter) env.eng.ExportReleaseSweepWriter(tgt.ID, sweepWriter)
require.True( require.True(
t, env.eng.ExportHasArchiveWriter(webhookID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"a writer adopted by a delivery during a sweep must "+ "a writer adopted by a delivery during a sweep must "+
"stay registered, or its open handle is unreachable", "stay registered, or its open handle is unreachable",
) )
env.eng.EvictWebhook(webhookID) env.eng.EvictTarget(tgt.ID)
assert.False( assert.False(
t, env.eng.ExportHasArchiveWriter(webhookID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"the adopted writer must still be evictable", "the adopted writer must still be evictable",
) )
assert.False( assert.False(
@@ -481,34 +492,34 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
) )
} }
// TestArchiveSweep_ContinuesAfterPerWebhookFailure proves a // TestArchiveSweep_ContinuesAfterPerTargetFailure proves a
// failure for one webhook does not abort the sweep for the // failure for one target does not abort the sweep for the
// others: an unparseable expiry and an unreadable archive both // others: an unparseable expiry and an unreadable archive both
// have to be logged and stepped over. // have to be logged and stepped over.
func TestArchiveSweep_ContinuesAfterPerWebhookFailure( func TestArchiveSweep_ContinuesAfterPerTargetFailure(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
env := setupSweeperTest(t) env := setupArchiveTest(t)
// Seeded first so the sweep reaches them before the healthy // Seeded first so the sweep reaches them before the healthy
// webhook: targets come back in insertion order. // target: targets come back in insertion order.
badConfigID := env.seedDatabaseTarget(t, `{"expiry":"!!!"}`) badConfig := env.seedDatabaseTarget(t, `{"expiry":"!!!"}`)
env.seedArchiveRows( env.seedArchiveRows(
t, badConfigID, time.Now().Add(-48*time.Hour), t, badConfig, time.Now().Add(-48*time.Hour),
) )
corruptID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) corrupt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
require.NoError(t, os.WriteFile( require.NoError(t, os.WriteFile(
env.archivePath(corruptID), env.archivePath(corrupt),
[]byte("this is not a sqlite database"), []byte("this is not a sqlite database"),
0o600, 0o600,
)) ))
healthyID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) healthy := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
healthyPath := env.seedArchiveRows( healthyPath := env.seedArchiveRows(
t, healthyID, t, healthy,
time.Now().Add(-48*time.Hour), time.Now().Add(-48*time.Hour),
time.Now().Add(-time.Minute), time.Now().Add(-time.Minute),
) )
@@ -518,14 +529,14 @@ func TestArchiveSweep_ContinuesAfterPerWebhookFailure(
assert.Equal( assert.Equal(
t, []string{sweepRowNew}, t, []string{sweepRowNew},
archivedEventIDs(t, healthyPath), archivedEventIDs(t, healthyPath),
"a failure for an earlier webhook must not stop the "+ "a failure for an earlier target must not stop the "+
"sweep from pruning the ones after it", "sweep from pruning the ones after it",
) )
} }
// TestArchiveSweep_OpenExistingDoesNotCreateFile pins the second // TestArchiveSweep_OpenExistingDoesNotCreateFile pins the second
// of the two no-create guards. The first is the stat in // of the two no-create guards. The first is the stat in
// sweepWebhook; this one is the SQLite open mode, which is what // sweepExpired; this one is the SQLite open mode, which is what
// protects the window between that stat and the open. Flipping // protects the window between that stat and the open. Flipping
// the sweep's mode to create-if-missing makes this fail. // the sweep's mode to create-if-missing makes this fail.
func TestArchiveSweep_OpenExistingDoesNotCreateFile( func TestArchiveSweep_OpenExistingDoesNotCreateFile(
@@ -561,13 +572,13 @@ func TestArchiveSweep_OpenExistingDoesNotCreateFile(
func TestArchiveSweep_PrunesIdleArchive(t *testing.T) { func TestArchiveSweep_PrunesIdleArchive(t *testing.T) {
t.Parallel() t.Parallel()
env := setupSweeperTest(t) env := setupArchiveTest(t)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
now := time.Now() now := time.Now()
path := env.seedArchiveRows( path := env.seedArchiveRows(
t, webhookID, t, tgt,
now.Add(-48*time.Hour), now.Add(-48*time.Hour),
now.Add(-time.Minute), now.Add(-time.Minute),
) )
@@ -600,11 +611,11 @@ func TestArchiveSweep_PrunesIdleArchive(t *testing.T) {
func TestArchiveSweep_LeavesArchiveClosed(t *testing.T) { func TestArchiveSweep_LeavesArchiveClosed(t *testing.T) {
t.Parallel() t.Parallel()
env := setupSweeperTest(t) env := setupArchiveTest(t)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.seedArchiveRows( path := env.seedArchiveRows(
t, webhookID, time.Now().Add(-48*time.Hour), t, tgt, time.Now().Add(-48*time.Hour),
) )
w := delivery.NewExportArchiveWriter( w := delivery.NewExportArchiveWriter(
@@ -640,35 +651,32 @@ func TestArchiveSweep_ClosesHandleOfRegisteredWriter(
) { ) {
t.Parallel() t.Parallel()
env := setupSweeperTest(t) env := setupArchiveTest(t)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows( env.seedArchiveRows(
t, webhookID, time.Now().Add(-48*time.Hour), t, tgt, time.Now().Add(-48*time.Hour),
) )
webhookDB := testWebhookDB(t) webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`) event := seedEvent(t, webhookDB, `{"n":1}`)
event.WebhookID = webhookID d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
d := seedDatabaseTargetDelivery(
t, webhookDB, event, `{"expiry":"1h"}`,
)
env.eng.ExportDeliverDatabase(webhookDB, d) env.eng.ExportDeliverDatabase(webhookDB, d)
require.True( require.True(
t, env.eng.ExportArchiveHandleOpen(webhookID), t, env.eng.ExportArchiveHandleOpen(tgt.ID),
"the delivery must leave the archive handle open", "the delivery must leave the archive handle open",
) )
env.sweeper.ExportSweep(context.Background()) env.sweeper.ExportSweep(context.Background())
require.True( require.True(
t, env.eng.ExportHasArchiveWriter(webhookID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"the delivery's registry entry must survive the sweep", "the delivery's registry entry must survive the sweep",
) )
assert.False( assert.False(
t, env.eng.ExportArchiveHandleOpen(webhookID), t, env.eng.ExportArchiveHandleOpen(tgt.ID),
"the sweep must leave the archive closed", "the sweep must leave the archive closed",
) )
} }
@@ -684,11 +692,11 @@ func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) {
`{"expiry":""}`, `{"expiry":""}`,
"", "",
} { } {
env := setupSweeperTest(t) env := setupArchiveTest(t)
webhookID := env.seedDatabaseTarget(t, configJSON) tgt := env.seedDatabaseTarget(t, configJSON)
path := env.seedArchiveRows( path := env.seedArchiveRows(
t, webhookID, t, tgt,
time.Now().Add(-10000*time.Hour), time.Now().Add(-10000*time.Hour),
) )
@@ -699,7 +707,7 @@ func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) {
"config %q must keep rows forever", configJSON, "config %q must keep rows forever", configJSON,
) )
assert.False( assert.False(
t, env.eng.ExportHasArchiveWriter(webhookID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"config %q must leave no registry entry behind", "config %q must leave no registry entry behind",
configJSON, configJSON,
) )
@@ -722,10 +730,10 @@ func TestArchiveSweep_NeverExpirySkipsBeforeOpening(
) { ) {
t.Parallel() t.Parallel()
env := setupSweeperTest(t) env := setupArchiveTest(t)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"never"}`) tgt := env.seedDatabaseTarget(t, `{"expiry":"never"}`)
path := env.archivePath(webhookID) path := env.archivePath(tgt)
seedUnmigratedArchive(t, path) seedUnmigratedArchive(t, path)
require.False(t, archiveTableExists(t, path)) require.False(t, archiveTableExists(t, path))
@@ -768,16 +776,16 @@ func archiveTableExists(t *testing.T, path string) bool {
} }
// TestArchiveSweep_DoesNotCreateArchiveFile proves the sweep // TestArchiveSweep_DoesNotCreateArchiveFile proves the sweep
// never conjures an archive: a webhook with a database target // never conjures an archive: a database target that has never
// that has never received an event must still have no archive // received an event must still have no archive file (nor SQLite
// file (nor SQLite sidecar) after a sweep. // sidecar) after a sweep, and no registry entry either.
func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) { func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) {
t.Parallel() t.Parallel()
env := setupSweeperTest(t) env := setupArchiveTest(t)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.archivePath(webhookID) path := env.archivePath(tgt)
require.NoFileExists(t, path) require.NoFileExists(t, path)
@@ -789,6 +797,11 @@ func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) {
"the sweep must not create an archive file", "the sweep must not create an archive file",
) )
} }
assert.False(
t, env.eng.ExportHasArchiveWriter(tgt.ID),
"the sweep must leave no registry entry behind",
)
} }
// TestArchiveSweep_DoesNotCreateAfterWriterExists covers the // TestArchiveSweep_DoesNotCreateAfterWriterExists covers the
@@ -800,11 +813,11 @@ func TestArchiveSweep_DoesNotCreateAfterWriterExists(
) { ) {
t.Parallel() t.Parallel()
env := setupSweeperTest(t) env := setupArchiveTest(t)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path, err := env.eng.ExportEnsureArchiveWriter(webhookID) path, err := env.eng.ExportEnsureArchiveWriter(tgt.ID)
require.NoError(t, err) require.NoError(t, err)
require.NoFileExists(t, path) require.NoFileExists(t, path)
@@ -819,17 +832,17 @@ func TestArchiveSweep_DoesNotCreateAfterWriterExists(
func TestArchiveSweep_SkipsDeletedWebhookTargets(t *testing.T) { func TestArchiveSweep_SkipsDeletedWebhookTargets(t *testing.T) {
t.Parallel() t.Parallel()
env := setupSweeperTest(t) env := setupArchiveTest(t)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.seedArchiveRows( path := env.seedArchiveRows(
t, webhookID, time.Now().Add(-48*time.Hour), t, tgt, time.Now().Add(-48*time.Hour),
) )
require.NoError( require.NoError(
t, t,
env.mainDB.DB(). env.mainDB.DB().
Where("webhook_id = ?", webhookID). Where("webhook_id = ?", tgt.WebhookID).
Delete(&database.Target{}).Error, Delete(&database.Target{}).Error,
) )
@@ -842,14 +855,14 @@ func TestArchiveSweep_SkipsDeletedWebhookTargets(t *testing.T) {
} }
// TestArchiveSweep_ConcurrentWrites proves the sweep serialises // TestArchiveSweep_ConcurrentWrites proves the sweep serialises
// against writes through the per-webhook writer mutex. Run // against writes through the target's writer mutex. Run under
// under -race, an unsynchronised sweep would be caught here. // -race, an unsynchronised sweep would be caught here.
func TestArchiveSweep_ConcurrentWrites(t *testing.T) { func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
t.Parallel() t.Parallel()
env := setupSweeperTest(t) env := setupArchiveTest(t)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookDB := testWebhookDB(t) webhookDB := testWebhookDB(t)
@@ -862,13 +875,10 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
for range sweepConcurrentWrites { for range sweepConcurrentWrites {
event := seedEvent(t, webhookDB, `{"n":1}`) event := seedEvent(t, webhookDB, `{"n":1}`)
event.WebhookID = webhookID
deliveries = append( deliveries = append(
deliveries, deliveries,
seedDatabaseTargetDelivery( seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
t, webhookDB, event, `{"expiry":"1h"}`,
),
) )
} }
@@ -894,7 +904,7 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
wg.Wait() wg.Wait()
assert.FileExists(t, env.archivePath(webhookID)) assert.FileExists(t, env.archivePath(tgt))
} }
// TestArchiveSweeper_StopsCleanly proves the background loop // TestArchiveSweeper_StopsCleanly proves the background loop
@@ -902,11 +912,11 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
func TestArchiveSweeper_StopsCleanly(t *testing.T) { func TestArchiveSweeper_StopsCleanly(t *testing.T) {
t.Parallel() t.Parallel()
env := setupSweeperTest(t) env := setupArchiveTest(t)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows( env.seedArchiveRows(
t, webhookID, time.Now().Add(-48*time.Hour), t, tgt, time.Now().Add(-48*time.Hour),
) )
env.sweeper.ExportSetInterval(time.Millisecond) env.sweeper.ExportSetInterval(time.Millisecond)
@@ -930,7 +940,7 @@ func TestArchiveSweeper_StopHookHonoursStopTimeout(
) { ) {
t.Parallel() t.Parallel()
env := setupSweeperTest(t) env := setupArchiveTest(t)
lc := &recordingLifecycle{} lc := &recordingLifecycle{}
env.sweeper.ExportRegisterHooks(lc) env.sweeper.ExportRegisterHooks(lc)
+177
View File
@@ -0,0 +1,177 @@
package delivery_test
import (
"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"
)
// targetTotals reads one target's totals from a webhook database, all
// zero when it has no row.
func targetTotals(
t *testing.T, db *gorm.DB, targetID string,
) database.TargetTotals {
t.Helper()
var rows []database.TargetTotals
require.NoError(t, db.Where("target_id = ?", targetID).
Find(&rows).Error)
if len(rows) == 0 {
return database.TargetTotals{TargetID: targetID}
}
return rows[0]
}
// TestUpdateDeliveryStatus_FinishTimeAndTargetTotals pins what a status
// write records for the webhook page's statistics: the time a delivery
// finished, set only when it becomes delivered or failed, and one more
// on its target's delivered or failed total.
func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) {
t.Parallel()
tests := []struct {
status database.DeliveryStatus
finished bool
delivered int64
failed int64
}{
{database.DeliveryStatusRetrying, false, 0, 0},
{database.DeliveryStatusDelivered, true, 1, 0},
{database.DeliveryStatusFailed, true, 0, 1},
}
for _, tt := range tests {
t.Run(string(tt.status), func(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e := testEngine(t, 1)
event := seedEvent(t, db, `{}`)
targetID := uuid.New().String()
d := seedDelivery(
t, db, event.ID, targetID,
database.DeliveryStatusPending,
)
before := time.Now()
require.NoError(t, e.ExportUpdateDeliveryStatus(
db, &d, tt.status,
))
var stored database.Delivery
require.NoError(t, db.First(&stored, "id = ?", d.ID).Error)
assert.Equal(t, tt.status, stored.Status)
if tt.finished {
require.NotNil(t, stored.FinishedAt)
assert.False(t, stored.FinishedAt.Before(before))
} else {
assert.Nil(t, stored.FinishedAt)
}
assert.Equal(t, database.TargetTotals{
TargetID: targetID,
Delivered: tt.delivered,
Failed: tt.failed,
}, targetTotals(t, db, targetID))
})
}
}
// TestUpdateDeliveryStatus_DeletedDeliveryIsNotCounted covers a
// delivery retention deleted while the engine still held it. Failing
// it afterwards writes no row, so it adds no failure either: retention
// has already counted what it removed.
func TestUpdateDeliveryStatus_DeletedDeliveryIsNotCounted(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e := testEngine(t, 1)
event := seedEvent(t, db, `{}`)
targetID := uuid.New().String()
d := seedDelivery(
t, db, event.ID, targetID,
database.DeliveryStatusRetrying,
)
require.NoError(t, db.Unscoped().
Delete(&database.Delivery{}, "id = ?", d.ID).Error)
require.NoError(t, e.ExportUpdateDeliveryStatus(
db, &d, database.DeliveryStatusFailed,
))
assert.Equal(t, database.TargetTotals{TargetID: targetID},
targetTotals(t, db, targetID))
}
// TestUpdateDeliveryStatus_FinishedDeliveryIsNotSettledAgain covers a
// delivery settled a second time, as recovery can do when a worker has
// settled it since recovery read it. Neither status writes over the
// first, and the totals do not move.
func TestUpdateDeliveryStatus_FinishedDeliveryIsNotSettledAgain(
t *testing.T,
) {
t.Parallel()
finished := []database.DeliveryStatus{
database.DeliveryStatusDelivered,
database.DeliveryStatusFailed,
}
for _, first := range finished {
t.Run(string(first), func(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e := testEngine(t, 1)
event := seedEvent(t, db, `{}`)
targetID := uuid.New().String()
d := seedDelivery(
t, db, event.ID, targetID,
database.DeliveryStatusRetrying,
)
// The delivery as recovery read it, before the worker
// settled it.
readBefore := d
require.NoError(t, e.ExportUpdateDeliveryStatus(
db, &d, first,
))
var settled database.Delivery
require.NoError(t, db.First(&settled, "id = ?", d.ID).Error)
require.NotNil(t, settled.FinishedAt)
totals := targetTotals(t, db, targetID)
for _, again := range finished {
stale := readBefore
require.NoError(t, e.ExportUpdateDeliveryStatus(
db, &stale, again,
))
}
var stored database.Delivery
require.NoError(t, db.First(&stored, "id = ?", d.ID).Error)
assert.Equal(t, first, stored.Status)
require.NotNil(t, stored.FinishedAt)
assert.True(t, settled.FinishedAt.Equal(*stored.FinishedAt))
assert.Equal(t, totals, targetTotals(t, db, targetID))
})
}
}
+116 -29
View File
@@ -14,6 +14,7 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"gorm.io/gorm" "gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/lifecycle" "sneak.berlin/go/webhooker/internal/lifecycle"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/metrics" "sneak.berlin/go/webhooker/internal/metrics"
@@ -122,21 +123,24 @@ type Notifier interface {
Notify(tasks []Task) Notify(tasks []Task)
} }
// WebhookEvictor releases the delivery engine's per-webhook // Archives is how the handlers keep the database targets' archive
// state for a webhook that no longer needs it — currently the // files in step with the configuration. Deleting a webhook or a
// cached archive writer of the database target, whose open // target releases the cached archive writers, whose open file
// file handle would otherwise outlive the webhook. // handles would otherwise outlive them; renaming one renames the
// archive files, which are named for the webhook and the target
// (see ArchiveFileName).
// //
// It is deliberately separate from Notifier and deliberately // It is deliberately separate from Notifier: archiving lifecycle
// one method wide: archiving lifecycle is not notification, and // is not notification, and a small interface keeps the handlers
// a single-method interface keeps the handlers package free of // package free of any dependency on the engine's internals while
// any dependency on the engine's internals while staying // staying trivially fakeable in tests.
// trivially fakeable in tests.
// //
// EvictWebhook never deletes an archive file. It is idempotent // Neither eviction deletes an archive file. Both are idempotent
// and is a no-op for a webhook with no engine state. // and are no-ops for a webhook or target with no engine state.
type WebhookEvictor interface { type Archives interface {
EvictWebhook(webhookID string) EvictWebhook(webhookID string)
EvictTarget(targetID string)
Rename(targetID, webhookName, targetName string) error
} }
// EngineParams are the fx dependencies for the delivery // EngineParams are the fx dependencies for the delivery
@@ -146,8 +150,10 @@ type EngineParams struct {
DB *database.Database DB *database.Database
DBManager *database.WebhookDBManager DBManager *database.WebhookDBManager
Globals *globals.Globals
Logger *logger.Logger Logger *logger.Logger
SSRFGuard *Guard SSRFGuard *Guard
Metrics *metrics.Set
} }
// Engine processes queued deliveries in the background // Engine processes queued deliveries in the background
@@ -167,10 +173,14 @@ type Engine struct {
retryCh chan Task retryCh chan Task
workers int workers int
// mtr is the delivery metric set. Production wires the // version is the running build's version, the one the web UI
// process-wide one; a test can substitute a set registered on // footer shows. userAgent puts it on every outbound request.
// a private registry so its assertions are not disturbed by version string
// deliveries other tests are making at the same time.
// mtr is the delivery metric set. Production wires the one
// registered on the registry /metrics serves; a test can
// substitute a set registered on a registry it holds, so it can
// gather what its own deliveries recorded.
mtr *metrics.Set mtr *metrics.Set
// targets maps each target type to its implementation. // targets maps each target type to its implementation.
@@ -181,7 +191,7 @@ type Engine struct {
httpTarget *httpTarget httpTarget *httpTarget
// dbTarget is retained so the engine can reach the archive // dbTarget is retained so the engine can reach the archive
// writer registry for webhook eviction and the idle sweep. // writer registry for eviction, renames and the idle sweep.
dbTarget *databaseTarget dbTarget *databaseTarget
// inflight is the set of deliveries this engine currently owns. // inflight is the set of deliveries this engine currently owns.
@@ -204,7 +214,8 @@ func New(
deliveryCh: make(chan Task, deliveryChannelSize), deliveryCh: make(chan Task, deliveryChannelSize),
retryCh: make(chan Task, retryChannelSize), retryCh: make(chan Task, retryChannelSize),
workers: defaultWorkers, workers: defaultWorkers,
mtr: metrics.Default(), version: params.Globals.Version,
mtr: params.Metrics,
} }
e.initTargets(&http.Client{ e.initTargets(&http.Client{
@@ -249,17 +260,44 @@ func (e *Engine) Notify(tasks []Task) {
} }
} }
// EvictWebhook implements WebhookEvictor. It releases the // EvictWebhook implements Archives. The cached archive writer of
// engine's per-webhook archiving state: the database target's // every database target of the webhook is dropped from the
// cached archive writer is dropped from the registry and its // registry and its file handle closed. The archive files
// file handle closed. The archive file itself is left on disk // themselves are left on disk — they are long-term storage the
// — it is long-term storage the operator owns. // operator owns.
func (e *Engine) EvictWebhook(webhookID string) { func (e *Engine) EvictWebhook(webhookID string) {
if e.dbTarget == nil { if e.dbTarget == nil {
return return
} }
e.dbTarget.evict(webhookID) e.dbTarget.evictWebhook(webhookID)
}
// EvictTarget implements Archives. It is EvictWebhook for a single
// database target, and leaves the archive file on disk the same
// way.
func (e *Engine) EvictTarget(targetID string) {
if e.dbTarget == nil {
return
}
e.dbTarget.evict(targetID)
}
// Rename implements Archives. It renames a database target's
// archive file to ArchiveFileName(webhookName, targetName,
// targetID), under the lock the target's archive writes and the
// idle sweep take. It never replaces a file: if one already has the
// new name, the error is ErrArchiveNameTaken. The caller renames
// before it saves the new name: see databaseTarget.rename.
func (e *Engine) Rename(
targetID, webhookName, targetName string,
) error {
if e.dbTarget == nil {
return nil
}
return e.dbTarget.rename(targetID, webhookName, targetName)
} }
// ScheduleRetry schedules a task to be re-enqueued onto the // ScheduleRetry schedules a task to be re-enqueued onto the
@@ -300,6 +338,13 @@ func (e *Engine) ScheduleRetry(
}) })
} }
// userAgent is the User-Agent header of every http and slack
// delivery request: the program name and the running build's
// version.
func (e *Engine) userAgent() string {
return "webhooker/" + e.version
}
// registerHooks wires the engine's start and stop into the fx // registerHooks wires the engine's start and stop into the fx
// lifecycle. The start hook's context is deliberately ignored // lifecycle. The start hook's context is deliberately ignored
// (see start for why the worker pool must not inherit it); the // (see start for why the worker pool must not inherit it); the
@@ -366,7 +411,8 @@ func (e *Engine) start() {
// Once the pool has drained it closes the archive writers, so a // Once the pool has drained it closes the archive writers, so a
// clean stop leaves no archive -wal behind. Nothing else holds a // clean stop leaves no archive -wal behind. Nothing else holds a
// writer for long by then: the archive sweeper stops before the // writer for long by then: the archive sweeper stops before the
// engine, and deleting a webhook only closes one. If the pool did // engine, and deleting or renaming a webhook or target only closes
// or moves one. If the pool did
// not drain in time, the writers are left open, as a kill would // not drain in time, the writers are left open, as a kill would
// leave them. Closing them would wait for any write in progress, // leave them. Closing them would wait for any write in progress,
// and a worker still running would then open new writers that // and a worker still running would then open new writers that
@@ -531,6 +577,11 @@ func (e *Engine) processRetryTask(
return return
} }
// Set before anything below can fail the delivery: the failure is
// added to this target's totals.
d.EventID = task.EventID
d.TargetID = task.TargetID
if d.Status != database.DeliveryStatusRetrying { if d.Status != database.DeliveryStatusRetrying {
e.log.Debug( e.log.Debug(
"skipping retry for delivery "+ "skipping retry for delivery "+
@@ -562,8 +613,6 @@ func (e *Engine) processRetryTask(
} }
target := buildTargetFromTask(task) target := buildTargetFromTask(task)
d.EventID = task.EventID
d.TargetID = task.TargetID
d.Event = event d.Event = event
d.Target = target d.Target = target
@@ -1554,8 +1603,9 @@ func (e *Engine) updateDeliveryStatus(
targetType database.TargetType, targetType database.TargetType,
status database.DeliveryStatus, status database.DeliveryStatus,
) error { ) error {
err := webhookDB.Model(d). err := webhookDB.Transaction(func(tx *gorm.DB) error {
Update("status", status).Error return writeDeliveryStatus(tx, d, status)
})
if err != nil { if err != nil {
return fmt.Errorf( return fmt.Errorf(
"updating delivery %s to status %s: %w", "updating delivery %s to status %s: %w",
@@ -1574,6 +1624,43 @@ func (e *Engine) updateDeliveryStatus(
return nil return nil
} }
// writeDeliveryStatus writes a delivery's new status. A delivery that
// becomes delivered or failed also gets the time it finished, and is
// added to its target's delivered or failed total. That write changes
// only a delivery not yet delivered or failed, and the total moves
// only when it changed a row: retention may have deleted the delivery
// while the engine was working on it, and a recovery path may settle
// a delivery that a worker has already settled.
func writeDeliveryStatus(
tx *gorm.DB,
d *database.Delivery,
status database.DeliveryStatus,
) error {
if !status.Terminal() {
return tx.Model(d).Update("status", status).Error
}
res := tx.Model(d).
Where("status NOT IN ?", []database.DeliveryStatus{
database.DeliveryStatusDelivered,
database.DeliveryStatusFailed,
}).
Updates(map[string]any{
"status": status,
"finished_at": time.Now(),
})
if res.Error != nil || res.RowsAffected == 0 {
return res.Error
}
add := database.TargetTotals{TargetID: d.TargetID, Delivered: 1}
if status == database.DeliveryStatusFailed {
add = database.TargetTotals{TargetID: d.TargetID, Failed: 1}
}
return database.AddTargetTotals(tx, add)
}
// settleStatus moves a delivery to its outcome status and reports a // settleStatus moves a delivery to its outcome status and reports a
// failed write through bookkeepingFailed, which leaves the row // failed write through bookkeepingFailed, which leaves the row
// recoverable. It exists so the target call sites read as one // recoverable. It exists so the target call sites read as one
+16 -2
View File
@@ -355,9 +355,14 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) {
s := newISetup(t) s := newISetup(t)
var receivedBody string
ts := httptest.NewServer( ts := httptest.NewServer(
http.HandlerFunc( http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) { func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
receivedBody = string(body)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}, },
), ),
@@ -397,6 +402,8 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) {
context.TODO(), &task, context.TODO(), &task,
) )
assert.Equal(t, event.Body, receivedBody)
iAssertStatus(t, s.WebhookDB, d.ID, iAssertStatus(t, s.WebhookDB, d.ID,
database.DeliveryStatusDelivered, database.DeliveryStatusDelivered,
) )
@@ -443,9 +450,14 @@ func TestProcessRetryTask_LargeBody_FetchFromDB(
s := newISetup(t) s := newISetup(t)
var receivedBody string
ts := httptest.NewServer( ts := httptest.NewServer(
http.HandlerFunc( http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) { func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
receivedBody = string(body)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}, },
), ),
@@ -482,6 +494,8 @@ func TestProcessRetryTask_LargeBody_FetchFromDB(
context.TODO(), &task, context.TODO(), &task,
) )
assert.Equal(t, largeBody, receivedBody)
iAssertStatus(t, s.WebhookDB, d.ID, iAssertStatus(t, s.WebhookDB, d.ID,
database.DeliveryStatusDelivered, database.DeliveryStatusDelivered,
) )
+23 -10
View File
@@ -2,7 +2,6 @@ package delivery_test
import ( import (
"context" "context"
"fmt"
"path/filepath" "path/filepath"
"testing" "testing"
"time" "time"
@@ -10,6 +9,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
) )
@@ -272,22 +272,35 @@ func TestEngine_StopHookHonoursStopTimeout(t *testing.T) {
requireStopHookExpires(t, lc.hooks[0], "delivery engine") requireStopHookExpires(t, lc.hooks[0], "delivery engine")
} }
// deliverToArchive runs one delivery to a database target through // deliverToArchive gives the setup's webhook a database target,
// the running engine and returns the webhook's archive file path. // runs one delivery to it through the running engine, and returns
// The archive writer holds the file open afterwards. // the target's ID and archive file path. The archive writer holds
func deliverToArchive(t *testing.T, s iSetup) string { // the file open afterwards.
func deliverToArchive(t *testing.T, s iSetup) (string, string) {
t.Helper() t.Helper()
iCreateWebhook(t, s.MainDB, s.WebhookID, "hook")
tgt := &database.Target{
WebhookID: s.WebhookID,
Name: "archive",
Type: database.TargetTypeDatabase,
}
require.NoError(
t, s.MainDB.Omit(clause.Associations).Create(tgt).Error,
)
deliveryID, task := seedLogTask(t, s) deliveryID, task := seedLogTask(t, s)
task.TargetID = tgt.ID
task.TargetType = database.TargetTypeDatabase task.TargetType = database.TargetTypeDatabase
s.Engine.Notify([]delivery.Task{task}) s.Engine.Notify([]delivery.Task{task})
iWaitForDelivered(t, s.WebhookDB, deliveryID) iWaitForDelivered(t, s.WebhookDB, deliveryID)
return filepath.Join( return tgt.ID, filepath.Join(
filepath.Dir(s.DBMgr.DBPath(s.WebhookID)), filepath.Dir(s.DBMgr.DBPath(s.WebhookID)),
fmt.Sprintf("archive-%s.db", s.WebhookID), "archive-hook-archive-"+tgt.ID+".db",
) )
} }
@@ -304,7 +317,7 @@ func TestEngine_StopHookClosesArchives(t *testing.T) {
lc := startEngineViaHook(t, s.Engine) lc := startEngineViaHook(t, s.Engine)
path := deliverToArchive(t, s) _, path := deliverToArchive(t, s)
require.FileExists( require.FileExists(
t, path+"-wal", t, path+"-wal",
"an open archive should have a -wal for the stop to remove", "an open archive should have a -wal for the stop to remove",
@@ -338,7 +351,7 @@ func TestEngine_StopHookTimeoutLeavesArchivesOpen(t *testing.T) {
lc := startEngineViaHook(t, s.Engine) lc := startEngineViaHook(t, s.Engine)
deliverToArchive(t, s) targetID, _ := deliverToArchive(t, s)
release := make(chan struct{}) release := make(chan struct{})
@@ -352,7 +365,7 @@ func TestEngine_StopHookTimeoutLeavesArchivesOpen(t *testing.T) {
requireStopHookExpires(t, lc.hooks[0], "delivery engine") requireStopHookExpires(t, lc.hooks[0], "delivery engine")
require.True( require.True(
t, s.Engine.ExportArchiveHandleOpen(s.WebhookID), t, s.Engine.ExportArchiveHandleOpen(targetID),
"a stop that timed out must not close archive writers", "a stop that timed out must not close archive writers",
) )
} }
+20 -34
View File
@@ -57,7 +57,10 @@ func testWebhookDB(t *testing.T) *gorm.DB {
&database.Event{}, &database.Event{},
&database.Delivery{}, &database.Delivery{},
&database.DeliveryResult{}, &database.DeliveryResult{},
&database.EventTotals{},
&database.TargetTotals{},
)) ))
require.NoError(t, db.Create(&database.EventTotals{}).Error)
return db return db
} }
@@ -348,23 +351,15 @@ func TestDeliverDatabase_ImmediateSuccess(
db := testWebhookDB(t) db := testWebhookDB(t)
// The database target archives for real now, so the engine // The database target archives for real, so the engine needs
// needs a webhook DB manager to locate the data directory. // the target in the main database and a data directory.
e := delivery.NewTestEngineWithDB( env := setupArchiveTest(t)
nil, tgt := env.seedDatabaseTarget(t, "")
database.NewTestWebhookDBManager(t.TempDir()),
slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
)),
&http.Client{Timeout: 5 * time.Second},
1,
)
event := seedEvent(t, db, `{"db":"target"}`) event := seedEvent(t, db, `{"db":"target"}`)
d := seedDatabaseTargetDelivery(t, db, event, "") d := seedDatabaseTargetDelivery(t, db, event, tgt)
e.ExportDeliverDatabase(db, d) env.eng.ExportDeliverDatabase(db, d)
var updated database.Delivery var updated database.Delivery
@@ -1244,11 +1239,6 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
testContentType, testContentType,
receivedHeaders.Get("Content-Type"), receivedHeaders.Get("Content-Type"),
) )
assert.Equal(t,
"webhooker/1.0",
receivedHeaders.Get("User-Agent"),
)
} }
// The event's stored inbound headers carry the same Content-Type the // The event's stored inbound headers carry the same Content-Type the
@@ -1317,6 +1307,7 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) {
ContentType: tc.event, ContentType: tc.event,
}, },
cfg, cfg,
"webhooker/dev",
) )
assert.Equal(t, assert.Equal(t,
@@ -1333,32 +1324,27 @@ func TestProcessDelivery_RoutesToCorrectHandler(
db := testWebhookDB(t) db := testWebhookDB(t)
// The database target archives for real now, so the engine // The database target archives for real, so the engine needs
// needs a webhook DB manager to locate the data directory. // the target in the main database and a data directory.
e := delivery.NewTestEngineWithDB( env := setupArchiveTest(t)
nil, archive := env.seedDatabaseTarget(t, "")
database.NewTestWebhookDBManager(t.TempDir()),
slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
)),
&http.Client{Timeout: 5 * time.Second},
1,
)
tests := []struct { tests := []struct {
name string name string
targetType database.TargetType targetType database.TargetType
targetID string
wantStatus database.DeliveryStatus wantStatus database.DeliveryStatus
}{ }{
{ {
"database target", "database target",
database.TargetTypeDatabase, database.TargetTypeDatabase,
archive.ID,
database.DeliveryStatusDelivered, database.DeliveryStatusDelivered,
}, },
{ {
"log target", "log target",
database.TargetTypeLog, database.TargetTypeLog,
uuid.New().String(),
database.DeliveryStatusDelivered, database.DeliveryStatusDelivered,
}, },
} }
@@ -1368,7 +1354,7 @@ func TestProcessDelivery_RoutesToCorrectHandler(
t.Parallel() t.Parallel()
runRoutingSubtest( runRoutingSubtest(
t, db, e, tt.targetType, t, db, env.eng, tt.targetType, tt.targetID,
tt.wantStatus, tt.wantStatus,
) )
}) })
@@ -1380,6 +1366,7 @@ func runRoutingSubtest(
db *gorm.DB, db *gorm.DB,
e *delivery.Engine, e *delivery.Engine,
targetType database.TargetType, targetType database.TargetType,
targetID string,
wantStatus database.DeliveryStatus, wantStatus database.DeliveryStatus,
) { ) {
t.Helper() t.Helper()
@@ -1387,8 +1374,7 @@ func runRoutingSubtest(
event := seedEvent(t, db, `{"routing":"test"}`) event := seedEvent(t, db, `{"routing":"test"}`)
dlv := seedDelivery( dlv := seedDelivery(
t, db, event.ID, t, db, event.ID, targetID,
uuid.New().String(),
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
+54 -31
View File
@@ -9,6 +9,7 @@ import (
"net/url" "net/url"
"time" "time"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/fx" "go.uber.org/fx"
"gorm.io/gorm" "gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
@@ -40,11 +41,6 @@ const (
ExportPendingSweepMinAge = pendingSweepMinAge ExportPendingSweepMinAge = pendingSweepMinAge
) )
// ExportIsBlockedIP exposes isBlockedIP for testing.
func ExportIsBlockedIP(ip net.IP) bool {
return isBlockedIP(ip)
}
// NewTestGuard builds an SSRF Guard from an explicit egress // NewTestGuard builds an SSRF Guard from an explicit egress
// allowlist, without going through config. Passing no prefixes // allowlist, without going through config. Passing no prefixes
// yields the default guard, which blocks every private/reserved // yields the default guard, which blocks every private/reserved
@@ -70,6 +66,11 @@ func ExportBlockedNetworks() []*net.IPNet {
return blockedNetworks return blockedNetworks
} }
// ExportBlockedPublicNetworks exposes blockedPublicNetworks.
func ExportBlockedPublicNetworks() []*net.IPNet {
return blockedPublicNetworks
}
// ExportIsForwardableHeader exposes isForwardableHeader. // ExportIsForwardableHeader exposes isForwardableHeader.
func ExportIsForwardableHeader(name string) bool { func ExportIsForwardableHeader(name string) bool {
return isForwardableHeader(name) return isForwardableHeader(name)
@@ -82,8 +83,9 @@ func ExportApplyRequestHeaders(
req *http.Request, req *http.Request,
event *database.Event, event *database.Event,
cfg *HTTPTargetConfig, cfg *HTTPTargetConfig,
userAgent string,
) []string { ) []string {
return applyRequestHeaders(req, event, cfg) return applyRequestHeaders(req, event, cfg, userAgent)
} }
// ExportTruncate exposes truncate for testing. // ExportTruncate exposes truncate for testing.
@@ -150,6 +152,16 @@ func (e *Engine) ExportDeliverSlack(
) )
} }
// ExportUpdateDeliveryStatus exposes updateDeliveryStatus. It passes no
// target type, so no metric moves.
func (e *Engine) ExportUpdateDeliveryStatus(
webhookDB *gorm.DB,
d *database.Delivery,
status database.DeliveryStatus,
) error {
return e.updateDeliveryStatus(webhookDB, d, "", status)
}
// ExportProcessNewTask exposes processNewTask. // ExportProcessNewTask exposes processNewTask.
func (e *Engine) ExportProcessNewTask( func (e *Engine) ExportProcessNewTask(
ctx context.Context, task *Task, ctx context.Context, task *Task,
@@ -389,7 +401,7 @@ func NewTestEngine(
deliveryCh: make(chan Task, deliveryChannelSize), deliveryCh: make(chan Task, deliveryChannelSize),
retryCh: make(chan Task, retryChannelSize), retryCh: make(chan Task, retryChannelSize),
workers: workers, workers: workers,
mtr: metrics.Default(), mtr: metrics.New(prometheus.NewRegistry()),
} }
e.initTargets(client) e.initTargets(client)
@@ -404,7 +416,7 @@ func NewTestEngineSmallRetry(
e := &Engine{ e := &Engine{
log: log, log: log,
retryCh: make(chan Task, 1), retryCh: make(chan Task, 1),
mtr: metrics.Default(), mtr: metrics.New(prometheus.NewRegistry()),
} }
e.initTargets(nil) e.initTargets(nil)
@@ -427,7 +439,7 @@ func NewTestEngineWithDB(
deliveryCh: make(chan Task, deliveryChannelSize), deliveryCh: make(chan Task, deliveryChannelSize),
retryCh: make(chan Task, retryChannelSize), retryCh: make(chan Task, retryChannelSize),
workers: workers, workers: workers,
mtr: metrics.Default(), mtr: metrics.New(prometheus.NewRegistry()),
} }
e.initTargets(client) e.initTargets(client)
@@ -435,8 +447,7 @@ func NewTestEngineWithDB(
} }
// ExportSetMetrics substitutes the engine's metric set, so a test can // ExportSetMetrics substitutes the engine's metric set, so a test can
// assert on collectors registered on a private registry instead of // assert on collectors registered on a registry it holds.
// the process-wide ones every other test is also moving.
func (e *Engine) ExportSetMetrics(mtr *metrics.Set) { func (e *Engine) ExportSetMetrics(mtr *metrics.Set) {
e.mtr = mtr e.mtr = mtr
} }
@@ -463,7 +474,7 @@ func NewTestCircuitBreaker(
type ExportArchivedEvent = archivedEvent type ExportArchivedEvent = archivedEvent
// ExportArchiveWriter wraps an archiveWriter so black-box tests // ExportArchiveWriter wraps an archiveWriter so black-box tests
// can exercise the per-webhook archive file mechanics. // can exercise the archive file mechanics.
type ExportArchiveWriter struct { type ExportArchiveWriter struct {
w *archiveWriter w *archiveWriter
} }
@@ -501,6 +512,12 @@ func (e *ExportArchiveWriter) Reopen(
return e.w.reopen(expiry) return e.w.reopen(expiry)
} }
// SetNow replaces the clock the writer measures its reopen
// debounce on.
func (e *ExportArchiveWriter) SetNow(now func() time.Time) {
e.w.now = now
}
// Reopens reports how many times the file has been opened. // Reopens reports how many times the file has been opened.
func (e *ExportArchiveWriter) Reopens() int { func (e *ExportArchiveWriter) Reopens() int {
return e.w.reopens return e.w.reopens
@@ -538,6 +555,12 @@ func (e *ExportArchiveWriter) Evict() {
e.w.evict() e.w.evict()
} }
// Rename gives the archive file a new name in the same directory,
// as a rename of the webhook or target does.
func (e *ExportArchiveWriter) Rename(name string) error {
return e.w.rename(name)
}
// HandleOpen reports whether the writer currently holds an open // HandleOpen reports whether the writer currently holds an open
// archive handle. // archive handle.
func (e *ExportArchiveWriter) HandleOpen() bool { func (e *ExportArchiveWriter) HandleOpen() bool {
@@ -557,16 +580,16 @@ func (e *ExportArchiveWriter) Same(
} }
// ExportArchiveWriterFor returns the archive writer the registry // ExportArchiveWriterFor returns the archive writer the registry
// currently caches for a webhook, or nil when none is cached. It // currently caches for a database target, or nil when none is
// never creates one, so a test can hold a reference to the very // cached. It never creates one, so a test can hold a reference to
// writer an eviction is about to detach. // the very writer an eviction is about to detach.
func (e *Engine) ExportArchiveWriterFor( func (e *Engine) ExportArchiveWriterFor(
webhookID string, targetID string,
) *ExportArchiveWriter { ) *ExportArchiveWriter {
e.dbTarget.mu.Lock() e.dbTarget.mu.Lock()
defer e.dbTarget.mu.Unlock() defer e.dbTarget.mu.Unlock()
w, ok := e.dbTarget.writers[webhookID] w, ok := e.dbTarget.writers[targetID]
if !ok { if !ok {
return nil return nil
} }
@@ -575,26 +598,26 @@ func (e *Engine) ExportArchiveWriterFor(
} }
// ExportHasArchiveWriter reports whether the database target // ExportHasArchiveWriter reports whether the database target
// currently caches an archive writer for a webhook. // type currently caches an archive writer for a target.
func (e *Engine) ExportHasArchiveWriter( func (e *Engine) ExportHasArchiveWriter(
webhookID string, targetID string,
) bool { ) bool {
e.dbTarget.mu.Lock() e.dbTarget.mu.Lock()
defer e.dbTarget.mu.Unlock() defer e.dbTarget.mu.Unlock()
_, ok := e.dbTarget.writers[webhookID] _, ok := e.dbTarget.writers[targetID]
return ok return ok
} }
// ExportArchiveHandleOpen reports whether the cached archive // ExportArchiveHandleOpen reports whether the cached archive
// writer for a webhook holds an open database handle. It // writer for a target holds an open database handle. It
// returns false when no writer is cached. // returns false when no writer is cached.
func (e *Engine) ExportArchiveHandleOpen( func (e *Engine) ExportArchiveHandleOpen(
webhookID string, targetID string,
) bool { ) bool {
e.dbTarget.mu.Lock() e.dbTarget.mu.Lock()
w, ok := e.dbTarget.writers[webhookID] w, ok := e.dbTarget.writers[targetID]
e.dbTarget.mu.Unlock() e.dbTarget.mu.Unlock()
if !ok { if !ok {
@@ -608,12 +631,12 @@ func (e *Engine) ExportArchiveHandleOpen(
} }
// ExportEnsureArchiveWriter creates (if needed) and returns the // ExportEnsureArchiveWriter creates (if needed) and returns the
// archive file path of the cached writer for a webhook, so a // archive file path of the cached writer for a target, so a
// test can prime the registry the way a delivery would. // test can prime the registry the way a delivery would.
func (e *Engine) ExportEnsureArchiveWriter( func (e *Engine) ExportEnsureArchiveWriter(
webhookID string, targetID string,
) (string, error) { ) (string, error) {
w, err := e.dbTarget.writerFor(webhookID) w, err := e.dbTarget.writerFor(targetID)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -621,14 +644,14 @@ func (e *Engine) ExportEnsureArchiveWriter(
return w.path, nil return w.path, nil
} }
// ExportSweepWriterFor takes a webhook's registry writer exactly // ExportSweepWriterFor takes a target's registry writer exactly
// as the idle sweep does, reporting whether the sweep had to // as the idle sweep does, reporting whether the sweep had to
// create the entry. It lets a test drive the registry through the // create the entry. It lets a test drive the registry through the
// sweep's own entry point instead of choreographing goroutines. // sweep's own entry point instead of choreographing goroutines.
func (e *Engine) ExportSweepWriterFor( func (e *Engine) ExportSweepWriterFor(
webhookID string, targetID string,
) (*ExportArchiveWriter, bool, error) { ) (*ExportArchiveWriter, bool, error) {
w, created, err := e.dbTarget.sweepWriterFor(webhookID) w, created, err := e.dbTarget.sweepWriterFor(targetID)
if err != nil { if err != nil {
return nil, false, err return nil, false, err
} }
@@ -639,9 +662,9 @@ func (e *Engine) ExportSweepWriterFor(
// ExportReleaseSweepWriter releases a sweep-created registry entry // ExportReleaseSweepWriter releases a sweep-created registry entry
// exactly as a finished sweep does. // exactly as a finished sweep does.
func (e *Engine) ExportReleaseSweepWriter( func (e *Engine) ExportReleaseSweepWriter(
webhookID string, w *ExportArchiveWriter, targetID string, w *ExportArchiveWriter,
) { ) {
e.dbTarget.releaseSweepWriter(webhookID, w.w) e.dbTarget.releaseSweepWriter(targetID, w.w)
} }
// NewTestArchiveSweeper builds an ArchiveSweeper backed by the // NewTestArchiveSweeper builds an ArchiveSweeper backed by the
+2 -3
View File
@@ -35,9 +35,8 @@ const (
) )
// mIsolate gives the setup's engine a metric set registered on a // mIsolate gives the setup's engine a metric set registered on a
// private registry. The process-wide collectors are moved by every // registry this test holds, so its exact assertions can gather from
// other delivery test running in parallel, so exact assertions are // it.
// only possible against a registry this test owns.
func mIsolate( func mIsolate(
t *testing.T, s iSetup, t *testing.T, s iSetup,
) *prometheus.Registry { ) *prometheus.Registry {
+1
View File
@@ -375,6 +375,7 @@ func TestApplyRequestHeaders_ReportsOriginScopedNames(t *testing.T) {
"Content-Type": testContentType, "Content-Type": testContentType,
}, },
}, },
"webhooker/dev",
) )
assert.Equal(t, assert.Equal(t,
+105 -33
View File
@@ -25,36 +25,64 @@ var (
errNoIPs = errors.New( errNoIPs = errors.New(
"hostname resolved to no IP addresses", "hostname resolved to no IP addresses",
) )
errBlockedIP = errors.New( // ErrBlockedPrivateOrReservedIP reports an address in the
"blocked private, reserved or cloud metadata address", // default blocklist's private and reserved ranges,
// blockedNetworks.
ErrBlockedPrivateOrReservedIP = errors.New(
"blocked private or reserved address",
)
// errBlockedPublicMetadata reports a public address on the
// default blocklist, one in blockedPublicNetworks.
errBlockedPublicMetadata = errors.New(
"blocked cloud metadata address",
) )
errBlockedMetadata = errors.New( errBlockedMetadata = errors.New(
"blocked link-local or cloud instance metadata " + "blocked link-local, cloud instance metadata or " +
"address: ALLOWED_EGRESS_CIDRS cannot open it", "unspecified address: ALLOWED_EGRESS_CIDRS cannot open it",
) )
errInvalidScheme = errors.New( errInvalidScheme = errors.New(
"only http and https are allowed", "only http and https are allowed",
) )
) )
// blockedNetworks is the default blocklist: the private and // blockedNetworks and blockedPublicNetworks together are the
// reserved IP ranges, plus the public cloud metadata addresses, // default blocklist: the private and reserved IP ranges, plus
// that are blocked to prevent SSRF attacks. An operator can // the public cloud metadata addresses, that are blocked to
// permit specific blocks out of this set with // prevent SSRF attacks. An operator can permit specific blocks
// ALLOWED_EGRESS_CIDRS; see Guard. // out of this set with ALLOWED_EGRESS_CIDRS; see Guard.
//
// blockedNetworks holds the private and reserved IP ranges.
// //
//nolint:gochecknoglobals // package-level network list is appropriate here //nolint:gochecknoglobals // package-level network list is appropriate here
var blockedNetworks []*net.IPNet var blockedNetworks []*net.IPNet
// alwaysBlockedNetworks are the ranges no configuration can // blockedPublicNetworks holds the default blocklist's public
// open: the link-local blocks and the cloud instance metadata // addresses, kept apart from blockedNetworks so that they are
// endpoints that live outside them. Reaching one is credential // refused as cloud metadata addresses, never as private or
// or user-data theft rather than delivery to an internal // reserved ones.
// service, so a supplied CIDR that covers such an address still
// leaves it blocked.
// //
// Inclusion criterion — an address belongs here only if BOTH // A public address belongs on the default blocklist only if it
// hold, and every entry below satisfies both: // hands credentials, user data or bootstrap material to whatever
// can reach it, without the caller presenting anything; it goes
// in this list. A provider's other public addresses are not
// refused, since reaching them can be legitimate and no list of
// them could be complete.
//
//nolint:gochecknoglobals // package-level network list is appropriate here
var blockedPublicNetworks []*net.IPNet
// alwaysBlockedNetworks are the ranges no configuration can
// open, so a supplied CIDR that covers one still leaves it
// blocked. An entry is here for one of two reasons: it is a
// metadata endpoint (the link-local blocks and the cloud
// instance metadata endpoints that live outside them), or it is
// an unspecified address. Reaching a metadata endpoint is
// credential or user-data theft rather than delivery to an
// internal service.
//
// Inclusion criterion for metadata endpoints — one belongs here
// only if BOTH hold, and every metadata entry below satisfies
// both:
// //
// 1. It is a fixed address assigned by the provider, or a // 1. It is a fixed address assigned by the provider, or a
// range reserved by IANA — never one the operator chose. // range reserved by IANA — never one the operator chose.
@@ -65,8 +93,8 @@ var blockedNetworks []*net.IPNet
// not cheaply rotated. // not cheaply rotated.
// //
// Both halves are load-bearing, so use them to refuse a // Both halves are load-bearing, so use them to refuse a
// candidate and say why. An endpoint disclosing only the // metadata candidate and say why. An endpoint disclosing only
// operator's own inventory (instance id, region, disks, NICs) // the operator's own inventory (instance id, region, disks, NICs)
// fails (2): letting a delivery target reach the operator's own // fails (2): letting a delivery target reach the operator's own
// infrastructure is the feature ALLOWED_EGRESS_CIDRS exists to // infrastructure is the feature ALLOWED_EGRESS_CIDRS exists to
// provide. But (2) is not "IAM credentials only" either — // provide. But (2) is not "IAM credentials only" either —
@@ -81,12 +109,21 @@ var blockedNetworks []*net.IPNet
// when it clears both halves. Nothing in this list can be // when it clears both halves. Nothing in this list can be
// reopened, so putting a public address here leaves the operator // reopened, so putting a public address here leaves the operator
// no escape hatch at all — the condition ALLOWED_EGRESS_CIDRS // no escape hatch at all — the condition ALLOWED_EGRESS_CIDRS
// exists to remove. Default-block it in blockedNetworks instead, // exists to remove. Default-block it in blockedPublicNetworks
// which an allowlist can override. // instead, which an allowlist can override.
// //
// This is a criterion, not an enumeration of every metadata // This is a criterion, not an enumeration of every metadata
// address in existence. // address in existence.
// //
// The unspecified addresses 0.0.0.0 and :: are here for a
// separate reason: they disclose nothing, but no host can have
// either, and on Linux a connection to one reaches this host's
// own loopback. Listing them means an allowlist reaches loopback
// only through an entry that covers a loopback address
// (127.0.0.0/8, ::1/128, 0.0.0.0/0), never through one that
// covers only 0.0.0.0 or :: (0.0.0.0/8, for example). Nothing
// else lives at either address, so refusing them costs nothing.
//
// Every entry is either already in blockedNetworks — this list is // Every entry is either already in blockedNetworks — this list is
// what makes it unconditional — or an alternate encoding of // what makes it unconditional — or an alternate encoding of
// 169.254.169.254 that Contains does not match against // 169.254.169.254 that Contains does not match against
@@ -106,23 +143,49 @@ var alwaysBlockedNetworks []*net.IPNet
//nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup //nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup
func init() { func init() {
blockedNetworks = mustParseCIDRs([]string{ blockedNetworks = mustParseCIDRs([]string{
// IPv4 loopback.
"127.0.0.0/8", "127.0.0.0/8",
// RFC 1918 private network.
"10.0.0.0/8", "10.0.0.0/8",
// RFC 1918 private network.
"172.16.0.0/12", "172.16.0.0/12",
// RFC 1918 private network.
"192.168.0.0/16", "192.168.0.0/16",
// IPv4 link-local.
"169.254.0.0/16", "169.254.0.0/16",
// "This network", holding the IPv4 unspecified address 0.0.0.0.
"0.0.0.0/8", "0.0.0.0/8",
// Carrier-grade NAT shared address space.
"100.64.0.0/10", "100.64.0.0/10",
// IETF protocol assignments.
"192.0.0.0/24", "192.0.0.0/24",
// IPv4 documentation (TEST-NET-1).
"192.0.2.0/24", "192.0.2.0/24",
// Benchmarking.
"198.18.0.0/15", "198.18.0.0/15",
// IPv4 documentation (TEST-NET-2).
"198.51.100.0/24", "198.51.100.0/24",
// IPv4 documentation (TEST-NET-3).
"203.0.113.0/24", "203.0.113.0/24",
// IPv4 multicast.
"224.0.0.0/4", "224.0.0.0/4",
// Reserved, including the broadcast address.
"240.0.0.0/4", "240.0.0.0/4",
// IPv6 loopback.
"::1/128", "::1/128",
// IPv6 unspecified address.
"::/128",
// IPv6 unique local addresses.
"fc00::/7", "fc00::/7",
// IPv6 link-local.
"fe80::/10", "fe80::/10",
// IPv6 multicast.
"ff00::/8",
// IPv6 documentation.
"2001:db8::/32",
})
blockedPublicNetworks = mustParseCIDRs([]string{
// Azure WireServer, a public address that serves VM credentials. // Azure WireServer, a public address that serves VM credentials.
"168.63.129.16/32", "168.63.129.16/32",
}) })
@@ -179,6 +242,14 @@ func init() {
// allowlist from opening it. // allowlist from opening it.
"192.0.0.192/32", "192.0.0.192/32",
// The unspecified addresses, each of which reaches this
// host's loopback on Linux.
//
// IPv4 unspecified address, inside the blocked 0.0.0.0/8.
"0.0.0.0/32",
// IPv6 unspecified address.
"::/128",
// 169.254.169.254 as an IPv4-compatible IPv6 address. // 169.254.169.254 as an IPv4-compatible IPv6 address.
"::a9fe:a9fe/128", "::a9fe:a9fe/128",
// 169.254.169.254 behind the NAT64 well-known prefix. // 169.254.169.254 behind the NAT64 well-known prefix.
@@ -218,13 +289,6 @@ func matchesAny(networks []*net.IPNet, ip net.IP) bool {
return false return false
} }
// isBlockedIP checks whether an IP address falls within
// the default blocklist, before any operator allowlist is
// considered.
func isBlockedIP(ip net.IP) bool {
return matchesAny(blockedNetworks, ip)
}
// Guard makes every SSRF decision in the process. // Guard makes every SSRF decision in the process.
// //
// It holds the operator's ALLOWED_EGRESS_CIDRS allowlist and // It holds the operator's ALLOWED_EGRESS_CIDRS allowlist and
@@ -322,10 +386,12 @@ func (g *Guard) allows(ip net.IP) bool {
// The order is the policy: // The order is the policy:
// //
// 1. alwaysBlockedNetworks is refused before the allowlist is // 1. alwaysBlockedNetworks is refused before the allowlist is
// consulted, so no configured CIDR reaches link-local or a // consulted, so no configured CIDR reaches link-local, a
// cloud metadata endpoint at a non-public address. // cloud metadata endpoint at a non-public address, or an
// unspecified address.
// 2. The allowlist is consulted next, so a listed private // 2. The allowlist is consulted next, so a listed private
// network becomes reachable. // network, or a listed public address on the default
// blocklist, becomes reachable.
// 3. Everything else keeps the default blocklist's answer. // 3. Everything else keeps the default blocklist's answer.
func (g *Guard) checkIP(ip net.IP) error { func (g *Guard) checkIP(ip net.IP) error {
if matchesAny(alwaysBlockedNetworks, ip) { if matchesAny(alwaysBlockedNetworks, ip) {
@@ -338,9 +404,15 @@ func (g *Guard) checkIP(ip net.IP) error {
return nil return nil
} }
if isBlockedIP(ip) { if matchesAny(blockedNetworks, ip) {
return fmt.Errorf( return fmt.Errorf(
"target IP %s: %w", ip, errBlockedIP, "target IP %s: %w", ip, ErrBlockedPrivateOrReservedIP,
)
}
if matchesAny(blockedPublicNetworks, ip) {
return fmt.Errorf(
"target IP %s: %w", ip, errBlockedPublicMetadata,
) )
} }
+131 -12
View File
@@ -23,6 +23,10 @@ const (
metadataIP = "169.254.169.254" metadataIP = "169.254.169.254"
metadataURL = "http://" + metadataIP + "/latest/meta-data/" metadataURL = "http://" + metadataIP + "/latest/meta-data/"
// linkLocalIPv4 is the IPv4 link-local block, which holds
// metadataIP.
linkLocalIPv4 = "169.254.0.0/16"
// loopbackHookURL is a target on this host: blocked by // loopbackHookURL is a target on this host: blocked by
// default, reachable only once an operator allowlists // default, reachable only once an operator allowlists
// loopback. // loopback.
@@ -164,12 +168,13 @@ func TestGuardAllowlist_UnlistedPrivateStillRefused(t *testing.T) {
// TestGuardAllowlist_MetadataAlwaysRefused is the load-bearing // TestGuardAllowlist_MetadataAlwaysRefused is the load-bearing
// case: cloud instance metadata endpoints are credential theft // case: cloud instance metadata endpoints are credential theft
// rather than delivery to an internal service, so no allowlist // rather than delivery to an internal service, and the
// reaches one. Every guard below names a CIDR that covers its // unspecified addresses 0.0.0.0 and :: reach this host's loopback
// target — including 0.0.0.0/0, ::/0, and the ordinary ULA and // on Linux, so no allowlist reaches any of them. Every guard
// CGNAT blocks an operator would really list — and the address // below names a CIDR that covers its target — including
// must stay refused anyway, on both the validation and the // 0.0.0.0/0, ::/0, and the ordinary ULA and CGNAT blocks an
// delivery path. // operator would really list — and the address must stay
// refused anyway, on both the validation and the delivery path.
func TestGuardAllowlist_MetadataAlwaysRefused(t *testing.T) { func TestGuardAllowlist_MetadataAlwaysRefused(t *testing.T) {
t.Parallel() t.Parallel()
@@ -215,15 +220,17 @@ type metadataAlwaysRefusedCase struct {
} }
// metadataAlwaysRefusedCases enumerates every unconditionally // metadataAlwaysRefusedCases enumerates every unconditionally
// blocked address together with an allowlist entry that would // blocked address (link-local, the cloud metadata endpoints and
// otherwise reach it. Split by family of address only to stay // the unspecified addresses) together with an allowlist entry
// under the function-length limit. // that would otherwise reach it. Split by family of address only
// to stay under the function-length limit.
func metadataAlwaysRefusedCases() []metadataAlwaysRefusedCase { func metadataAlwaysRefusedCases() []metadataAlwaysRefusedCase {
cases := linkLocalRefusedCases() cases := linkLocalRefusedCases()
cases = append(cases, ulaMetadataRefusedCases()...) cases = append(cases, ulaMetadataRefusedCases()...)
cases = append(cases, ipv4MetadataRefusedCases()...) cases = append(cases, ipv4MetadataRefusedCases()...)
cases = append(cases, encodedMetadataRefusedCases()...)
return append(cases, encodedMetadataRefusedCases()...) return append(cases, unspecifiedRefusedCases()...)
} }
// linkLocalRefusedCases covers the link-local blocks, including // linkLocalRefusedCases covers the link-local blocks, including
@@ -237,7 +244,7 @@ func linkLocalRefusedCases() []metadataAlwaysRefusedCase {
}, },
{ {
name: "whole link-local block", name: "whole link-local block",
allow: "169.254.0.0/16", allow: linkLocalIPv4,
target: metadataURL, target: metadataURL,
}, },
{ {
@@ -363,6 +370,23 @@ func encodedMetadataRefusedCases() []metadataAlwaysRefusedCase {
} }
} }
// unspecifiedRefusedCases covers the unspecified addresses, each
// of which reaches this host's loopback on Linux.
func unspecifiedRefusedCases() []metadataAlwaysRefusedCase {
return []metadataAlwaysRefusedCase{
{
name: "IPv4 unspecified address under 0.0.0.0/0",
allow: allowAllIPv4,
target: "http://0.0.0.0:8080/hook",
},
{
name: "IPv6 unspecified address under ::/0",
allow: allowAllIPv6,
target: "http://[::]:8080/hook",
},
}
}
// TestGuardAllowlist_PublicUnaffected asserts the allowlist does // TestGuardAllowlist_PublicUnaffected asserts the allowlist does
// not narrow anything: public addresses were reachable before it // not narrow anything: public addresses were reachable before it
// existed and stay reachable, whether or not a list is set. // existed and stay reachable, whether or not a list is set.
@@ -412,6 +436,9 @@ func TestGuardAllowlist_AzureWireServerReopenable(t *testing.T) {
"WireServer must be refused by the default blocklist, "+ "WireServer must be refused by the default blocklist, "+
"which an allowlist can override", "which an allowlist can override",
) )
require.NotErrorIs(t, err, delivery.ErrBlockedPrivateOrReservedIP,
"WireServer is public, not private or reserved",
)
assertDialRefused(t, defaultGuard, target) assertDialRefused(t, defaultGuard, target)
@@ -496,7 +523,7 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
want := []string{ want := []string{
// IPv4 link-local: the 169.254.169.254 metadata // IPv4 link-local: the 169.254.169.254 metadata
// service on AWS, Azure and others. // service on AWS, Azure and others.
"169.254.0.0/16", linkLocalIPv4,
// IPv6 link-local. // IPv6 link-local.
"fe80::/10", "fe80::/10",
// AWS IPv6 IMDS, inside the ULA space an operator may // AWS IPv6 IMDS, inside the ULA space an operator may
@@ -517,6 +544,10 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
// Oracle Cloud Classic metadata, inside the blocked // Oracle Cloud Classic metadata, inside the blocked
// 192.0.0.0/24. // 192.0.0.0/24.
"192.0.0.192/32", "192.0.0.192/32",
// The IPv4 and IPv6 unspecified addresses, each of
// which reaches this host's loopback on Linux.
"0.0.0.0/32",
"::/128",
// 169.254.169.254 as an IPv4-compatible IPv6 address. // 169.254.169.254 as an IPv4-compatible IPv6 address.
"::a9fe:a9fe/128", "::a9fe:a9fe/128",
// 169.254.169.254 behind the NAT64 well-known prefix. // 169.254.169.254 behind the NAT64 well-known prefix.
@@ -526,6 +557,94 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
assert.Equal(t, want, got) assert.Equal(t, want, got)
} }
// TestDefaultBlocklist_PinnedSet pins each list of the default
// blocklist on its own, the private and reserved ranges in
// blockedNetworks and the public addresses in
// blockedPublicNetworks, so moving an entry from one list to the
// other fails it. For the first address of each entry it then
// checks that the default guard refuses it, and that listing the
// entry in ALLOWED_EGRESS_CIDRS opens it unless the unconditional
// set holds that address.
func TestDefaultBlocklist_PinnedSet(t *testing.T) {
t.Parallel()
// public marks an entry of blockedPublicNetworks; every other
// entry belongs in blockedNetworks.
tests := []struct {
cidr string
public bool
reopenable bool
}{
{cidr: "127.0.0.0/8", reopenable: true},
{cidr: "10.0.0.0/8", reopenable: true},
{cidr: "172.16.0.0/12", reopenable: true},
{cidr: "192.168.0.0/16", reopenable: true},
{cidr: linkLocalIPv4, reopenable: false},
// Its first address, 0.0.0.0, is in the unconditional set.
{cidr: "0.0.0.0/8", reopenable: false},
{cidr: "100.64.0.0/10", reopenable: true},
{cidr: "192.0.0.0/24", reopenable: true},
{cidr: "192.0.2.0/24", reopenable: true},
{cidr: "198.18.0.0/15", reopenable: true},
{cidr: "198.51.100.0/24", reopenable: true},
{cidr: "203.0.113.0/24", reopenable: true},
{cidr: "224.0.0.0/4", reopenable: true},
{cidr: "240.0.0.0/4", reopenable: true},
{cidr: "::1/128", reopenable: true},
{cidr: "::/128", reopenable: false},
{cidr: "fc00::/7", reopenable: true},
{cidr: "fe80::/10", reopenable: false},
{cidr: "ff00::/8", reopenable: true},
{cidr: "2001:db8::/32", reopenable: true},
{cidr: "168.63.129.16/32", public: true, reopenable: true},
}
wantPrivate := make([]string, 0, len(tests))
wantPublic := make([]string, 0, len(tests))
for _, tt := range tests {
if tt.public {
wantPublic = append(wantPublic, tt.cidr)
} else {
wantPrivate = append(wantPrivate, tt.cidr)
}
}
gotPrivate := make([]string, 0, len(tests))
for _, n := range delivery.ExportBlockedNetworks() {
gotPrivate = append(gotPrivate, n.String())
}
gotPublic := make([]string, 0, len(tests))
for _, n := range delivery.ExportBlockedPublicNetworks() {
gotPublic = append(gotPublic, n.String())
}
assert.ElementsMatch(t, wantPrivate, gotPrivate, "blockedNetworks")
assert.ElementsMatch(t, wantPublic, gotPublic, "blockedPublicNetworks")
for _, tt := range tests {
t.Run(tt.cidr, func(t *testing.T) {
t.Parallel()
prefix := netip.MustParsePrefix(tt.cidr)
ip := net.IP(prefix.Addr().AsSlice())
require.Error(t,
delivery.NewTestGuard().ExportCheckIP(ip),
"the default guard must refuse %s", ip,
)
err := delivery.NewTestGuard(prefix).ExportCheckIP(ip)
if tt.reopenable {
assert.NoError(t, err, "listing %s must open it", tt.cidr)
} else {
assert.Error(t, err, "listing %s must not open it", tt.cidr)
}
})
}
}
// requireLoopback fails the test unless rawURL's host is a // requireLoopback fails the test unless rawURL's host is a
// loopback address, so the allowlist test cannot silently stop // loopback address, so the allowlist test cannot silently stop
// exercising a blocked range. // exercising a blocked range.
+42 -4
View File
@@ -10,7 +10,7 @@ import (
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
) )
func TestIsBlockedIP_PrivateRanges(t *testing.T) { func TestGuardCheckIP_PrivateRanges(t *testing.T) {
t.Parallel() t.Parallel()
tests := []struct { tests := []struct {
@@ -56,12 +56,14 @@ func TestIsBlockedIP_PrivateRanges(t *testing.T) {
"failed to parse IP %s", tt.ip, "failed to parse IP %s", tt.ip,
) )
refused := delivery.NewTestGuard().ExportCheckIP(ip) != nil
assert.Equal(t, assert.Equal(t,
tt.blocked, tt.blocked,
delivery.ExportIsBlockedIP(ip), refused,
"isBlockedIP(%s) = %v, want %v", "default guard refuses %s = %v, want %v",
tt.ip, tt.ip,
delivery.ExportIsBlockedIP(ip), refused,
tt.blocked, tt.blocked,
) )
}) })
@@ -99,6 +101,42 @@ func TestValidateTargetURL_Blocked(t *testing.T) {
} }
} }
// TestDefaultGuard_RefusesUnspecifiedMulticastAndDocumentation
// covers the unspecified addresses and the IPv6 multicast and
// documentation ranges: with no allowlist set, each is refused
// both when a target is created and when a delivery dials it.
func TestDefaultGuard_RefusesUnspecifiedMulticastAndDocumentation(
t *testing.T,
) {
t.Parallel()
guard := delivery.NewTestGuard()
targets := []string{
// The unspecified addresses. On Linux a connection to
// either reaches this host's loopback.
"http://0.0.0.0:8080/hook",
"http://[::]:8080/hook",
// IPv6 multicast, all nodes.
"http://[ff02::1]/hook",
// IPv6 documentation.
"http://[2001:db8::1]/hook",
}
for _, target := range targets {
t.Run(target, func(t *testing.T) {
t.Parallel()
require.Error(t,
guard.ValidateTargetURL(context.Background(), target),
"%s must be refused at target creation", target,
)
assertDialRefused(t, guard, target)
})
}
}
func TestValidateTargetURL_Allowed(t *testing.T) { func TestValidateTargetURL_Allowed(t *testing.T) {
t.Parallel() t.Parallel()
+193 -90
View File
@@ -3,7 +3,7 @@ package delivery
import ( import (
"context" "context"
"fmt" "fmt"
"path/filepath" "strings"
"sync" "sync"
"time" "time"
@@ -11,22 +11,75 @@ import (
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
// databaseTarget is a no-retry target that archives the // archiveNameMaxLen is how many characters of a webhook or target
// full inbound event into a per-webhook archive SQLite file, // name an archive file name keeps.
// separate from the per-webhook event database. The event is const archiveNameMaxLen = 40
// already persisted in the per-webhook event DB by the time
// delivery runs; the database target additionally writes a // databaseTarget is a no-retry target that archives the full
// durable long-term copy into archive-{webhookID}.db and then // inbound event into the target's own archive SQLite file, separate
// records a single attempt whose outcome reflects whether the // from the per-webhook event database. The event is already
// archive write succeeded. See archiveWriter for the // persisted in the per-webhook event DB by the time delivery runs;
// close/reopen, auto-recreate, and expiry semantics. // the database target additionally writes a durable long-term copy
// into the file ArchiveFileName names and then records a single
// attempt whose outcome reflects whether the archive write
// succeeded. See archiveWriter for the close/reopen, auto-recreate,
// and expiry semantics.
type databaseTarget struct { type databaseTarget struct {
eng *Engine eng *Engine
// writers holds one archive writer per database target, keyed
// by target ID.
mu sync.Mutex mu sync.Mutex
writers map[string]*archiveWriter writers map[string]*archiveWriter
} }
// ArchiveFileName returns the file name of a database target's
// archive: archive-WEBHOOKNAME-TARGETNAME-TARGETID.db, with both
// names passed through archiveNamePart. The target ID keeps the
// name unique when two targets' names come out the same.
func ArchiveFileName(webhookName, targetName, targetID string) string {
return "archive-" + archiveNamePart(webhookName) + "-" +
archiveNamePart(targetName) + "-" + targetID + ".db"
}
// archiveNamePart makes a webhook or target name safe to put in a
// file name. It is lowercased; ASCII letters and digits are kept,
// every other run of characters becomes a single "-", and no "-" is
// left at either end. It is cut to archiveNameMaxLen characters, and
// a name with nothing left is "unnamed".
func archiveNamePart(name string) string {
var b strings.Builder
dash := false
for _, r := range strings.ToLower(name) {
if (r < 'a' || r > 'z') && (r < '0' || r > '9') {
dash = b.Len() > 0
continue
}
if dash {
b.WriteByte('-')
dash = false
}
b.WriteRune(r)
}
part := b.String()
if len(part) > archiveNameMaxLen {
part = strings.TrimRight(part[:archiveNameMaxLen], "-")
}
if part == "" {
return "unnamed"
}
return part
}
// Deliver implements Target. It archives the event, then // Deliver implements Target. It archives the event, then
// records one successful attempt and marks the delivery // records one successful attempt and marks the delivery
// delivered. An archiving error fails the delivery: the // delivered. An archiving error fails the delivery: the
@@ -92,7 +145,7 @@ func (t *databaseTarget) Deliver(
) )
} }
// archive writes the full event as a row into the webhook's // archive writes the full event as a row into the target's
// archive database, honouring the optional per-target expiry // archive database, honouring the optional per-target expiry
// parsed from the target config JSON. // parsed from the target config JSON.
func (t *databaseTarget) archive(d *database.Delivery) error { func (t *databaseTarget) archive(d *database.Delivery) error {
@@ -106,7 +159,7 @@ func (t *databaseTarget) archive(d *database.Delivery) error {
return err return err
} }
w, err := t.writerFor(webhookID) w, err := t.writerFor(d.TargetID)
if err != nil { if err != nil {
return err return err
} }
@@ -124,30 +177,31 @@ func (t *databaseTarget) archive(d *database.Delivery) error {
return w.write(row, expiry) return w.write(row, expiry)
} }
// writerFor returns the archiveWriter for a webhook, creating // writerFor returns the archive writer for a database target,
// and caching it on first use. Each webhook has one writer so // creating and caching it on first use. Each target has one writer
// its close/reopen debounce state is shared across concurrent // so its close/reopen debounce state is shared across concurrent
// deliveries. The archive file lives beside the per-webhook // deliveries, and so a rename and the idle sweep take the same lock
// event database in the data directory. // as its writes.
func (t *databaseTarget) writerFor( func (t *databaseTarget) writerFor(
webhookID string, targetID string,
) (*archiveWriter, error) { ) (*archiveWriter, error) {
path, err := t.archivePath(webhookID) 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 { if err != nil {
return nil, err return nil, err
} }
t.mu.Lock()
defer t.mu.Unlock()
if t.writers == nil { if t.writers == nil {
t.writers = make(map[string]*archiveWriter) t.writers = make(map[string]*archiveWriter)
} }
w, ok := t.writers[webhookID] t.writers[targetID] = w
if !ok {
w = newArchiveWriter(path, t.eng.log)
t.writers[webhookID] = w
} }
// A delivery claims the entry: even if the idle sweep created // A delivery claims the entry: even if the idle sweep created
@@ -159,40 +213,39 @@ func (t *databaseTarget) writerFor(
} }
// sweepWriterFor returns the archive writer the idle sweep should // sweepWriterFor returns the archive writer the idle sweep should
// prune a webhook through, together with whether the sweep itself // prune a target's archive through, together with whether the sweep
// created the registry entry. // itself created the registry entry.
// //
// The sweep must route its prune through the registered writer so // The sweep must route its prune through the registered writer so
// the writer's mutex orders it against concurrent writes, but it // the writer's mutex orders it against concurrent writes, but it
// must never leave a registry entry behind: a sweep that ran // must never leave a registry entry behind: a sweep that ran
// concurrently with the webhook's deletion would otherwise // concurrently with the target's deletion would otherwise
// re-create an entry that nothing will ever evict again, which is // re-create an entry that nothing will ever evict again, which is
// exactly the leak eviction exists to prevent. An entry the sweep // exactly the leak eviction exists to prevent. An entry the sweep
// creates is therefore marked sweep-owned and handed back to // creates is therefore marked sweep-owned and handed back to
// releaseSweepWriter when the sweep is done. // releaseSweepWriter when the sweep is done.
func (t *databaseTarget) sweepWriterFor( func (t *databaseTarget) sweepWriterFor(
webhookID string, targetID string,
) (*archiveWriter, bool, error) { ) (*archiveWriter, bool, error) {
path, err := t.archivePath(webhookID) t.mu.Lock()
defer t.mu.Unlock()
w, ok := t.writers[targetID]
if ok {
return w, false, nil
}
w, err := t.newWriter(targetID)
if err != nil { if err != nil {
return nil, false, err return nil, false, err
} }
t.mu.Lock()
defer t.mu.Unlock()
if t.writers == nil { if t.writers == nil {
t.writers = make(map[string]*archiveWriter) t.writers = make(map[string]*archiveWriter)
} }
w, ok := t.writers[webhookID]
if ok {
return w, false, nil
}
w = newArchiveWriter(path, t.eng.log)
w.sweepOwned = true w.sweepOwned = true
t.writers[webhookID] = w t.writers[targetID] = w
return w, true, nil return w, true, nil
} }
@@ -209,57 +262,92 @@ func (t *databaseTarget) sweepWriterFor(
// delivery that adopted the writer keeps a registered, evictable // delivery that adopted the writer keeps a registered, evictable
// one. // one.
func (t *databaseTarget) releaseSweepWriter( func (t *databaseTarget) releaseSweepWriter(
webhookID string, w *archiveWriter, targetID string, w *archiveWriter,
) { ) {
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
cur, ok := t.writers[webhookID] cur, ok := t.writers[targetID]
if !ok || cur != w || !cur.sweepOwned { if !ok || cur != w || !cur.sweepOwned {
return return
} }
delete(t.writers, webhookID) delete(t.writers, targetID)
} }
// archivePath returns the archive file path for a webhook: it // newWriter builds the writer for a database target's archive. The
// lives beside the per-webhook event database in the data // file is the one ArchivePath gives for the webhook and the target as
// directory. It does not touch the filesystem. // the main database names them now; from then on only rename changes
func (t *databaseTarget) archivePath( // the name the writer uses. It does not touch the archive file.
webhookID string, func (t *databaseTarget) newWriter(
) (string, error) { targetID string,
) (*archiveWriter, error) {
if t.eng.dbManager == nil { if t.eng.dbManager == nil {
return "", errArchiveNoDataDir return nil, errArchiveNoDataDir
} }
dir := filepath.Dir(t.eng.dbManager.DBPath(webhookID)) var target database.Target
return filepath.Join( err := t.eng.database.DB().
dir, fmt.Sprintf("archive-%s.db", webhookID), Preload("Webhook").
), nil First(&target, "id = ?", targetID).Error
if err != nil {
return nil, fmt.Errorf(
"loading database target %s: %w", targetID, err,
)
}
w := newArchiveWriter(
ArchivePath(t.eng.dbManager, &target.Webhook, &target),
t.eng.log,
)
w.webhookID = target.WebhookID
return w, nil
} }
// evict drops a webhook's archive writer from the registry and // rename moves a database target's archive file to the name for
// closes its handle, so a deleted webhook does not leave a // webhookName and targetName. It goes through the target's writer,
// writer (and an open archive handle within its debounce // so the move holds the lock that writes and the idle sweep take,
// window) alive for the process lifetime. // and later writes use the new name.
//
// The writer is created if there is none, and it stays cached. The
// handlers rename before they save the new name, so until the save
// the main database still has the old one; a delivery in that window
// must find this writer rather than build one from the old name.
func (t *databaseTarget) rename(
targetID, webhookName, targetName string,
) error {
w, err := t.writerFor(targetID)
if err != nil {
return err
}
return w.rename(ArchiveFileName(webhookName, targetName, targetID))
}
// evict drops a database target's archive writer from the registry
// and closes its handle, so a deleted target does not leave a
// writer (and an open archive handle within its debounce window)
// alive for the process lifetime.
// //
// The map entry is removed under the registry lock, which is // The map entry is removed under the registry lock, which is
// then released before the handle is closed under the writer's // then released before the handle is closed under the writer's
// own lock: that ordering keeps the registry available to other // own lock: that ordering keeps the registry available to other
// webhooks while an in-flight write on this one drains, and // targets while an in-flight write on this one drains, and
// closing under the writer's lock means eviction can never race // closing under the writer's lock means eviction can never race
// a write. // a write.
// //
// Eviction is idempotent and silent for a webhook with no // Eviction is idempotent and silent for a target with no writer,
// writer, which is the common case: a webhook with no database // which is the common case: only a database target that has
// target never creates one. It never deletes the archive file. // received an event or been renamed has one. It never deletes the
func (t *databaseTarget) evict(webhookID string) { // archive file.
func (t *databaseTarget) evict(targetID string) {
t.mu.Lock() t.mu.Lock()
w, ok := t.writers[webhookID] w, ok := t.writers[targetID]
if ok { if ok {
delete(t.writers, webhookID) delete(t.writers, targetID)
} }
t.mu.Unlock() t.mu.Unlock()
@@ -272,13 +360,41 @@ func (t *databaseTarget) evict(webhookID string) {
t.eng.log.Info( t.eng.log.Info(
"evicted archive writer", "evicted archive writer",
"webhook_id", webhookID, "target_id", targetID,
"path", w.path, "path", w.path,
) )
} }
// evictWebhook evicts, exactly as evict does, the writer of every
// database target of a webhook.
func (t *databaseTarget) evictWebhook(webhookID string) {
t.mu.Lock()
var gone []*archiveWriter
for targetID, w := range t.writers {
if w.webhookID == webhookID {
delete(t.writers, targetID)
gone = append(gone, w)
}
}
t.mu.Unlock()
for _, w := range gone {
w.evict()
t.eng.log.Info(
"evicted archive writer",
"webhook_id", webhookID,
"path", w.path,
)
}
}
// evictAll evicts every cached archive writer, exactly as evict // evictAll evicts every cached archive writer, exactly as evict
// does for one webhook. The engine calls it at shutdown, once its // does for one target. The engine calls it at shutdown, once its
// workers have returned. Closing the last handle on an archive // workers have returned. Closing the last handle on an archive
// moves the contents of its -wal into the .db and removes the // moves the contents of its -wal into the .db and removes the
// -wal, so a clean stop leaves each archive as a single file. // -wal, so a clean stop leaves each archive as a single file.
@@ -295,38 +411,25 @@ func (t *databaseTarget) evictAll() {
} }
} }
// sweepWebhook prunes one webhook's archive of rows older than // sweepArchive prunes one database target's archive of rows older
// expiry, without requiring a write. It returns nil (nothing to // than expiry, without requiring a write. A missing archive file is
// do) when the archive file does not exist, so a sweep never // left missing (see sweepExpired), so a sweep never creates an
// creates an archive for a webhook that has a database target // archive for a target that has never received an event.
// but has never received an event.
// //
// It also never leaves a registry entry behind: an entry it had // It also never leaves a registry entry behind: an entry it had
// to create to reach the writer's mutex is released again once // to create to reach the writer's mutex is released again once
// the prune is done, so a sweep racing a webhook deletion cannot // the prune is done, so a sweep racing a target deletion cannot
// resurrect the writer the eviction just dropped. // resurrect the writer the eviction just dropped.
func (t *databaseTarget) sweepWebhook( func (t *databaseTarget) sweepArchive(
webhookID string, expiry time.Duration, targetID string, expiry time.Duration,
) error { ) error {
path, err := t.archivePath(webhookID) w, created, err := t.sweepWriterFor(targetID)
if err != nil {
return err
}
// Check before taking a writer at all: a webhook whose
// archive has never been created gets no writer, no handle,
// and no file.
if !fileExists(path) {
return nil
}
w, created, err := t.sweepWriterFor(webhookID)
if err != nil { if err != nil {
return err return err
} }
if created { if created {
defer t.releaseSweepWriter(webhookID, w) defer t.releaseSweepWriter(targetID, w)
} }
return w.sweepExpired(expiry) return w.sweepExpired(expiry)
+104 -19
View File
@@ -4,8 +4,10 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io/fs"
"log/slog" "log/slog"
"os" "os"
"path/filepath"
"sync" "sync"
"time" "time"
@@ -41,7 +43,7 @@ const (
var ( var (
// errArchiveMissingWebhookID is returned when an event to // errArchiveMissingWebhookID is returned when an event to
// archive has no webhook id to key its archive file on. // archive has no webhook id to record in its archive row.
errArchiveMissingWebhookID = errors.New( errArchiveMissingWebhookID = errors.New(
"cannot archive event without a webhook id", "cannot archive event without a webhook id",
) )
@@ -61,13 +63,19 @@ var (
) )
// errArchiveWriterEvicted is returned when a writer that has // errArchiveWriterEvicted is returned when a writer that has
// been evicted (its webhook was deleted, or its last database // been evicted (its target or its webhook was deleted) is used
// target was removed) is used again. An evicted writer is no // again. An evicted writer is no longer in the registry, so
// longer in the registry, so reopening its file would leak a // reopening its file would leak a handle nothing owns.
// handle nothing owns.
errArchiveWriterEvicted = errors.New( errArchiveWriterEvicted = errors.New(
"archive writer has been evicted", "archive writer has been evicted",
) )
// ErrArchiveNameTaken is returned when an archive cannot be
// renamed because a file already has the new name. That file may
// be an archive with rows of its own, so it is never replaced.
ErrArchiveNameTaken = errors.New(
"a file already has the archive's new name",
)
) )
// databaseTargetConfig is the optional per-target JSON config // databaseTargetConfig is the optional per-target JSON config
@@ -80,7 +88,7 @@ type databaseTargetConfig struct {
} }
// archivedEvent is one fully captured webhook event stored in a // archivedEvent is one fully captured webhook event stored in a
// per-webhook archive database for long-term retention. It is a // database target's archive for long-term retention. It is a
// self-contained copy — independent of the per-webhook event // self-contained copy — independent of the per-webhook event
// database, which may prune events under its own retention. // database, which may prune events under its own retention.
type archivedEvent struct { type archivedEvent struct {
@@ -170,8 +178,8 @@ func ValidateArchiveExpiry(expiry string) error {
return nil return nil
} }
// archiveWriter owns one per-webhook archive SQLite file. It // archiveWriter owns one database target's archive SQLite file.
// serialises writes, and after each write closes and reopens // It serialises writes, and after each write closes and reopens
// the file (debounced to at most once per debounce window) so // the file (debounced to at most once per debounce window) so
// an operator can move the file away for offline archiving. The // an operator can move the file away for offline archiving. The
// next write recreates a moved or removed file, because the // next write recreates a moved or removed file, because the
@@ -186,17 +194,26 @@ type archiveWriter struct {
lastReopen time.Time lastReopen time.Time
reopens int reopens int
// now is the clock the reopen debounce is measured on. It is
// time.Now outside tests.
now func() time.Time
// evicted marks a writer that has been removed from the // evicted marks a writer that has been removed from the
// per-webhook registry. Its handle is closed and it must // registry. Its handle is closed and it must never open the
// never open the file again: nothing holds it any more, so a // file again: nothing holds it any more, so a reopen would
// reopen would leak the handle for the process lifetime. // leak the handle for the process lifetime.
evicted bool evicted bool
// webhookID is the webhook the archive's target belongs to,
// so deleting the webhook can find its writers. It is set
// when the writer is created and never changes.
webhookID string
// sweepOwned marks a registry entry that the idle sweep // sweepOwned marks a registry entry that the idle sweep
// created because no writer was cached for the webhook. The // created because no writer was cached for the target. The
// sweep removes such an entry again when it is done, so a // sweep removes such an entry again when it is done, so a
// sweep can never leave — or resurrect — a registry entry // sweep can never leave — or resurrect — a registry entry
// for a webhook that has been deleted. A delivery that adopts // for a target that has been deleted. A delivery that adopts
// the writer clears the flag, handing the entry to the // the writer clears the flag, handing the entry to the
// registry proper. // registry proper.
// //
@@ -215,6 +232,7 @@ func newArchiveWriter(
path: path, path: path,
log: log, log: log,
debounce: archiveReopenDebounce, debounce: archiveReopenDebounce,
now: time.Now,
} }
} }
@@ -250,7 +268,7 @@ func (w *archiveWriter) write(
) )
} }
if time.Since(w.lastReopen) >= w.debounce { if w.now().Sub(w.lastReopen) >= w.debounce {
return w.reopen(expiry) return w.reopen(expiry)
} }
@@ -310,7 +328,7 @@ func (w *archiveWriter) openMode(
} }
w.db = gdb w.db = gdb
w.lastReopen = time.Now() w.lastReopen = w.now()
w.reopens++ w.reopens++
if expiry > 0 { if expiry > 0 {
@@ -385,11 +403,78 @@ func (w *archiveWriter) sweepExpired(expiry time.Duration) error {
return nil return nil
} }
// rename gives the archive file a new name in the same directory,
// and the writer uses the file under that name from now on. The
// handle is closed first, which folds the -wal into the .db; any
// -wal or -shm still beside the file (left by a crash) is moved with
// it, because SQLite finds them by name. A missing file is not an
// error: the operator may have moved it away, and the next write
// creates it under the new name.
//
// If a file already has the new name, nothing is moved and the
// error is ErrArchiveNameTaken. If one file fails to move, those
// already moved are moved back before the error is returned, so the
// archive is never split across two names.
func (w *archiveWriter) rename(name string) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.evicted {
return fmt.Errorf(
"%w: %s", errArchiveWriterEvicted, w.path,
)
}
path := filepath.Join(filepath.Dir(w.path), name)
if path == w.path {
return nil
}
suffixes := []string{"", "-wal", "-shm"}
for _, suffix := range suffixes {
if fileExists(path + suffix) {
return fmt.Errorf(
"%w: %s", ErrArchiveNameTaken, name+suffix,
)
}
}
w.close()
for i, suffix := range suffixes {
err := os.Rename(w.path+suffix, path+suffix)
if err == nil || errors.Is(err, fs.ErrNotExist) {
continue
}
for _, moved := range suffixes[:i] {
backErr := os.Rename(path+moved, w.path+moved)
if backErr != nil && !errors.Is(backErr, fs.ErrNotExist) {
w.log.Error(
"failed to move archive file back",
"from", path+moved,
"to", w.path+moved,
"error", backErr,
)
}
}
return fmt.Errorf(
"renaming archive %s to %s: %w", w.path+suffix, path+suffix, err,
)
}
w.path = path
return nil
}
// evict closes the writer's handle and marks it unusable. It is // evict closes the writer's handle and marks it unusable. It is
// called when the writer leaves the registry, either because the // called when the writer leaves the registry, because its target
// webhook was deleted or because its last database target was // or its webhook was deleted, or at shutdown. The archive FILE is
// removed. The archive FILE is deliberately left on disk: it is // deliberately left on disk: it is long-term storage an operator
// long-term storage an operator may still want. // may still want.
func (w *archiveWriter) evict() { func (w *archiveWriter) evict() {
w.mu.Lock() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()
+93 -83
View File
@@ -17,85 +17,109 @@ import (
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
) )
// evictTestEngine builds an engine backed by a temporary data // deliverTo archives one event to a database target, leaving the
// directory and returns it along with that directory. // target's writer cached with its handle open.
func evictTestEngine(t *testing.T) (*delivery.Engine, string) { func deliverTo(
t *testing.T, env *archiveEnv, tgt *database.Target,
) {
t.Helper() t.Helper()
dataDir := t.TempDir()
eng := delivery.NewTestEngineWithDB(
nil,
database.NewTestWebhookDBManager(dataDir),
archiveTestLogger(),
&http.Client{Timeout: 5 * time.Second},
1,
)
return eng, dataDir
}
// TestEvictWebhook_ClosesAndRemovesWriter proves that evicting
// a webhook drops its archive writer from the registry and
// closes the open archive handle, rather than leaving both
// alive for the process lifetime.
func TestEvictWebhook_ClosesAndRemovesWriter(t *testing.T) {
t.Parallel()
eng, dataDir := evictTestEngine(t)
webhookDB := testWebhookDB(t) webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"archived":true}`) event := seedEvent(t, webhookDB, `{"archived":true}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
eng.ExportDeliverDatabase(webhookDB, d) env.eng.ExportDeliverDatabase(
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
webhookID := event.WebhookID
require.True(
t, eng.ExportHasArchiveWriter(webhookID),
"a delivery should have cached an archive writer",
) )
}
// TestEvictWebhook_ClosesAndRemovesWriter proves that evicting
// a webhook drops the archive writers of its database targets
// from the registry and closes their open handles, rather than
// leaving them alive for the process lifetime, and leaves another
// webhook's writer alone.
func TestEvictWebhook_ClosesAndRemovesWriter(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
first := env.seedDatabaseTarget(t, "")
second := env.addDatabaseTarget(t, first.WebhookID, "")
other := env.seedDatabaseTarget(t, "")
for _, tgt := range []*database.Target{first, second, other} {
deliverTo(t, env, tgt)
require.True( require.True(
t, eng.ExportArchiveHandleOpen(webhookID), 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",
) )
}
eng.EvictWebhook(webhookID) env.eng.EvictWebhook(first.WebhookID)
for _, tgt := range []*database.Target{first, second} {
assert.False( assert.False(
t, eng.ExportHasArchiveWriter(webhookID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"eviction should remove the registry entry", "eviction should remove the registry entry",
) )
assert.False( assert.False(
t, eng.ExportArchiveHandleOpen(webhookID), t, env.eng.ExportArchiveHandleOpen(tgt.ID),
"eviction should close the archive handle", "eviction should close the archive handle",
) )
archivePath := filepath.Join(
dataDir, fmt.Sprintf("archive-%s.db", webhookID),
)
assert.FileExists( assert.FileExists(
t, archivePath, t, env.archivePath(tgt),
"eviction must not delete the archive file", "eviction must not delete the archive file",
) )
}
assert.True(
t, env.eng.ExportArchiveHandleOpen(other.ID),
"another webhook's writer must be left alone",
)
}
// TestEvictTarget_LeavesOtherTargets proves that evicting one
// database target leaves the writer of another target of the same
// webhook in place.
func TestEvictTarget_LeavesOtherTargets(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
doomed := env.seedDatabaseTarget(t, "")
kept := env.addDatabaseTarget(t, doomed.WebhookID, "")
deliverTo(t, env, doomed)
deliverTo(t, env, kept)
env.eng.EvictTarget(doomed.ID)
assert.False(t, env.eng.ExportHasArchiveWriter(doomed.ID))
assert.FileExists(
t, env.archivePath(doomed),
"eviction must not delete the archive file",
)
assert.True(
t, env.eng.ExportArchiveHandleOpen(kept.ID),
"the other target's writer must be left alone",
)
} }
// TestEvictWebhook_UnknownWebhookIsNoOp proves eviction is safe // TestEvictWebhook_UnknownWebhookIsNoOp proves eviction is safe
// for the common case of a webhook that never had a database // for the common case of a webhook or target that never had an
// target, and that repeating it does not panic. // archive writer, and that repeating it does not panic.
func TestEvictWebhook_UnknownWebhookIsNoOp(t *testing.T) { func TestEvictWebhook_UnknownWebhookIsNoOp(t *testing.T) {
t.Parallel() t.Parallel()
eng, _ := evictTestEngine(t) env := setupArchiveTest(t)
assert.NotPanics(t, func() { assert.NotPanics(t, func() {
eng.EvictWebhook("no-such-webhook") env.eng.EvictWebhook("no-such-webhook")
eng.EvictWebhook("no-such-webhook") env.eng.EvictWebhook("no-such-webhook")
env.eng.EvictTarget("no-such-target")
env.eng.EvictTarget("no-such-target")
}) })
assert.False( assert.False(
t, eng.ExportHasArchiveWriter("no-such-webhook"), t, env.eng.ExportHasArchiveWriter("no-such-target"),
"eviction must not create a writer", "eviction must not create a writer",
) )
} }
@@ -289,17 +313,14 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
) { ) {
t.Parallel() t.Parallel()
eng, _ := evictTestEngine(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"archived":true}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
// Prime the registry so the test can hold the very writer the // Prime the registry so the test can hold the very writer the
// eviction is about to detach. // eviction is about to detach.
eng.ExportDeliverDatabase(webhookDB, d) deliverTo(t, env, tgt)
w := eng.ExportArchiveWriterFor(event.WebhookID) w := env.eng.ExportArchiveWriterFor(tgt.ID)
require.NotNil(t, w) require.NotNil(t, w)
require.True(t, w.HandleOpen()) require.True(t, w.HandleOpen())
@@ -309,7 +330,7 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
// eviction has to contend for the writer's mutex. // eviction has to contend for the writer's mutex.
race.awaitFirstWrite() race.awaitFirstWrite()
eng.EvictWebhook(event.WebhookID) env.eng.EvictWebhook(tgt.WebhookID)
sawEvicted, otherErr := race.wait() sawEvicted, otherErr := race.wait()
@@ -324,41 +345,33 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
"been evicted", "been evicted",
) )
assert.False( assert.False(
t, eng.ExportHasArchiveWriter(event.WebhookID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"the registry entry must stay gone", "the registry entry must stay gone",
) )
} }
// TestEvictWebhook_LaterDeliveryRecreatesWriter proves eviction // TestEvictWebhook_LaterDeliveryRecreatesWriter proves eviction
// does not break archiving for a webhook that is still alive: a // does not break archiving for a target that is still alive: a
// subsequent delivery gets a brand new writer from the registry. // subsequent delivery gets a brand new writer from the registry.
// It says nothing about the evicted writer itself — that is what // It says nothing about the evicted writer itself — that is what
// TestEvictedWriter_WriteDoesNotReopenFile covers. // TestEvictedWriter_WriteDoesNotReopenFile covers.
func TestEvictWebhook_LaterDeliveryRecreatesWriter(t *testing.T) { func TestEvictWebhook_LaterDeliveryRecreatesWriter(t *testing.T) {
t.Parallel() t.Parallel()
eng, _ := evictTestEngine(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
webhookDB := testWebhookDB(t) deliverTo(t, env, tgt)
event := seedEvent(t, webhookDB, `{"archived":true}`) require.True(t, env.eng.ExportHasArchiveWriter(tgt.ID))
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
eng.ExportDeliverDatabase(webhookDB, d) env.eng.EvictWebhook(tgt.WebhookID)
require.True(
t, eng.ExportHasArchiveWriter(event.WebhookID),
)
eng.EvictWebhook(event.WebhookID) // A fresh delivery for the same target gets a brand new
// A fresh delivery for the same webhook gets a brand new
// writer from the registry, so archiving keeps working. // writer from the registry, so archiving keeps working.
second := seedDatabaseTargetDelivery( deliverTo(t, env, tgt)
t, webhookDB, event, "",
)
eng.ExportDeliverDatabase(webhookDB, second)
assert.True( assert.True(
t, eng.ExportHasArchiveWriter(event.WebhookID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"a later delivery should recreate the writer", "a later delivery should recreate the writer",
) )
} }
@@ -370,19 +383,16 @@ func TestEvictWebhook_LaterDeliveryRecreatesWriter(t *testing.T) {
func TestEngineStop_WriteAfterStopIsRefused(t *testing.T) { func TestEngineStop_WriteAfterStopIsRefused(t *testing.T) {
t.Parallel() t.Parallel()
eng, _ := evictTestEngine(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
webhookDB := testWebhookDB(t) deliverTo(t, env, tgt)
event := seedEvent(t, webhookDB, `{"archived":true}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
eng.ExportDeliverDatabase(webhookDB, d) w := env.eng.ExportArchiveWriterFor(tgt.ID)
w := eng.ExportArchiveWriterFor(event.WebhookID)
require.NotNil(t, w) require.NotNil(t, w)
require.True(t, w.HandleOpen()) require.True(t, w.HandleOpen())
require.NoError(t, eng.ExportStop(context.Background())) require.NoError(t, env.eng.ExportStop(context.Background()))
err := w.Write(evictTestRow("ev-after-stop"), 0) err := w.Write(evictTestRow("ev-after-stop"), 0)
@@ -395,7 +405,7 @@ func TestEngineStop_WriteAfterStopIsRefused(t *testing.T) {
"a refused write must not reopen the archive", "a refused write must not reopen the archive",
) )
assert.False( assert.False(
t, eng.ExportHasArchiveWriter(event.WebhookID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"the stop should empty the registry", "the stop should empty the registry",
) )
+275
View File
@@ -0,0 +1,275 @@
package delivery
import (
"compress/gzip"
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log/slog"
"path/filepath"
"time"
"unicode/utf8"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/gormlog"
)
// archiveTableQuery counts the archive's table: 0 when the archive
// writer has created the file but not yet the table in it.
const archiveTableQuery = "SELECT count(*) FROM sqlite_master " +
"WHERE type = 'table' AND name = 'archived_events'"
// ArchivePath returns where a database target's archive file is: in
// the data directory, beside the webhook's event database, under the
// name ArchiveFileName gives it.
func ArchivePath(
dbMgr *database.WebhookDBManager,
webhook *database.Webhook,
target *database.Target,
) string {
return filepath.Join(
filepath.Dir(dbMgr.DBPath(webhook.ID)),
ArchiveFileName(webhook.Name, target.Name, target.ID),
)
}
// ArchiveExportFileName returns the name a database target's archive
// downloads under:
// archive-WEBHOOKNAME-TARGETNAME-YYYYMMDDTHHMMSSZ.json.gz, the names
// made safe as in ArchiveFileName and the time in UTC.
func ArchiveExportFileName(
webhookName, targetName string, at time.Time,
) string {
return "archive-" + archiveNamePart(webhookName) + "-" +
archiveNamePart(targetName) + "-" +
at.UTC().Format("20060102T150405Z") + ".json.gz"
}
// ArchiveExport is a database target's archive opened for download.
// It reads the file on its own connection, inside one read-only
// transaction, so it writes out the archive as it stood when
// OpenArchiveExport returned.
//
// Archives are in WAL mode, where a reader works from a snapshot and
// never blocks a writer: archive writes go on while an export is open,
// and the export does not see them. SQLite cannot checkpoint the -wal
// past an open snapshot, so the -wal grows until the export is closed.
type ArchiveExport struct {
db *sql.DB
tx *gorm.DB
// empty is true when there is nothing to read: no file, or a file
// without the archive's table yet.
empty bool
}
// exportedName is how an export names its webhook and its target.
type exportedName struct {
ID string `json:"id"`
Name string `json:"name"`
}
// OpenArchiveExport opens the archive file at path for export and
// takes the snapshot the export reads. It never creates the file: with
// no file at path, the export has no rows.
//
// Once it has returned, the file is open, so a rename or a move of it
// does not affect the export, which reads the same file under its new
// name.
//
// The transaction lasts as long as ctx does, so ctx must last for the
// whole export.
func OpenArchiveExport(
ctx context.Context, path string, log *slog.Logger,
) (*ArchiveExport, error) {
if !fileExists(path) {
return &ArchiveExport{empty: true}, nil
}
db, err := database.OpenSQLite(path, archiveModeExisting)
if err != nil {
return nil, fmt.Errorf("opening archive %s: %w", path, err)
}
gdb, err := gorm.Open(
sqlite.Dialector{Conn: db}, &gorm.Config{
// Never leave this at GORM's default. See
// internal/gormlog.
Logger: gormlog.New(log),
},
)
if err != nil {
_ = db.Close()
return nil, fmt.Errorf("opening archive %s: %w", path, err)
}
// ReadOnly makes the driver begin a deferred transaction in place
// of the BEGIN IMMEDIATE the connection string asks for, so the
// export never takes the archive's write lock.
tx := gdb.WithContext(ctx).Begin(&sql.TxOptions{ReadOnly: true})
if tx.Error != nil {
_ = db.Close()
return nil, fmt.Errorf("reading archive %s: %w", path, tx.Error)
}
// The transaction's first read is what takes the snapshot.
var tables int
err = tx.Raw(archiveTableQuery).Row().Scan(&tables)
if err != nil {
_ = tx.Rollback()
_ = db.Close()
return nil, fmt.Errorf("reading archive %s: %w", path, err)
}
return &ArchiveExport{db: db, tx: tx, empty: tables == 0}, nil
}
// WriteGzipJSON writes the export to w as one gzipped JSON object:
// webhook and target, each an id and a name; exported_at; and
// archived_events, one object per archived row, keyed by column name.
// A body that is not valid UTF-8 cannot be a JSON string, so it is
// written in base64, with "body_encoding": "base64" beside it.
//
// Each row is written out before the next is read, so neither the
// archive nor its JSON is ever held in memory whole. After an error
// the gzip stream is left unfinished, so what was written does not
// decompress as a whole file.
func (x *ArchiveExport) WriteGzipJSON(
ctx context.Context,
w io.Writer,
webhook *database.Webhook,
target *database.Target,
exportedAt time.Time,
) error {
head, err := json.Marshal(map[string]any{
"webhook": exportedName{ID: webhook.ID, Name: webhook.Name},
"target": exportedName{ID: target.ID, Name: target.Name},
"exported_at": exportedAt.UTC(),
})
if err != nil {
return fmt.Errorf("encoding archive export: %w", err)
}
zw := gzip.NewWriter(w)
err = x.writeJSON(ctx, zw, head)
if err != nil {
return fmt.Errorf("writing archive export: %w", err)
}
return zw.Close()
}
// Close ends the export's transaction and closes its connection.
func (x *ArchiveExport) Close() error {
if x.db == nil {
return nil
}
_ = x.tx.Rollback()
return x.db.Close()
}
// writeJSON writes head with archived_events added as its last key,
// the rows going into it one at a time.
func (x *ArchiveExport) writeJSON(
ctx context.Context, w io.Writer, head []byte,
) error {
// head goes out without its closing brace, so that
// archived_events can follow it.
_, err := w.Write(head[:len(head)-1])
if err != nil {
return err
}
_, err = io.WriteString(w, `,"archived_events":[`)
if err != nil {
return err
}
err = x.writeRows(ctx, w)
if err != nil {
return err
}
_, err = io.WriteString(w, "\n]}\n")
return err
}
// writeRows writes each archived row to w, oldest first, one per line,
// separated by commas.
func (x *ArchiveExport) writeRows(ctx context.Context, w io.Writer) error {
if x.empty {
return nil
}
rows, err := x.tx.WithContext(ctx).
Model(&archivedEvent{}).Order("id").Rows()
if err != nil {
return err
}
defer func() { _ = rows.Close() }()
for sep := "\n"; rows.Next(); sep = ",\n" {
var ev archivedEvent
err = x.tx.ScanRows(rows, &ev)
if err != nil {
return err
}
_, err = io.WriteString(w, sep)
if err != nil {
return err
}
err = writeRow(w, &ev)
if err != nil {
return err
}
}
return rows.Err()
}
// writeRow writes an archived row to w as a JSON object keyed by
// column name, its body in base64 when it is not valid UTF-8.
func writeRow(w io.Writer, ev *archivedEvent) error {
row := map[string]any{
"id": ev.ID,
"event_id": ev.EventID,
"webhook_id": ev.WebhookID,
"entrypoint_id": ev.EntrypointID,
"method": ev.Method,
"headers": ev.Headers,
"body": ev.Body,
"content_type": ev.ContentType,
"archived_at": ev.ArchivedAt.UTC(),
}
if !utf8.ValidString(ev.Body) {
row["body"] = base64.StdEncoding.EncodeToString([]byte(ev.Body))
row["body_encoding"] = "base64"
}
line, err := json.Marshal(row)
if err != nil {
return err
}
_, err = w.Write(line)
return err
}
@@ -0,0 +1,393 @@
package delivery_test
import (
"bufio"
"bytes"
"compress/gzip"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
// The webhook and the target the export tests' archives belong to.
const (
exportWebhookID = "wh-export"
exportWebhookName = "Orders (EU)"
exportTargetID = "tgt-export"
exportTargetName = "Long-term archive"
)
const (
// binaryBody is a body that is not valid UTF-8.
binaryBody = "\xff\xfe\x00\x01binary\x80"
// openedEventID is the event the snapshot tests archive before
// they open the export.
openedEventID = "opened"
)
// writeExportTo writes export to w as the archive of the export tests'
// webhook and target, exported at 2026-10-02T12:03:04Z.
func writeExportTo(
t *testing.T, export *delivery.ArchiveExport, w io.Writer,
) error {
t.Helper()
return export.WriteGzipJSON(
t.Context(), w,
&database.Webhook{
BaseModel: database.BaseModel{ID: exportWebhookID},
Name: exportWebhookName,
},
&database.Target{
BaseModel: database.BaseModel{ID: exportTargetID},
Name: exportTargetName,
},
time.Date(2026, 10, 2, 12, 3, 4, 0, time.UTC),
)
}
// exportArchive runs a whole export of the archive at path and returns
// its JSON, decompressed and parsed.
func exportArchive(t *testing.T, path string) map[string]any {
t.Helper()
export, err := delivery.OpenArchiveExport(
t.Context(), path, archiveTestLogger(),
)
require.NoError(t, err)
defer func() { require.NoError(t, export.Close()) }()
return writeExport(t, export)
}
// writeExport writes an opened export and returns its JSON,
// decompressed and parsed. Reading to the end makes the gzip reader
// check that the stream was finished.
func writeExport(
t *testing.T, export *delivery.ArchiveExport,
) map[string]any {
t.Helper()
var buf bytes.Buffer
require.NoError(t, writeExportTo(t, export, &buf))
zr, err := gzip.NewReader(&buf)
require.NoError(t, err)
raw, err := io.ReadAll(zr)
require.NoError(t, err)
var got map[string]any
require.NoError(t, json.Unmarshal(raw, &got))
return got
}
// exportedEvents returns an export's archived_events.
func exportedEvents(t *testing.T, got map[string]any) []map[string]any {
t.Helper()
list, ok := got["archived_events"].([]any)
require.True(t, ok, "archived_events must be an array: %v", got)
events := make([]map[string]any, len(list))
for i, v := range list {
events[i], ok = v.(map[string]any)
require.True(t, ok, "an archived event must be an object: %v", v)
}
return events
}
// exportedEventIDs returns the event_id of each of an export's
// archived_events.
func exportedEventIDs(t *testing.T, got map[string]any) []string {
t.Helper()
events := exportedEvents(t, got)
ids := make([]string, 0, len(events))
for _, ev := range events {
ids = append(ids, fmt.Sprint(ev["event_id"]))
}
return ids
}
// TestArchiveExport_MatchesStoredRows proves an export holds the
// webhook, the target, the time, and every column of every stored
// row: a body that is valid UTF-8 as a string, and one that is not in
// base64, marked as such.
func TestArchiveExport_MatchesStoredRows(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive.db")
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
bodies := []string{`{"order":1}`, "plain text", "", binaryBody}
for i, body := range bodies {
require.NoError(t, w.Write(delivery.ExportArchivedEvent{
EventID: fmt.Sprintf("ev-%d", i),
WebhookID: exportWebhookID,
EntrypointID: "ep-1",
Method: "POST",
Headers: `{"X-Test":["yes"]}`,
Body: body,
ContentType: testContentType,
}, 0))
}
var stored []delivery.ExportArchivedEvent
require.NoError(t, openArchiveDBForRead(t, path).
Order("id").Find(&stored).Error)
got := exportArchive(t, path)
assert.Equal(t,
map[string]any{"id": exportWebhookID, "name": exportWebhookName},
got["webhook"],
)
assert.Equal(t,
map[string]any{"id": exportTargetID, "name": exportTargetName},
got["target"],
)
assert.Equal(t, "2026-10-02T12:03:04Z", got["exported_at"])
events := exportedEvents(t, got)
require.Len(t, events, len(bodies))
for i, row := range stored {
assertExportedRow(t, row, events[i])
}
}
// assertExportedRow checks that ev, from an export, holds every column
// of the stored row.
func assertExportedRow(
t *testing.T, row delivery.ExportArchivedEvent, ev map[string]any,
) {
t.Helper()
archivedAt, err := time.Parse(
time.RFC3339Nano, fmt.Sprint(ev["archived_at"]),
)
require.NoError(t, err)
assert.True(t, archivedAt.Equal(row.ArchivedAt))
assert.EqualValues(t, row.ID, ev["id"])
assert.Equal(t, row.EventID, ev["event_id"])
assert.Equal(t, row.WebhookID, ev["webhook_id"])
assert.Equal(t, row.EntrypointID, ev["entrypoint_id"])
assert.Equal(t, row.Method, ev["method"])
assert.Equal(t, row.Headers, ev["headers"])
assert.Equal(t, row.ContentType, ev["content_type"])
if row.Body != binaryBody {
assert.Equal(t, row.Body, ev["body"])
assert.Len(t, ev, 9, "the nine columns and nothing else: %v", ev)
return
}
body, err := base64.StdEncoding.DecodeString(fmt.Sprint(ev["body"]))
require.NoError(t, err)
assert.Equal(t, binaryBody, string(body))
assert.Equal(t, "base64", ev["body_encoding"])
assert.Len(t, ev, 10, "the nine columns and body_encoding: %v", ev)
}
// TestArchiveExport_Empty proves an archive with nothing in it exports
// as an empty archived_events: no file, which the export must not
// create; a file the archive writer has not yet put its table in; and
// a table with no rows.
func TestArchiveExport_Empty(t *testing.T) {
t.Parallel()
dir := t.TempDir()
missing := filepath.Join(dir, "missing.db")
noTable := filepath.Join(dir, "no-table.db")
noRows := filepath.Join(dir, "no-rows.db")
require.NoError(t, os.WriteFile(noTable, nil, 0o600))
require.NoError(t,
delivery.NewExportArchiveWriter(noRows, archiveTestLogger(), 0).
Open(0),
)
for _, path := range []string{missing, noTable, noRows} {
assert.Empty(t, exportedEvents(t, exportArchive(t, path)), path)
}
for _, suffix := range archiveFileSuffixes() {
assert.NoFileExists(t, missing+suffix)
}
}
// TestArchiveExport_ReadsOneSnapshot proves an export writes the
// archive as it was when it was opened, and holds up no archive
// write: a row written while the export is open is stored, and is not
// in the export. A write held up for the whole busy timeout would
// fail.
func TestArchiveExport_ReadsOneSnapshot(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive.db")
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: openedEventID}, 0))
export, err := delivery.OpenArchiveExport(
t.Context(), path, archiveTestLogger(),
)
require.NoError(t, err)
defer func() { require.NoError(t, export.Close()) }()
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "during"}, 0))
assert.Equal(t,
[]string{openedEventID}, exportedEventIDs(t, writeExport(t, export)),
)
var stored int64
require.NoError(t, openArchiveDBForRead(t, path).
Model(&delivery.ExportArchivedEvent{}).Count(&stored).Error)
assert.Equal(t, int64(2), stored)
}
// TestArchiveExport_SurvivesRename proves that renaming the archive
// while an export of it is open, as renaming its webhook or target
// does, leaves the export reading the same file.
func TestArchiveExport_SurvivesRename(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive-old.db")
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: openedEventID}, 0))
export, err := delivery.OpenArchiveExport(
t.Context(), path, archiveTestLogger(),
)
require.NoError(t, err)
defer func() { require.NoError(t, export.Close()) }()
require.NoError(t, w.Rename("archive-new.db"))
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "after"}, 0))
require.NoFileExists(t, path)
assert.Equal(t,
[]string{openedEventID}, exportedEventIDs(t, writeExport(t, export)),
)
}
// heapPeak is an io.Writer that discards what it is given and records
// the largest heap it saw at a write. It collects garbage before each
// reading, so the heap it reads is what is still held.
type heapPeak struct {
max uint64
}
func (p *heapPeak) Write(b []byte) (int, error) {
var m runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&m)
p.max = max(p.max, m.HeapAlloc)
return len(b), nil
}
// TestArchiveExport_Streams proves an export holds neither the archive
// nor its output in memory whole: exporting a 3 MiB archive grows the
// heap by less than half of that. The bodies are random bytes in
// base64, which gzip shrinks by only a quarter, so an export that read
// every row before writing, or built the JSON or the gzipped file
// before writing it, would hold at least 2.25 MiB at a write. A
// streaming export holds about 1 MiB, most of it gzip's compressor,
// which is why the archive is not smaller.
//
//nolint:paralleltest // It measures the heap, which tests share.
func TestArchiveExport_Streams(t *testing.T) {
const (
rows = 96
bodySize = 32 << 10
limit = rows * bodySize / 2
)
path := filepath.Join(t.TempDir(), "archive.db")
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
// Base64 makes four characters of every three bytes.
random := make([]byte, bodySize/4*3)
for range rows {
_, _ = rand.Read(random)
require.NoError(t, w.Write(delivery.ExportArchivedEvent{
Body: base64.StdEncoding.EncodeToString(random),
}, 0))
}
export, err := delivery.OpenArchiveExport(
t.Context(), path, archiveTestLogger(),
)
require.NoError(t, err)
defer func() { require.NoError(t, export.Close()) }()
// Through a buffer, the heap is read once per 256 KiB of output
// rather than at each of gzip's small writes, which takes far
// longer.
peak := &heapPeak{}
buffered := bufio.NewWriterSize(peak, 256<<10)
runtime.GC()
var start runtime.MemStats
runtime.ReadMemStats(&start)
require.NoError(t, writeExportTo(t, export, buffered))
require.NoError(t, buffered.Flush())
assert.Less(t, peak.max, start.HeapAlloc+limit,
"heap at the start %d, at its peak %d", start.HeapAlloc, peak.max,
)
}
// TestArchiveExportFileName proves the download is named for the
// webhook and the target, with the names made safe as for the archive
// file, and the export time in UTC.
func TestArchiveExportFileName(t *testing.T) {
t.Parallel()
cest := time.FixedZone("CEST", int((2 * time.Hour).Seconds()))
assert.Equal(t,
"archive-orders-eu-long-term-archive-20261002T120304Z.json.gz",
delivery.ArchiveExportFileName(
exportWebhookName, exportTargetName,
time.Date(2026, 10, 2, 14, 3, 4, 0, cest),
),
)
}
+318 -44
View File
@@ -4,13 +4,12 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
@@ -74,25 +73,18 @@ func removeArchiveFiles(t *testing.T, path string) {
// TestDeliverDatabase_ArchivesEvent verifies that delivering to // TestDeliverDatabase_ArchivesEvent verifies that delivering to
// a database target marks the delivery delivered and archives // a database target marks the delivery delivered and archives
// the full event into a separate per-webhook archive file. // the full event into the target's own archive file.
func TestDeliverDatabase_ArchivesEvent(t *testing.T) { func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
t.Parallel() t.Parallel()
dataDir := t.TempDir() env := setupArchiveTest(t)
dbMgr := database.NewTestWebhookDBManager(dataDir) tgt := env.seedDatabaseTarget(t, "")
e := delivery.NewTestEngineWithDB(
nil, dbMgr,
archiveTestLogger(),
&http.Client{Timeout: 5 * time.Second},
1,
)
webhookDB := testWebhookDB(t) webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"archived":true}`) event := seedEvent(t, webhookDB, `{"archived":true}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, "") d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
e.ExportDeliverDatabase(webhookDB, d) env.eng.ExportDeliverDatabase(webhookDB, d)
var updated database.Delivery var updated database.Delivery
@@ -105,8 +97,7 @@ func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
) )
archivePath := filepath.Join( archivePath := filepath.Join(
dataDir, env.dataDir, "archive-sweep-test-archive-"+tgt.ID+".db",
fmt.Sprintf("archive-%s.db", event.WebhookID),
) )
assert.FileExists(t, archivePath) assert.FileExists(t, archivePath)
@@ -193,13 +184,20 @@ func TestArchiveWriter_RecreatesAfterRemoval(
func TestArchiveWriter_ReopenDebounce(t *testing.T) { func TestArchiveWriter_ReopenDebounce(t *testing.T) {
t.Parallel() t.Parallel()
// A generous debounce keeps the two rapid writes inside const debounce = 2 * time.Second
// the window even on a heavily loaded test machine.
path := filepath.Join(t.TempDir(), "archive-wh.db") path := filepath.Join(t.TempDir(), "archive-wh.db")
w := delivery.NewExportArchiveWriter( w := delivery.NewExportArchiveWriter(
path, archiveTestLogger(), 2*time.Second, 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( require.NoError(t, w.Write(
delivery.ExportArchivedEvent{EventID: "a"}, 0, delivery.ExportArchivedEvent{EventID: "a"}, 0,
)) ))
@@ -211,7 +209,7 @@ func TestArchiveWriter_ReopenDebounce(t *testing.T) {
// initial open — no extra close/reopen. // initial open — no extra close/reopen.
assert.Equal(t, 1, w.Reopens()) assert.Equal(t, 1, w.Reopens())
time.Sleep(2100 * time.Millisecond) now = now.Add(debounce)
require.NoError(t, w.Write( require.NoError(t, w.Write(
delivery.ExportArchivedEvent{EventID: "c"}, 0, delivery.ExportArchivedEvent{EventID: "c"}, 0,
@@ -288,31 +286,31 @@ func TestParseArchiveExpiry(t *testing.T) {
} }
} }
// seedDatabaseTargetDelivery seeds a pending delivery for a // seedDatabaseTargetDelivery seeds a pending delivery of an event
// database target with the given config JSON and returns the // to a database target and returns the in-memory delivery the
// in-memory delivery the target handler is invoked with. // target handler is invoked with.
func seedDatabaseTargetDelivery( func seedDatabaseTargetDelivery(
t *testing.T, t *testing.T,
webhookDB *gorm.DB, webhookDB *gorm.DB,
event database.Event, event database.Event,
config string, tgt *database.Target,
) *database.Delivery { ) *database.Delivery {
t.Helper() t.Helper()
dlv := seedDelivery( dlv := seedDelivery(
t, webhookDB, event.ID, uuid.New().String(), t, webhookDB, event.ID, tgt.ID,
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
d := &database.Delivery{ d := &database.Delivery{
EventID: event.ID, EventID: event.ID,
TargetID: dlv.TargetID, TargetID: tgt.ID,
Status: database.DeliveryStatusPending, Status: database.DeliveryStatusPending,
Event: event, Event: event,
Target: database.Target{ Target: database.Target{
Name: "test-db", Name: tgt.Name,
Type: database.TargetTypeDatabase, Type: database.TargetTypeDatabase,
Config: config, Config: tgt.Config,
}, },
} }
d.ID = dlv.ID d.ID = dlv.ID
@@ -330,22 +328,14 @@ func TestDeliverDatabase_ArchiveFailureFailsDelivery(
) { ) {
t.Parallel() t.Parallel()
dataDir := t.TempDir() env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"nonsense"}`)
e := delivery.NewTestEngineWithDB(
nil, database.NewTestWebhookDBManager(dataDir),
archiveTestLogger(),
&http.Client{Timeout: 5 * time.Second},
1,
)
webhookDB := testWebhookDB(t) webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"archived":false}`) event := seedEvent(t, webhookDB, `{"archived":false}`)
d := seedDatabaseTargetDelivery( d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
t, webhookDB, event, `{"expiry":"nonsense"}`,
)
e.ExportDeliverDatabase(webhookDB, d) env.eng.ExportDeliverDatabase(webhookDB, d)
var updated database.Delivery var updated database.Delivery
@@ -373,10 +363,7 @@ func TestDeliverDatabase_ArchiveFailureFailsDelivery(
) )
assert.NoFileExists(t, assert.NoFileExists(t,
filepath.Join( env.archivePath(tgt),
dataDir,
fmt.Sprintf("archive-%s.db", event.WebhookID),
),
"no archive file should exist for a failed config", "no archive file should exist for a failed config",
) )
} }
@@ -400,3 +387,290 @@ func TestValidateArchiveExpiry(t *testing.T) {
) )
} }
} }
// TestArchiveFileName pins the archive file name and the rules
// that make a webhook or target name safe to put in it.
func TestArchiveFileName(t *testing.T) {
t.Parallel()
const id = "3f2a1c9e-8d4b-4c1a-9e2f-0a1b2c3d4e5f"
cases := []struct {
name string
webhook string
target string
want string
}{
{
"plain names", "orders", "archive",
"archive-orders-archive-" + id + ".db",
},
{
"lowercased", "Orders", "Main Archive",
"archive-orders-main-archive-" + id + ".db",
},
{
"a run of other characters is one dash",
`a /\..b`, "c__--d",
"archive-a-b-c-d-" + id + ".db",
},
{
"no dash at either end", " --orders!! ", "(archive)",
"archive-orders-archive-" + id + ".db",
},
{
"path separators", "../../etc/passwd", "a/b",
"archive-etc-passwd-a-b-" + id + ".db",
},
{
"letters outside ASCII are dropped",
"Bestellungen Größe", "café",
"archive-bestellungen-gr-e-caf-" + id + ".db",
},
{
"nothing left is unnamed", "", "!!!",
"archive-unnamed-unnamed-" + id + ".db",
},
{
"cut to 40 characters", strings.Repeat("a", 50), "x",
"archive-" + strings.Repeat("a", 40) + "-x-" + id + ".db",
},
{
"no dash left by the cut",
strings.Repeat("a", 39) + " b", "x",
"archive-" + strings.Repeat("a", 39) + "-x-" + id + ".db",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assert.Equal(
t, tc.want,
delivery.ArchiveFileName(tc.webhook, tc.target, id),
)
})
}
}
// TestDeliverDatabase_EachTargetHasItsOwnArchive proves two
// database targets of one webhook archive into separate files.
func TestDeliverDatabase_EachTargetHasItsOwnArchive(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
first := env.seedDatabaseTarget(t, "")
second := env.addDatabaseTarget(t, first.WebhookID, "")
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`)
for _, tgt := range []*database.Target{first, second} {
env.eng.ExportDeliverDatabase(
webhookDB,
seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
)
}
require.NotEqual(
t, env.archivePath(first), env.archivePath(second),
)
assert.Equal(
t, []string{event.ID},
archivedEventIDs(t, env.archivePath(first)),
)
assert.Equal(
t, []string{event.ID},
archivedEventIDs(t, env.archivePath(second)),
)
}
// TestRename_MovesTheFile proves a rename moves the archive, rows
// and all, and that later writes go to the new name.
func TestRename_MovesTheFile(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
oldPath := env.archivePath(tgt)
webhookDB := testWebhookDB(t)
first := seedEvent(t, webhookDB, `{"n":1}`)
env.eng.ExportDeliverDatabase(
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, first, tgt),
)
require.FileExists(t, oldPath)
require.NoError(
t, env.eng.Rename(tgt.ID, "Orders", "Long Term"),
)
newPath := filepath.Join(
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
)
assert.NoFileExists(t, oldPath)
assert.Equal(t, []string{first.ID}, archivedEventIDs(t, newPath))
second := seedEvent(t, webhookDB, `{"n":2}`)
env.eng.ExportDeliverDatabase(
webhookDB,
seedDatabaseTargetDelivery(t, webhookDB, second, tgt),
)
assert.ElementsMatch(
t, []string{first.ID, second.ID},
archivedEventIDs(t, newPath),
)
assert.NoFileExists(
t, oldPath, "a write after the rename must use the new name",
)
}
// TestRename_NeverReplacesAFile plants a file at the new name, once
// the .db alone, once a lone -wal and once a lone -shm, and proves
// each time that the rename is refused, the planted file survives,
// and the archive keeps its name and its rows.
func TestRename_NeverReplacesAFile(t *testing.T) {
t.Parallel()
for _, suffix := range archiveFileSuffixes() {
t.Run("planted .db"+suffix, func(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
oldPath := env.archivePath(tgt)
webhookDB := testWebhookDB(t)
first := seedEvent(t, webhookDB, `{"n":1}`)
env.eng.ExportDeliverDatabase(
webhookDB,
seedDatabaseTargetDelivery(t, webhookDB, first, tgt),
)
newPath := filepath.Join(
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
)
plantedPath := newPath + suffix
require.NoError(
t, os.WriteFile(plantedPath, []byte("planted"), 0o600),
)
require.ErrorIs(
t, env.eng.Rename(tgt.ID, "Orders", "Long Term"),
delivery.ErrArchiveNameTaken,
)
//nolint:gosec // reads the file the test planted under t.TempDir()
planted, err := os.ReadFile(plantedPath)
require.NoError(t, err)
assert.Equal(t, "planted", string(planted))
second := seedEvent(t, webhookDB, `{"n":2}`)
env.eng.ExportDeliverDatabase(
webhookDB,
seedDatabaseTargetDelivery(t, webhookDB, second, tgt),
)
assert.ElementsMatch(
t, []string{first.ID, second.ID},
archivedEventIDs(t, oldPath),
)
})
}
}
// TestRename_BeforeTheNameIsSaved covers the order the handlers
// use: they rename before they save the new name, so a delivery in
// between must write under the new name although the main database
// still has the old one. It also shows that renaming an archive that
// does not exist yet is not an error.
func TestRename_BeforeTheNameIsSaved(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
require.NoError(
t, env.eng.Rename(tgt.ID, "Orders", "Archive"),
)
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`)
env.eng.ExportDeliverDatabase(
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
)
assert.FileExists(
t,
filepath.Join(
env.dataDir, "archive-orders-archive-"+tgt.ID+".db",
),
)
assert.NoFileExists(t, env.archivePath(tgt))
}
// TestArchiveWriter_RenameMovesSidecars proves a rename carries
// the -wal and -shm a crash can leave beside an archive no handle
// has opened since. SQLite finds them by name, so a -wal left
// behind would lose the transactions it holds.
func TestArchiveWriter_RenameMovesSidecars(t *testing.T) {
t.Parallel()
dir := t.TempDir()
oldPath := filepath.Join(dir, "archive-old.db")
newPath := filepath.Join(dir, "archive-new.db")
for _, suffix := range archiveFileSuffixes() {
require.NoError(
t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600),
)
}
w := delivery.NewExportArchiveWriter(
oldPath, archiveTestLogger(), 0,
)
require.NoError(t, w.Rename("archive-new.db"))
for _, suffix := range archiveFileSuffixes() {
assert.NoFileExists(t, oldPath+suffix)
assert.FileExists(t, newPath+suffix)
}
assert.Equal(t, newPath, w.Path())
}
// TestArchiveWriter_RenameMovesBackOnFailure makes the -wal fail to
// move after the .db has moved, and proves the .db is moved back, so
// the archive is never split across two names. The new name is 255
// bytes, the longest a file name may be, so the .db can take it but
// the -wal, four bytes longer, cannot.
func TestArchiveWriter_RenameMovesBackOnFailure(t *testing.T) {
t.Parallel()
dir := t.TempDir()
oldPath := filepath.Join(dir, "archive-old.db")
newName := strings.Repeat("a", 252) + ".db"
for _, suffix := range archiveFileSuffixes() {
require.NoError(
t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600),
)
}
w := delivery.NewExportArchiveWriter(
oldPath, archiveTestLogger(), 0,
)
require.Error(t, w.Rename(newName))
for _, suffix := range archiveFileSuffixes() {
assert.FileExists(t, oldPath+suffix)
}
assert.NoFileExists(t, filepath.Join(dir, newName))
assert.Equal(t, oldPath, w.Path())
}
+7 -2
View File
@@ -442,7 +442,9 @@ func (t *httpTarget) doHTTPRequest(
) )
} }
originScoped := applyRequestHeaders(req, event, cfg) originScoped := applyRequestHeaders(
req, event, cfg, t.eng.userAgent(),
)
client := t.clientForRequest(cfg, originScoped) client := t.clientForRequest(cfg, originScoped)
@@ -562,10 +564,13 @@ func isForwardableHeader(name string) bool {
// Content-Type goes out once: a Content-Type configured on the target // Content-Type goes out once: a Content-Type configured on the target
// wins, otherwise the event's ContentType, otherwise none. The inbound // wins, otherwise the event's ContentType, otherwise none. The inbound
// Content-Type in the event's headers is never forwarded. // Content-Type in the event's headers is never forwarded.
//
// userAgent is set last, over any configured or inbound User-Agent.
func applyRequestHeaders( func applyRequestHeaders(
req *http.Request, req *http.Request,
event *database.Event, event *database.Event,
cfg *HTTPTargetConfig, cfg *HTTPTargetConfig,
userAgent string,
) []string { ) []string {
if event.ContentType != "" { if event.ContentType != "" {
req.Header.Set( req.Header.Set(
@@ -580,7 +585,7 @@ func applyRequestHeaders(
originScoped[http.CanonicalHeaderKey(k)] = struct{}{} originScoped[http.CanonicalHeaderKey(k)] = struct{}{}
} }
req.Header.Set("User-Agent", "webhooker/1.0") req.Header.Set("User-Agent", userAgent)
// A Content-Type configured on the target describes the body // A Content-Type configured on the target describes the body
// being sent rather than the sender. A 307/308 preserves the // being sent rather than the sender. A 307/308 preserves the
+1 -1
View File
@@ -136,7 +136,7 @@ func (t *slackTarget) attempt(
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "webhooker/1.0") req.Header.Set("User-Agent", t.eng.userAgent())
resp, doErr := executeHTTPRequest(t.client, req) resp, doErr := executeHTTPRequest(t.client, req)
durationMs := time.Since(start).Milliseconds() durationMs := time.Since(start).Milliseconds()
+32
View File
@@ -418,6 +418,38 @@ func TestProcessRetryTask_TargetDeleted_MakesNoAttempt(
assert.Zero(t, s.Engine.ExportInflightHeld()) assert.Zero(t, s.Engine.ExportInflightHeld())
} }
// TestProcessRetryTask_TargetDeleted_CountsFailureOnTarget verifies
// that the failure of a retry abandoned because its target is gone is
// added to that target's own totals, not to a row with no target.
func TestProcessRetryTask_TargetDeleted_CountsFailureOnTarget(
t *testing.T,
) {
t.Parallel()
s := newISetup(t)
var hits atomic.Int64
task, targetID := tRetryChainSetup(
t, s, "gone-counted", &hits,
)
require.NoError(t, s.MainDB.Delete(
&database.Target{}, "id = ?", targetID,
).Error)
s.Engine.ExportProcessRetryTask(
context.Background(), &task,
)
var rows []database.TargetTotals
require.NoError(t, s.WebhookDB.Find(&rows).Error)
assert.Equal(t, []database.TargetTotals{
{TargetID: targetID, Failed: 1},
}, rows)
}
// TestProcessRetryTask_TargetPresent_StillDelivers is the guard's // TestProcessRetryTask_TargetPresent_StillDelivers is the guard's
// mutation check: a liveness check that refused every retry would pass // mutation check: a liveness check that refused every retry would pass
// the test above and break every retry there is. // the test above and break every retry there is.
+91
View File
@@ -0,0 +1,91 @@
package delivery_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/netip"
"testing"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/metrics"
)
// Both the http and the slack target send webhooker/ and the version
// in Globals, the value the web UI footer shows. A User-Agent
// configured on the target or carried in by the sender does not
// replace it.
func TestUserAgent_IsTheBuildVersion(t *testing.T) {
t.Parallel()
const want = "webhooker/1.2.3-test"
userAgents := make(chan string, 1)
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
userAgents <- r.Header.Get("User-Agent")
w.WriteHeader(http.StatusOK)
},
))
defer ts.Close()
g := &globals.Globals{Version: "1.2.3-test"}
lc := fxtest.NewLifecycle(t)
log, err := logger.New(lc, logger.LoggerParams{Globals: g})
require.NoError(t, err)
e := delivery.New(lc, delivery.EngineParams{
Globals: g,
Logger: log,
// httptest listens on loopback, which the default guard
// refuses.
SSRFGuard: delivery.NewTestGuard(
netip.MustParsePrefix("127.0.0.0/8"),
),
Metrics: metrics.New(prometheus.NewRegistry()),
})
statusCode, _, _, err := e.ExportDoHTTPRequest(
context.Background(),
&delivery.HTTPTargetConfig{
URL: ts.URL,
Headers: map[string]string{"User-Agent": "configured/1"},
},
&database.Event{Headers: `{"User-Agent":["curl/8"]}`},
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, statusCode)
require.Len(t, userAgents, 1, "the http target sent no request")
assert.Equal(t, want, <-userAgents, "http target")
db := testWebhookDB(t)
targetID := uuid.New().String()
slackCfg, err := json.Marshal(
delivery.SlackTargetConfig{WebhookURL: ts.URL},
)
require.NoError(t, err)
event := seedEvent(t, db, `{"action":"test"}`)
dlv := seedDelivery(
t, db, event.ID, targetID, database.DeliveryStatusPending,
)
e.ExportDeliverSlack(context.Background(), db, buildSlackDelivery(
dlv, event, targetID, "test-slack", string(slackCfg),
))
require.Len(t, userAgents, 1, "the slack target sent no request")
assert.Equal(t, want, <-userAgents, "slack target")
}
+7 -2
View File
@@ -117,8 +117,8 @@ func readFirstBootSecrets(
} }
// bootAtDebug starts and stops the real application graph against // bootAtDebug starts and stops the real application graph against
// dataDir with DEBUG=true, and returns everything it wrote to standard // dataDir with DEBUG=true and nothing else set, and returns everything
// output. // it wrote to standard output.
// //
// config.New reads DEBUG from the environment exactly as the binary // config.New reads DEBUG from the environment exactly as the binary
// does, internal/logger builds the handler it builds in production, // does, internal/logger builds the handler it builds in production,
@@ -128,6 +128,7 @@ func readFirstBootSecrets(
func bootAtDebug(t *testing.T, dataDir string) string { func bootAtDebug(t *testing.T, dataDir string) string {
t.Helper() t.Helper()
config.ClearEnvForTest(t)
t.Setenv("DEBUG", "true") t.Setenv("DEBUG", "true")
t.Setenv("DATA_DIR", dataDir) t.Setenv("DATA_DIR", dataDir)
@@ -137,6 +138,10 @@ func bootAtDebug(t *testing.T, dataDir string) string {
app := fxtest.New( app := fxtest.New(
t, t,
// fx's own log is discarded, not sent to t.Logf: a hook still
// running after a start or stop timeout would write there after
// the test has returned.
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
+1 -1
View File
@@ -7,7 +7,7 @@
// SQL — parameters and all — for every statement that returns an // SQL — parameters and all — for every statement that returns an
// error, including gorm.ErrRecordNotFound. Two of this service's // error, including gorm.ErrRecordNotFound. Two of this service's
// lookups miss by design on unauthenticated routes: the entrypoint // lookups miss by design on unauthenticated routes: the entrypoint
// lookup on /webhook/{uuid}, whose path segment the client picks // lookup on /h/{uuid}, whose path segment the client picks
// outright, and the user lookup behind the login form, whose username // outright, and the user lookup behind the login form, whose username
// the client picks outright. Under the default logger each of those // the client picks outright. Under the default logger each of those
// misses printed an unbounded, attacker-chosen string, at no level the // misses printed an unbounded, attacker-chosen string, at no level the
+63 -33
View File
@@ -2,19 +2,56 @@ package handlers
import ( import (
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings"
"unicode"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/logfield" "sneak.berlin/go/webhooker/internal/logfield"
"sneak.berlin/go/webhooker/internal/middleware"
) )
// loginDestination returns where a successful login sends the
// browser: next when it is a path on this site, otherwise "/", which
// leads to the webhook list.
//
// A browser reads "//host" as another site, reads "\" as "/", and
// drops tabs and newlines before reading at all. So the value must
// start with exactly one "/" and hold no "\" or control character
// anywhere: http.Redirect cleans "/a/../\host" down to "/\host". It
// is checked after percent-decoding, so an encoded form of any of
// these is refused too.
func loginDestination(next string) string {
if len(next) > middleware.MaxNextBytes {
return "/"
}
decoded, err := url.PathUnescape(next)
if err != nil ||
!strings.HasPrefix(decoded, "/") ||
strings.HasPrefix(decoded, "//") ||
strings.Contains(decoded, `\`) ||
strings.ContainsFunc(decoded, unicode.IsControl) {
return "/"
}
return next
}
// HandleLoginPage returns a handler for the login page (GET) // HandleLoginPage returns a handler for the login page (GET)
func (h *Handlers) HandleLoginPage() http.HandlerFunc { func (h *Handlers) HandleLoginPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
next := loginDestination(
r.URL.Query().Get(middleware.NextParam),
)
// Check if already logged in // Check if already logged in
sess, err := h.session.Get(r) sess, err := h.session.Get(r)
if err == nil && h.session.IsAuthenticated(sess) { if err == nil && h.session.IsAuthenticated(sess) {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect( //nolint:gosec // checked by loginDestination
w, r, next, http.StatusSeeOther,
)
return return
} }
@@ -22,6 +59,7 @@ func (h *Handlers) HandleLoginPage() http.HandlerFunc {
// Render login page // Render login page
data := map[string]any{ data := map[string]any{
tmplKeyError: "", tmplKeyError: "",
tmplKeyNext: next,
} }
h.renderTemplate(w, r, "login.html", data) h.renderTemplate(w, r, "login.html", data)
@@ -36,7 +74,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
h.log.Error("failed to parse form", "error", err) h.log.Error("failed to parse form", "error", err)
http.Error(w, "Bad request", http.StatusBadRequest) h.renderError(w, r, http.StatusBadRequest)
return return
} }
@@ -77,8 +115,13 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
"user_id", user.ID, "user_id", user.ID,
) )
// Redirect to home page // The form value is the client's to set, so it is checked
http.Redirect(w, r, "/", http.StatusSeeOther) // again here rather than trusted from the rendered page.
http.Redirect( //nolint:gosec // checked by loginDestination
w, r,
loginDestination(r.PostFormValue(middleware.NextParam)),
http.StatusSeeOther,
)
} }
} }
@@ -91,10 +134,12 @@ func (h *Handlers) renderLoginError(
) { ) {
data := map[string]any{ data := map[string]any{
tmplKeyError: msg, tmplKeyError: msg,
tmplKeyNext: loginDestination(
r.PostFormValue(middleware.NextParam),
),
} }
w.WriteHeader(status) h.renderTemplateStatus(w, r, "login.html", data, status)
h.renderTemplate(w, r, "login.html", data)
} }
// authenticateUser looks up and verifies a user's credentials. // authenticateUser looks up and verifies a user's credentials.
@@ -103,9 +148,10 @@ func (h *Handlers) renderLoginError(
// The credential check runs BEFORE any rate-limit budget is // The credential check runs BEFORE any rate-limit budget is
// consulted, and only a failed check spends budget. That is what // consulted, and only a failed check spends budget. That is what
// keeps the single administrative path reachable: behind the reverse // keeps the single administrative path reachable: behind the reverse
// proxy this deployment requires, with TRUSTED_PROXIES unset, every // proxy this deployment requires, when TRUSTED_PROXIES does not cover
// client shares one bucket, so a limiter spent on arrival lets any // it, every client shares one bucket, so a limiter spent on arrival
// stranger deny the operator's own correct password indefinitely. // lets any stranger deny the operator's own correct password
// indefinitely.
// //
// Verifying first means every login POST costs an Argon2id hash, so // Verifying first means every login POST costs an Argon2id hash, so
// the work is taken under a bounded number of verification slots. // the work is taken under a bounded number of verification slots.
@@ -165,11 +211,7 @@ func (h *Handlers) authenticateUser(
valid, err := database.VerifyPassword(password, user.Password) valid, err := database.VerifyPassword(password, user.Password)
if err != nil { if err != nil {
h.log.Error("failed to verify password", "error", err) h.serverError(w, r, "failed to verify password", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return user, err return user, err
} }
@@ -241,24 +283,14 @@ func (h *Handlers) createAuthenticatedSession(
) error { ) error {
oldSess, err := h.session.Get(r) oldSess, err := h.session.Get(r)
if err != nil { if err != nil {
h.log.Error("failed to get session", "error", err) h.serverError(w, r, "failed to get session", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return err return err
} }
sess, err := h.session.Regenerate(r, w, oldSess) sess, err := h.session.Regenerate(r, w, oldSess)
if err != nil { if err != nil {
h.log.Error( h.serverError(w, r, "failed to regenerate session", err)
"failed to regenerate session", "error", err,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return err return err
} }
@@ -267,11 +299,7 @@ func (h *Handlers) createAuthenticatedSession(
err = h.session.Save(r, w, sess) err = h.session.Save(r, w, sess)
if err != nil { if err != nil {
h.log.Error("failed to save session", "error", err) h.serverError(w, r, "failed to save session", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return err return err
} }
@@ -304,7 +332,9 @@ func (h *Handlers) HandleLogout() http.HandlerFunc {
) )
} }
// Redirect to login page http.Redirect(
http.Redirect(w, r, "/pages/login", http.StatusSeeOther) w, r, withNotice("/pages/login", signedOut),
http.StatusSeeOther,
)
} }
} }
+287 -6
View File
@@ -3,6 +3,7 @@ package handlers_test
import ( import (
"context" "context"
"fmt" "fmt"
"html/template"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@@ -25,7 +26,7 @@ const (
// sharedProxyPeer is the whole point of this file. Production is // sharedProxyPeer is the whole point of this file. Production is
// required to run behind a TLS-terminating reverse proxy, and // required to run behind a TLS-terminating reverse proxy, and
// TRUSTED_PROXIES defaults to empty, so every client — attacker // when TRUSTED_PROXIES does not cover it every client — attacker
// and operator alike — reaches the process from the proxy's // and operator alike — reaches the process from the proxy's
// address and shares one rate-limit bucket. Both parties in // address and shares one rate-limit bucket. Both parties in
// these tests therefore use the same RemoteAddr. // these tests therefore use the same RemoteAddr.
@@ -115,11 +116,11 @@ func floodFailures(
// done-criterion of https://git.eeqj.de/sneak/webhooker/issues/150. // done-criterion of https://git.eeqj.de/sneak/webhooker/issues/150.
// //
// The attacker and the operator share one rate-limit bucket, because // The attacker and the operator share one rate-limit bucket, because
// behind the mandated reverse proxy with TRUSTED_PROXIES unset every // behind the mandated reverse proxy, when TRUSTED_PROXIES does not
// client keys on the proxy's address. The attacker floods the // cover it, every client keys on the proxy's address. The attacker
// operator's own username — a single-admin product has a predictable // floods the operator's own username — a single-admin product has a
// one — far past the failure limit. The operator must still be able // predictable one — far past the failure limit. The operator must
// to log in with the correct password. // still be able to log in with the correct password.
// //
// This fails if credentials stop being verified ahead of the limiter. // This fails if credentials stop being verified ahead of the limiter.
func TestLogin_StrangersFloodCannotLockOutTheOperator(t *testing.T) { func TestLogin_StrangersFloodCannotLockOutTheOperator(t *testing.T) {
@@ -404,6 +405,60 @@ func TestLogin_MissingCredentialsRejectedBeforeAnyHash(t *testing.T) {
) )
} }
// TestLogin_FormErrorAnswersItsStatusWithThePage proves that the login
// form shown again with an error still answers 400 with the whole page.
func TestLogin_FormErrorAnswersItsStatusWithThePage(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
w := submitLogin(h, sharedProxyPeer, "", "")
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(
t, w.Body.String(), "Username and password are required",
)
assert.Contains(
t, w.Body.String(), "</html>",
"the page must render to completion",
)
}
// TestLogin_FormErrorRenderFailureAnswers500 proves that a login form
// error page whose template fails answers 500 with the error page and
// none of the form page, rather than the 400 it meant to send.
func TestLogin_FormErrorRenderFailureAnswers500(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
// The page prints its error message and then fails.
h.AddTemplateForTest("login.html", template.Must(
template.New("login").Funcs(template.FuncMap{
"fail": func() (string, error) { return "", errMidRender },
}).Parse(`{{.Error}}{{fail}}`),
))
w := submitLogin(h, sharedProxyPeer, "", "")
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.NotContains(
t, w.Body.String(), "Username and password are required",
"the response must carry no part of the aborted page",
)
assert.Contains(t, w.Body.String(), "500 Internal Server Error")
}
// TestLogin_SuccessCreatesSession is the control for the tests above: // TestLogin_SuccessCreatesSession is the control for the tests above:
// the success path they assert on really does authenticate. // the success path they assert on really does authenticate.
func TestLogin_SuccessCreatesSession(t *testing.T) { func TestLogin_SuccessCreatesSession(t *testing.T) {
@@ -453,3 +508,229 @@ func TestLogin_SuccessCreatesSession(t *testing.T) {
"the issued cookie must carry an authenticated session", "the issued cookie must carry an authenticated session",
) )
} }
// TestLogin_ReturnsOnlyToAPathOnThisSite is the security half of
// https://git.eeqj.de/sneak/webhooker/issues/384: the page a login
// returns to is client-chosen, so anything that is not a path on this
// site, plain or percent-encoded, must land on "/", the webhook list.
func TestLogin_ReturnsOnlyToAPathOnThisSite(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
db *database.Database
)
app := newTestApp(t, &h, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
seedOperator(t, db)
cases := []struct{ next, want string }{
{"/hook/abc/events?page=2", "/hook/abc/events?page=2"},
{"", "/"},
{"https://evil.example/", "/"},
{"https%3A%2F%2Fevil.example%2F", "/"},
{"//evil.example/", "/"},
{"%2F%2Fevil.example/", "/"},
{"/%2Fevil.example/", "/"},
{`/\evil.example/`, "/"},
{"%2F%5Cevil.example/", "/"},
{"/%5Cevil.example/", "/"},
{`/a/../\evil.example/`, "/"},
{"/\t/evil.example/", "/"},
{"/%09/evil.example/", "/"},
{"/\n/evil.example/", "/"},
{"/%0A/evil.example/", "/"},
{"/\r/evil.example/", "/"},
{"/%0D/evil.example/", "/"},
{"/%00/evil.example/", "/"},
{"/%7F/evil.example/", "/"},
{"%252F%252Fevil.example/", "/"},
{"https%253A%252F%252Fevil.example%252F", "/"},
{"/" + strings.Repeat("a", 4096), "/"},
}
for _, c := range cases {
form := url.Values{}
form.Set("username", operatorUser)
form.Set("password", operatorPassword)
form.Set("next", c.next)
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost,
"/pages/login",
strings.NewReader(form.Encode()),
)
req.Header.Set(
"Content-Type", "application/x-www-form-urlencoded",
)
req.RemoteAddr = sharedProxyPeer
w := httptest.NewRecorder()
h.HandleLoginSubmit().ServeHTTP(w, req)
assert.Equal(t, http.StatusSeeOther, w.Code, "next %q", c.next)
assert.Equal(
t, c.want, w.Header().Get("Location"), "next %q", c.next,
)
}
}
// TestLogin_WrongPasswordKeepsTheRequestedPage: after a wrong
// password the login page is shown again with the same next, so the
// next attempt still returns to the page that was asked for.
func TestLogin_WrongPasswordKeepsTheRequestedPage(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
db *database.Database
)
app := newTestApp(t, &h, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
seedOperator(t, db)
form := url.Values{}
form.Set("username", operatorUser)
form.Set("password", "wrong")
form.Set("next", "/hook/abc")
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost,
"/pages/login",
strings.NewReader(form.Encode()),
)
req.Header.Set(
"Content-Type", "application/x-www-form-urlencoded",
)
req.RemoteAddr = sharedProxyPeer
w := httptest.NewRecorder()
h.HandleLoginSubmit().ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.Contains(
t, w.Body.String(), `name="next" value="/hook/abc"`,
)
}
// loginPageGet renders the login page as a GET with the given next
// value and cookies.
func loginPageGet(
h *handlers.Handlers, next string, cookies []*http.Cookie,
) *httptest.ResponseRecorder {
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet,
"/pages/login?"+url.Values{"next": {next}}.Encode(), nil,
)
for _, c := range cookies {
req.AddCookie(c)
}
w := httptest.NewRecorder()
h.HandleLoginPage().ServeHTTP(w, req)
return w
}
// TestLoginPage_CarriesOnlyAPathOnThisSite covers the login page
// itself: its form carries the requested page only when it is a path
// on this site, and a browser already logged in goes straight there,
// or to "/" when it is not.
func TestLoginPage_CarriesOnlyAPathOnThisSite(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
)
app := newTestApp(t, &h, &sess)
app.RequireStart()
t.Cleanup(app.RequireStop)
assert.Contains(
t, loginPageGet(h, "/hook/abc", nil).Body.String(),
`name="next" value="/hook/abc"`,
)
assert.Contains(
t, loginPageGet(h, "//evil.example/", nil).Body.String(),
`name="next" value="/"`,
)
cookies := authenticatedCookies(t, sess, "test-user-id", "testuser")
cases := []struct{ next, want string }{
{"/hook/abc", "/hook/abc"},
{"//evil.example/", "/"},
{`/\evil.example/`, "/"},
}
for _, c := range cases {
w := loginPageGet(h, c.next, cookies)
assert.Equal(t, http.StatusSeeOther, w.Code, "next %q", c.next)
assert.Equal(
t, c.want, w.Header().Get("Location"), "next %q", c.next,
)
}
}
// TestLoginPage_HasNoLinkToItself: the navigation bar on the login
// page offers no link to the login page.
func TestLoginPage_HasNoLinkToItself(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
w := loginPageGet(h, "", nil)
require.Equal(t, http.StatusOK, w.Code)
assert.NotContains(t, w.Body.String(), `href="/pages/login"`)
}
// TestLogin_UsernameAtLimitCanLogIn shows that a username of exactly
// database.MaxUsernameBytes still fits in the session cookie. Past
// what the cookie can carry, a correct login answers 500.
func TestLogin_UsernameAtLimitCanLogIn(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
db *database.Database
)
app := newTestApp(t, &h, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
username := strings.Repeat("a", database.MaxUsernameBytes)
hash, err := database.HashPassword(operatorPassword)
require.NoError(t, err)
require.NoError(t, db.DB().Create(&database.User{
Username: username,
Password: hash,
}).Error)
w := submitLogin(h, sharedProxyPeer, username, operatorPassword)
assert.Equal(t, http.StatusSeeOther, w.Code)
}
+39 -67
View File
@@ -11,72 +11,37 @@ import (
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
) )
// replayOutcomeParam is the query parameter the replay POST redirects // The outcomes of a replay POST, as the notice codes its redirect
// with and the event log page reads its banner from. // carries. noticeFor holds the line each one shows.
const replayOutcomeParam = "replay"
// replayOutcomeCode is the outcome of a replay POST. The redirect
// carries one of these fixed codes rather than a message, so nothing a
// client submits can reach the rendered page through it.
type replayOutcomeCode string
const ( const (
// replayQueued reports that a new delivery was created and handed // replayQueued reports that a new delivery was created and handed
// to the delivery engine. // to the delivery engine.
replayQueued replayOutcomeCode = "queued" replayQueued noticeCode = "replay-queued"
// replayTargetDeleted reports a target that once existed and has // replayTargetDeleted reports a target that once existed and has
// since been deleted. Deletes are soft and deliveries carry no // since been deleted. Deletes are soft and deliveries carry no
// foreign key to the target row, so the history survives its // foreign key to the target row, so the history survives its
// target and this is the ordinary case for an old event. // target and this is the ordinary case for an old event.
replayTargetDeleted replayOutcomeCode = "target-deleted" replayTargetDeleted noticeCode = "replay-target-deleted"
// replayTargetMissing reports a target id that names no row at // replayTargetMissing reports a target id that names no row at
// all, deleted or otherwise. // all, deleted or otherwise.
replayTargetMissing replayOutcomeCode = "target-missing" replayTargetMissing noticeCode = "replay-target-missing"
// replayTargetInactive reports a target the operator has // replayTargetInactive reports a target the operator has
// deactivated. A deactivated target receives no new deliveries, so // deactivated. A deactivated target receives no new deliveries, so
// a replay to it would be a delivery they switched off. // a replay to it would be a delivery they switched off.
replayTargetInactive replayOutcomeCode = "target-inactive" replayTargetInactive noticeCode = "replay-target-inactive"
// replayNotTerminal reports a delivery the engine has not finished // replayNotTerminal reports a delivery the engine has not finished
// with. // with.
replayNotTerminal replayOutcomeCode = "not-terminal" replayNotTerminal noticeCode = "replay-not-terminal"
// replayInFlight reports that an earlier replay of this event to // replayInFlight reports that an earlier replay of this event to
// this target is still running. // this target is still running.
replayInFlight replayOutcomeCode = "in-flight" replayInFlight noticeCode = "replay-in-flight"
) )
// replayOutcome returns the banner the event log page shows for an
// outcome code, and whether the replay was queued. An unrecognised
// code yields no banner.
func replayOutcome(code string) (string, bool) {
switch replayOutcomeCode(code) {
case replayQueued:
return "Replay queued: a new delivery was created against " +
"the target's current configuration.", true
case replayTargetDeleted:
return "Not replayed: the target this delivery was for has " +
"been deleted. Recreate the target, then replay.", false
case replayTargetMissing:
return "Not replayed: the target this delivery was for no " +
"longer exists.", false
case replayTargetInactive:
return "Not replayed: the target this delivery was for is " +
"deactivated. Activate it, then replay.", false
case replayNotTerminal:
return "Not replayed: this delivery has not finished yet.",
false
case replayInFlight:
return "Not replayed: a delivery of this event to this " +
"target is already in flight.", false
default:
return "", false
}
}
// HandleDeliveryReplay re-sends a finished delivery's event to its // HandleDeliveryReplay re-sends a finished delivery's event to its
// target. // target.
// //
@@ -105,9 +70,7 @@ func (h *Handlers) HandleDeliveryReplay() http.HandlerFunc {
// middleware, which runs before CSRF parses the form. // middleware, which runs before CSRF parses the form.
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
http.Error( h.renderError(w, r, http.StatusBadRequest)
w, "Bad request", http.StatusBadRequest,
)
return return
} }
@@ -124,14 +87,14 @@ func (h *Handlers) replayDelivery(
webhook database.Webhook, webhook database.Webhook,
) { ) {
if !h.dbMgr.DBExists(webhook.ID) { if !h.dbMgr.DBExists(webhook.ID) {
http.NotFound(w, r) h.renderError(w, r, http.StatusNotFound)
return return
} }
webhookDB, err := h.dbMgr.GetDB(webhook.ID) webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil { if err != nil {
h.serverError(w, "failed to get webhook database", err) h.serverError(w, r, "failed to get webhook database", err)
return return
} }
@@ -142,14 +105,14 @@ func (h *Handlers) replayDelivery(
} }
if !original.Status.Terminal() { if !original.Status.Terminal() {
h.finishReplay(w, r, webhook, replayNotTerminal) redirectToEventLog(w, r, webhook, replayNotTerminal)
return return
} }
target, code := h.replayTarget(webhook.ID, original.TargetID) target, code := h.replayTarget(webhook.ID, original.TargetID)
if target == nil { if target == nil {
h.finishReplay(w, r, webhook, code) redirectToEventLog(w, r, webhook, code)
return return
} }
@@ -173,7 +136,7 @@ func (h *Handlers) loadReplaySource(
&original, "id = ?", chi.URLParam(r, "deliveryID"), &original, "id = ?", chi.URLParam(r, "deliveryID"),
).Error ).Error
if err != nil { if err != nil {
http.NotFound(w, r) h.renderError(w, r, http.StatusNotFound)
return nil, false return nil, false
} }
@@ -195,14 +158,14 @@ func (h *Handlers) queueReplay(
) )
if err != nil { if err != nil {
h.serverError( h.serverError(
w, "failed to count in-flight deliveries", err, w, r, "failed to count in-flight deliveries", err,
) )
return return
} }
if inFlight > 0 { if inFlight > 0 {
h.finishReplay(w, r, webhook, replayInFlight) redirectToEventLog(w, r, webhook, replayInFlight)
return return
} }
@@ -212,7 +175,7 @@ func (h *Handlers) queueReplay(
err = webhookDB. err = webhookDB.
First(&event, "id = ?", original.EventID).Error First(&event, "id = ?", original.EventID).Error
if err != nil { if err != nil {
h.serverError(w, "failed to load event for replay", err) h.serverError(w, r, "failed to load event for replay", err)
return return
} }
@@ -222,7 +185,7 @@ func (h *Handlers) queueReplay(
) )
if err != nil { if err != nil {
h.serverError( h.serverError(
w, "failed to create replay delivery", err, w, r, "failed to create replay delivery", err,
) )
return return
@@ -240,7 +203,7 @@ func (h *Handlers) queueReplay(
"delivery_id", task.DeliveryID, "delivery_id", task.DeliveryID,
) )
h.finishReplay(w, r, webhook, replayQueued) redirectToEventLog(w, r, webhook, replayQueued)
} }
// replayTarget loads the delivery's target as it stands now. // replayTarget loads the delivery's target as it stands now.
@@ -253,7 +216,7 @@ func (h *Handlers) queueReplay(
// with the returned code saying why. // with the returned code saying why.
func (h *Handlers) replayTarget( func (h *Handlers) replayTarget(
webhookID, targetID string, webhookID, targetID string,
) (*database.Target, replayOutcomeCode) { ) (*database.Target, noticeCode) {
var target database.Target var target database.Target
err := h.db.DB().Unscoped().Where( err := h.db.DB().Unscoped().Where(
@@ -299,8 +262,9 @@ func countInFlightDeliveries(
return count, err return count, err
} }
// createReplayDelivery writes the new pending delivery row and returns // createReplayDelivery writes the new pending delivery row, adds it to
// the task that carries it to the delivery engine. // 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 // The row is written with associations omitted, and neither Event nor
// Target is populated on it: GORM's SaveBeforeAssociations would // Target is populated on it: GORM's SaveBeforeAssociations would
@@ -319,7 +283,16 @@ func createReplayDelivery(
Status: database.DeliveryStatusPending, Status: database.DeliveryStatusPending,
} }
err := webhookDB.Omit(clause.Associations).Create(dlv).Error 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 { if err != nil {
return delivery.Task{}, err return delivery.Task{}, err
} }
@@ -353,17 +326,16 @@ func replayBody(body string) *string {
return &body return &body
} }
// finishReplay redirects back to the event log the replay was // redirectToEventLog redirects a replay or resubmit back to the event
// triggered from, carrying the outcome code the page turns into a // log it was triggered from, carrying the outcome as its notice and
// banner and the page number the form submitted. // the page number the form submitted.
func (h *Handlers) finishReplay( func redirectToEventLog(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
webhook database.Webhook, webhook database.Webhook,
code replayOutcomeCode, code noticeCode,
) { ) {
dest := "/source/" + webhook.ID + "/logs?" + dest := withNotice("/hook/"+webhook.ID+"/events", code)
replayOutcomeParam + "=" + string(code)
// The page is read from the form rather than the query string: // The page is read from the form rather than the query string:
// this is a POST, and its query string is what logs and Referer // this is a POST, and its query string is what logs and Referer
+10 -10
View File
@@ -138,7 +138,7 @@ func postReplay(
t.Helper() t.Helper()
req := postRequest( req := postRequest(
"/source/"+webhookID+"/deliveries/"+ "/hook/"+webhookID+"/deliveries/"+
deliveryID+"/replay", deliveryID+"/replay",
authenticatedCookies( authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername, t, sess, deleteTestUserID, deleteTestUsername,
@@ -212,7 +212,7 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal( assert.Equal(
t, t,
"/source/"+wh.ID+"/logs?replay=queued", "/hook/"+wh.ID+"/events?notice=replay-queued",
w.Header().Get("Location"), w.Header().Get("Location"),
) )
@@ -362,7 +362,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal( assert.Equal(
t, t,
"/source/"+wh.ID+"/logs?replay=target-deleted", "/hook/"+wh.ID+"/events?notice=replay-target-deleted",
w.Header().Get("Location"), w.Header().Get("Location"),
) )
@@ -390,7 +390,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
require.Equal(t, http.StatusSeeOther, missing.Code) require.Equal(t, http.StatusSeeOther, missing.Code)
assert.Equal( assert.Equal(
t, t,
"/source/"+wh.ID+"/logs?replay=target-missing", "/hook/"+wh.ID+"/events?notice=replay-target-missing",
missing.Header().Get("Location"), missing.Header().Get("Location"),
) )
} }
@@ -431,7 +431,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
require.Equal(t, http.StatusSeeOther, first.Code) require.Equal(t, http.StatusSeeOther, first.Code)
require.Equal( require.Equal(
t, t,
"/source/"+wh.ID+"/logs?replay=queued", "/hook/"+wh.ID+"/events?notice=replay-queued",
first.Header().Get("Location"), first.Header().Get("Location"),
) )
@@ -439,7 +439,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
require.Equal(t, http.StatusSeeOther, second.Code) require.Equal(t, http.StatusSeeOther, second.Code)
assert.Equal( assert.Equal(
t, t,
"/source/"+wh.ID+"/logs?replay=in-flight", "/hook/"+wh.ID+"/events?notice=replay-in-flight",
second.Header().Get("Location"), second.Header().Get("Location"),
) )
@@ -465,7 +465,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
require.Equal(t, http.StatusSeeOther, pending.Code) require.Equal(t, http.StatusSeeOther, pending.Code)
assert.Equal( assert.Equal(
t, t,
"/source/"+wh.ID+"/logs?replay=not-terminal", "/hook/"+wh.ID+"/events?notice=replay-not-terminal",
pending.Header().Get("Location"), pending.Header().Get("Location"),
) )
} }
@@ -501,7 +501,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
assert.Contains( assert.Contains(
t, body, t, body,
`action="/source/`+wh.ID+`/deliveries/`+ `action="/hook/`+wh.ID+`/deliveries/`+
original.ID+`/replay"`, original.ID+`/replay"`,
) )
assert.Contains(t, body, `method="POST"`) assert.Contains(t, body, `method="POST"`)
@@ -509,7 +509,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
assert.Contains(t, body, ">Replay<") assert.Contains(t, body, ">Replay<")
refused := renderSourceLogsPageWithQuery( refused := renderSourceLogsPageWithQuery(
t, h, sess, wh.ID, "?replay=target-deleted", t, h, sess, wh.ID, "?notice=replay-target-deleted",
) )
assert.Contains(t, refused, "alert-error") assert.Contains(t, refused, "alert-error")
@@ -517,7 +517,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
// An outcome code nobody issued renders no banner at all. // An outcome code nobody issued renders no banner at all.
unknown := renderSourceLogsPageWithQuery( unknown := renderSourceLogsPageWithQuery(
t, h, sess, wh.ID, "?replay=made-up", t, h, sess, wh.ID, "?notice=made-up",
) )
assert.NotContains(t, unknown, "alert-error") assert.NotContains(t, unknown, "alert-error")
+53
View File
@@ -0,0 +1,53 @@
package handlers_test
import (
"context"
"html/template"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"sneak.berlin/go/webhooker/internal/handlers"
)
// TestErrorPage_RenderFailureKeepsStatus proves that an error page
// which cannot render answers with the status it was reporting, as
// plain text, and is not attempted again: a page whose own render
// fails reaches the error page, and the error page failing as well
// ends there with the 500.
func TestErrorPage_RenderFailureKeepsStatus(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
// .Status is an int, so asking it for a field fails the render.
failing := `{{.Status.Missing}}`
h.AddTemplateForTest("error.html", template.Must(
template.New("error").Parse(failing),
))
h.AddTemplateForTest("failing.html", template.Must(
template.New("failing").Parse(`{{.Data.Missing}}`),
))
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil,
)
w := httptest.NewRecorder()
h.HandleErrorPage(http.StatusNotFound).ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
assert.Equal(t, "Not Found\n", w.Body.String())
w = httptest.NewRecorder()
h.RenderTemplateForTest(w, req, "failing.html", 0)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(t, "Internal Server Error\n", w.Body.String())
}
+5 -5
View File
@@ -52,7 +52,7 @@ func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc {
// steered by a client. // steered by a client.
eventID, err := uuid.Parse(chi.URLParam(r, "eventID")) eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
if err != nil { if err != nil {
http.NotFound(w, r) h.renderError(w, r, http.StatusNotFound)
return return
} }
@@ -103,21 +103,21 @@ func (h *Handlers) serveEventBody(
eventID string, eventID string,
) { ) {
if !h.dbMgr.DBExists(webhook.ID) { if !h.dbMgr.DBExists(webhook.ID) {
http.NotFound(w, r) h.renderError(w, r, http.StatusNotFound)
return return
} }
webhookDB, err := h.dbMgr.GetDB(webhook.ID) webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil { if err != nil {
h.serverError(w, "failed to get webhook database", err) h.serverError(w, r, "failed to get webhook database", err)
return return
} }
body, found, err := eventBody(webhookDB, webhook.ID, eventID) body, found, err := eventBody(webhookDB, webhook.ID, eventID)
if err != nil { if err != nil {
h.serverError(w, "failed to read event body", err) h.serverError(w, r, "failed to read event body", err)
return return
} }
@@ -130,7 +130,7 @@ func (h *Handlers) serveEventBody(
// row and the whole body is served, or it does not and the // row and the whole body is served, or it does not and the
// response is a clean 404. // response is a clean 404.
if !found { if !found {
http.NotFound(w, r) h.renderError(w, r, http.StatusNotFound)
return return
} }
+4 -4
View File
@@ -64,8 +64,8 @@ func fetchEventBody(
req := httptest.NewRequestWithContext( req := httptest.NewRequestWithContext(
context.Background(), context.Background(),
http.MethodGet, http.MethodGet,
"/source/"+url.PathEscape(sourceID)+ "/hook/"+url.PathEscape(sourceID)+
"/logs/"+url.PathEscape(eventID)+"/body", "/events/"+url.PathEscape(eventID)+"/body",
nil, nil,
) )
@@ -490,7 +490,7 @@ func TestHandleSourceLogs_TruncationMarkerLinksToDownload(
page := renderSourceLogsPage(t, h, sess, big.ID) page := renderSourceLogsPage(t, h, sess, big.ID)
assert.Contains( assert.Contains(
t, page, t, page,
"/source/"+big.ID+"/logs/"+bigEvt.ID+"/body", "/hook/"+big.ID+"/events/"+bigEvt.ID+"/body",
) )
small := seedWebhook(t, db) small := seedWebhook(t, db)
@@ -501,6 +501,6 @@ func TestHandleSourceLogs_TruncationMarkerLinksToDownload(
page = renderSourceLogsPage(t, h, sess, small.ID) page = renderSourceLogsPage(t, h, sess, small.ID)
assert.NotContains( assert.NotContains(
t, page, t, page,
"/source/"+small.ID+"/logs/"+smallEvt.ID+"/body", "/hook/"+small.ID+"/events/"+smallEvt.ID+"/body",
) )
} }
+13 -62
View File
@@ -3,7 +3,6 @@ package handlers
import ( import (
"errors" "errors"
"net/http" "net/http"
"strconv"
"github.com/go-chi/chi" "github.com/go-chi/chi"
"github.com/google/uuid" "github.com/google/uuid"
@@ -11,43 +10,19 @@ import (
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
// resubmitOutcomeParam is the query parameter the resubmit POST // The outcomes of a resubmit POST, as the notice codes its redirect
// redirects with and the event log page reads its banner from. // carries. noticeFor holds the line each one shows.
const resubmitOutcomeParam = "resubmit"
// resubmitOutcomeCode is the outcome of a resubmit POST. The redirect
// carries one of these fixed codes rather than a message, so nothing a
// client submits can reach the rendered page through it.
type resubmitOutcomeCode string
const ( const (
// resubmitQueued reports that a new event was stored and its // resubmitQueued reports that a new event was stored and its
// deliveries handed to the delivery engine. // deliveries handed to the delivery engine.
resubmitQueued resubmitOutcomeCode = "queued" resubmitQueued noticeCode = "resubmit-queued"
// resubmitNoTargets reports a source with no active targets. The // resubmitNoTargets reports a source with no active targets. The
// new event is stored either way, exactly as a received event // new event is stored either way, exactly as a received event
// with no targets is. // with no targets is.
resubmitNoTargets resubmitOutcomeCode = "no-targets" resubmitNoTargets noticeCode = "resubmit-no-targets"
) )
// resubmitOutcome returns the banner the event log page shows for an
// outcome code, and whether the resubmit was queued. An unrecognised
// code yields no banner.
func resubmitOutcome(code string) (string, bool) {
switch resubmitOutcomeCode(code) {
case resubmitQueued:
return "Resubmitted: a new event was created from the stored " +
"one and queued to every active target.", true
case resubmitNoTargets:
return "Resubmitted: a new event was created, but this " +
"source has no active targets, so nothing was queued.",
true
default:
return "", false
}
}
// resubmitSource is the stored event a resubmit copies. Its body is // resubmitSource is the stored event a resubmit copies. Its body is
// read as bytes rather than as a string so the copy is byte-identical // read as bytes rather than as a string so the copy is byte-identical
// to what was received, whatever the payload's encoding. // to what was received, whatever the payload's encoding.
@@ -99,7 +74,7 @@ func (h *Handlers) HandleEventResubmit() http.HandlerFunc {
// middleware, which runs before CSRF parses the form. // middleware, which runs before CSRF parses the form.
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
http.Error(w, "Bad request", http.StatusBadRequest) h.renderError(w, r, http.StatusBadRequest)
return return
} }
@@ -120,20 +95,20 @@ func (h *Handlers) resubmitEvent(
// alphabet rather than from the request. // alphabet rather than from the request.
eventID, err := uuid.Parse(chi.URLParam(r, "eventID")) eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
if err != nil { if err != nil {
http.NotFound(w, r) h.renderError(w, r, http.StatusNotFound)
return return
} }
if !h.dbMgr.DBExists(webhook.ID) { if !h.dbMgr.DBExists(webhook.ID) {
http.NotFound(w, r) h.renderError(w, r, http.StatusNotFound)
return return
} }
webhookDB, err := h.dbMgr.GetDB(webhook.ID) webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil { if err != nil {
h.serverError(w, "failed to get webhook database", err) h.serverError(w, r, "failed to get webhook database", err)
return return
} }
@@ -147,7 +122,7 @@ func (h *Handlers) resubmitEvent(
webhookDB, webhook.ID, eventID.String(), webhookDB, webhook.ID, eventID.String(),
) )
if err != nil { if err != nil {
h.serverError(w, "failed to load event to resubmit", err) h.serverError(w, r, "failed to load event to resubmit", err)
return return
} }
@@ -155,7 +130,7 @@ func (h *Handlers) resubmitEvent(
// A miss is a 404 whether the event was reaped, belongs to // A miss is a 404 whether the event was reaped, belongs to
// another webhook, or never existed. // another webhook, or never existed.
if !found { if !found {
http.NotFound(w, r) h.renderError(w, r, http.StatusNotFound)
return return
} }
@@ -207,7 +182,7 @@ func (h *Handlers) queueResubmit(
// inactive one is skipped rather than refused. // inactive one is skipped rather than refused.
targets, err := h.loadActiveTargets(webhook.ID) targets, err := h.loadActiveTargets(webhook.ID)
if err != nil { if err != nil {
h.serverError(w, "failed to query targets", err) h.serverError(w, r, "failed to query targets", err)
return return
} }
@@ -225,7 +200,7 @@ func (h *Handlers) queueResubmit(
targets, targets,
) )
if err != nil { if err != nil {
h.serverError(w, "failed to store resubmitted event", err) h.serverError(w, r, "failed to store resubmitted event", err)
return return
} }
@@ -245,29 +220,5 @@ func (h *Handlers) queueResubmit(
code = resubmitNoTargets code = resubmitNoTargets
} }
h.finishResubmit(w, r, webhook, code) redirectToEventLog(w, r, webhook, code)
}
// finishResubmit redirects back to the event log the resubmit was
// triggered from, carrying the outcome code the page turns into a
// banner and the page number the form submitted.
func (h *Handlers) finishResubmit(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
code resubmitOutcomeCode,
) {
dest := "/source/" + webhook.ID + "/logs?" +
resubmitOutcomeParam + "=" + string(code)
// The page is read from the form rather than the query string:
// this is a POST, and its query string is what logs and Referer
// headers record.
if page := pageOrFirst(
r.PostFormValue("page"),
); page > 1 {
dest += "&page=" + strconv.Itoa(page)
}
http.Redirect(w, r, dest, http.StatusSeeOther)
} }
+7 -6
View File
@@ -65,7 +65,7 @@ func postResubmit(
t.Helper() t.Helper()
req := postRequest( req := postRequest(
"/source/"+webhookID+"/events/"+eventID+"/resubmit", "/hook/"+webhookID+"/events/"+eventID+"/resubmit",
authenticatedCookies( authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername, t, sess, deleteTestUserID, deleteTestUsername,
), ),
@@ -154,7 +154,7 @@ func TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent(
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal( assert.Equal(
t, t,
"/source/"+wh.ID+"/logs?resubmit=queued", "/hook/"+wh.ID+"/events?notice=resubmit-queued",
w.Header().Get("Location"), w.Header().Get("Location"),
) )
@@ -204,6 +204,7 @@ func assertEventCopy(
assert.Equal(t, original.Method, fresh.Method) assert.Equal(t, original.Method, fresh.Method)
assert.Equal(t, original.Headers, fresh.Headers) assert.Equal(t, original.Headers, fresh.Headers)
assert.Equal(t, original.Body, fresh.Body) assert.Equal(t, original.Body, fresh.Body)
assert.Equal(t, int64(len(original.Body)), fresh.BodyBytes)
assert.Equal(t, original.ContentType, fresh.ContentType) assert.Equal(t, original.ContentType, fresh.ContentType)
assert.Equal(t, original.EntrypointID, fresh.EntrypointID) assert.Equal(t, original.EntrypointID, fresh.EntrypointID)
assert.Equal(t, original.WebhookID, fresh.WebhookID) assert.Equal(t, original.WebhookID, fresh.WebhookID)
@@ -281,7 +282,7 @@ func TestHandleEventResubmit_IsRepeatable(t *testing.T) {
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal( assert.Equal(
t, t,
"/source/"+wh.ID+"/logs?resubmit=queued", "/hook/"+wh.ID+"/events?notice=resubmit-queued",
w.Header().Get("Location"), w.Header().Get("Location"),
"a resubmit must not be refused while an earlier "+ "a resubmit must not be refused while an earlier "+
"one is in flight", "one is in flight",
@@ -435,7 +436,7 @@ func TestHandleEventResubmit_SkipsInactiveTarget(t *testing.T) {
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal( assert.Equal(
t, t,
"/source/"+wh.ID+"/logs?resubmit=queued", "/hook/"+wh.ID+"/events?notice=resubmit-queued",
w.Header().Get("Location"), w.Header().Get("Location"),
"an inactive target is skipped, not an error", "an inactive target is skipped, not an error",
) )
@@ -481,7 +482,7 @@ func TestHandleEventResubmit_NoActiveTargetsStillStoresEvent(
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal( assert.Equal(
t, t,
"/source/"+wh.ID+"/logs?resubmit=no-targets", "/hook/"+wh.ID+"/events?notice=resubmit-no-targets",
w.Header().Get("Location"), w.Header().Get("Location"),
) )
@@ -597,7 +598,7 @@ func TestHandleSourceLogs_ShowsResubmitProvenance(t *testing.T) {
) )
assert.Contains( assert.Contains(
t, body, t, body,
"/source/"+wh.ID+"/events/"+original.ID+"/resubmit", "/hook/"+wh.ID+"/events/"+original.ID+"/resubmit",
"the log must offer the resubmit action per event", "the log must offer the resubmit action per event",
) )
} }
+37 -2
View File
@@ -1,10 +1,14 @@
package handlers package handlers
import ( import (
"context"
"html/template" "html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/httptest"
"time"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
@@ -63,12 +67,43 @@ func (s *Handlers) LoadEventLogViewsForTest(
page int, page int,
) []EventLogView { ) []EventLogView {
views, _, _ := s.loadEventsWithDeliveries( views, _, _ := s.loadEventsWithDeliveries(
w, webhook, nil, page, w, newRequestForTest(), webhook, nil, page,
) )
return views return views
} }
// WebhookStatsForTest returns the figures the statistics pane on a
// webhook's page shows, from the webhook's entrypoints and targets
// loaded as that page loads them.
func (s *Handlers) WebhookStatsForTest(webhookID string) *WebhookStats {
var entrypoints []database.Entrypoint
s.db.DB().Where("webhook_id = ?", webhookID).Find(&entrypoints)
var targets []database.Target
s.db.DB().Where("webhook_id = ?", webhookID).Find(&targets)
return s.loadWebhookStats(webhookID, entrypoints, targets)
}
// FinishedByTargetForTest exposes finishedByTarget for use in the
// handlers_test package.
func FinishedByTargetForTest(
webhookDB *gorm.DB, since time.Time,
) ([]TargetFinished, error) {
return finishedByTarget(webhookDB, since)
}
// newRequestForTest is the request the helpers here pass on for
// callers that have none: it is used only to render the error page.
func newRequestForTest() *http.Request {
return httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil,
)
}
// AddTemplateForTest registers a template under a page name so that // AddTemplateForTest registers a template under a page name so that
// the handlers_test package can drive the render path with a // the handlers_test package can drive the render path with a
// template of its own. // template of its own.
@@ -122,5 +157,5 @@ func (s *Handlers) BuildDatabaseTargetConfigForTest(
w http.ResponseWriter, w http.ResponseWriter,
expiry string, expiry string,
) (string, error) { ) (string, error) {
return s.buildDatabaseTargetConfig(w, expiry) return s.buildDatabaseTargetConfig(w, newRequestForTest(), expiry)
} }
+1 -1
View File
@@ -306,7 +306,7 @@ func postWebhook(
t.Helper() t.Helper()
req := httptest.NewRequestWithContext( req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost, "/webhook/x", context.Background(), http.MethodPost, "/h/x",
strings.NewReader("{}"), strings.NewReader("{}"),
) )
+162 -42
View File
@@ -10,9 +10,12 @@ import (
"html/template" "html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"sync"
"sync/atomic" "sync/atomic"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/fx" "go.uber.org/fx"
"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"
"sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/globals"
@@ -28,7 +31,7 @@ const (
// maxBodyShift is the bit shift for 1 MB body limit. // maxBodyShift is the bit shift for 1 MB body limit.
maxBodyShift = 20 maxBodyShift = 20
// recentEventLimit is the number of recent events to show. // recentEventLimit is the number of recent events to show.
recentEventLimit = 20 recentEventLimit = 50
// paginationPerPage is the number of items per page. // paginationPerPage is the number of items per page.
paginationPerPage = 25 paginationPerPage = 25
@@ -36,6 +39,9 @@ const (
tmplKeyError = "Error" tmplKeyError = "Error"
// tmplKeyWebhook is the template data key for a webhook. // tmplKeyWebhook is the template data key for a webhook.
tmplKeyWebhook = "Webhook" tmplKeyWebhook = "Webhook"
// tmplKeyNext is the template data key for the page to return
// to after login.
tmplKeyNext = "Next"
) )
// errInvalidPassword is returned when a password does not match. // errInvalidPassword is returned when a password does not match.
@@ -53,14 +59,17 @@ type HandlersParams struct {
Logger *logger.Logger Logger *logger.Logger
Globals *globals.Globals Globals *globals.Globals
Config *config.Config
Database *database.Database Database *database.Database
WebhookDBMgr *database.WebhookDBManager WebhookDBMgr *database.WebhookDBManager
Healthcheck *healthcheck.Healthcheck Healthcheck *healthcheck.Healthcheck
Session *session.Session Session *session.Session
Middleware *middleware.Middleware Middleware *middleware.Middleware
Notifier delivery.Notifier Notifier delivery.Notifier
Evictor delivery.WebhookEvictor Archives delivery.Archives
SSRFGuard *delivery.Guard SSRFGuard *delivery.Guard
Metrics *metrics.Set
Registry *prometheus.Registry
} }
// Handlers provides HTTP handler methods for all application // Handlers provides HTTP handler methods for all application
@@ -74,7 +83,7 @@ type Handlers struct {
session *session.Session session *session.Session
mw *middleware.Middleware mw *middleware.Middleware
notifier delivery.Notifier notifier delivery.Notifier
evictor delivery.WebhookEvictor archives delivery.Archives
mtr *metrics.Set mtr *metrics.Set
templates map[string]*template.Template templates map[string]*template.Template
@@ -83,6 +92,15 @@ 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 opens the file they give.
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.
@@ -91,18 +109,23 @@ type Handlers struct {
// parsePageTemplate parses a page-specific template set from the // parsePageTemplate parses a page-specific template set from the
// embedded FS. Each page template is combined with the shared // embedded FS. Each page template is combined with the shared
// base, htmlheader, and navbar templates. The page file must be // base, htmlheader, navbar and notice templates, and with any further
// listed first so that its root action ({{template "base" .}}) // files the page includes. The page file must be listed first so that
// becomes the template set's entry point. // its root action ({{template "base" .}}) becomes the template set's
func parsePageTemplate(pageFile string) *template.Template { // entry point.
return template.Must( func parsePageTemplate(
template.ParseFS( pageFile string, included ...string,
templates.Templates, ) *template.Template {
files := append([]string{
pageFile, pageFile,
"base.html", "base.html",
"htmlheader.html", "htmlheader.html",
"navbar.html", "navbar.html",
), "notice.html",
}, included...)
return template.Must(
template.ParseFS(templates.Templates, files...),
) )
} }
@@ -121,20 +144,22 @@ func New(
s.session = params.Session s.session = params.Session
s.mw = params.Middleware s.mw = params.Middleware
s.notifier = params.Notifier s.notifier = params.Notifier
s.evictor = params.Evictor s.archives = params.Archives
s.mtr = metrics.Default() s.mtr = params.Metrics
s.ssrf = params.SSRFGuard s.ssrf = params.SSRFGuard
// Parse all page templates once at startup // Parse all page templates once at startup
s.templates = map[string]*template.Template{ s.templates = map[string]*template.Template{
"login.html": parsePageTemplate("login.html"), "login.html": parsePageTemplate("login.html"),
"profile.html": parsePageTemplate("profile.html"), "profile.html": parsePageTemplate("profile.html"),
"settings.html": parsePageTemplate("settings.html"),
"sources_list.html": parsePageTemplate("sources_list.html"), "sources_list.html": parsePageTemplate("sources_list.html"),
"sources_new.html": parsePageTemplate("sources_new.html"), "sources_new.html": parsePageTemplate("sources_new.html"),
"source_detail.html": parsePageTemplate("source_detail.html"), "source_detail.html": parsePageTemplate("source_detail.html", "webhook_stats.html"),
"source_edit.html": parsePageTemplate("source_edit.html"), "source_edit.html": parsePageTemplate("source_edit.html"),
"source_logs.html": parsePageTemplate("source_logs.html"), "source_logs.html": parsePageTemplate("source_logs.html"),
"target_edit.html": parsePageTemplate("target_edit.html"), "target_edit.html": parsePageTemplate("target_edit.html"),
"error.html": parsePageTemplate("error.html"),
} }
lc.Append(fx.Hook{ lc.Append(fx.Hook{
@@ -146,6 +171,16 @@ func New(
return s, nil return s, nil
} }
// HandleErrorPage returns a handler that answers every request with
// the error page for status. The router uses it for unknown paths, the
// CSRF middleware for a refused form, and each admin page route
// group's recoverer for a panic.
func (s *Handlers) HandleErrorPage(status int) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s.renderError(w, r, status)
}
}
func (s *Handlers) respondJSON( func (s *Handlers) respondJSON(
w http.ResponseWriter, w http.ResponseWriter,
_ *http.Request, _ *http.Request,
@@ -163,15 +198,78 @@ func (s *Handlers) respondJSON(
} }
} }
// serverError logs an error and sends a 500 response. // serverError logs an error and answers with the 500 error page.
func (s *Handlers) serverError( func (s *Handlers) serverError(
w http.ResponseWriter, msg string, err error, w http.ResponseWriter, r *http.Request, msg string, err error,
) { ) {
s.log.Error(msg, "error", err) s.log.Error(msg, "error", err)
http.Error( s.renderError(w, r, http.StatusInternalServerError)
w, "Internal server error", }
http.StatusInternalServerError,
) // renderError answers with status and the error page: the normal
// layout, one fixed line explaining the status, and a link back to the
// webhook list, or to sign-in when nobody is signed in.
//
// It renders the page itself rather than through renderTemplate,
// whose own failure comes here. If the error page cannot render
// either, the answer is the same status in plain text: never a second
// attempt, and never a different status.
func (s *Handlers) renderError(
w http.ResponseWriter,
r *http.Request,
status int,
) {
// The page names the signed-in user, and some error pages are
// served outside the routes where NoCache runs.
w.Header().Set("Cache-Control", "no-store")
// No notice: one would say an action worked above a page saying
// the request failed.
data := s.pageData(r, map[string]any{
"Status": status,
"StatusText": http.StatusText(status),
"Message": errorPageText(status),
}, nil)
var buf bytes.Buffer
err := s.templates["error.html"].Execute(&buf, data)
if err != nil {
s.log.Error("failed to render error page", "error", err)
http.Error(w, http.StatusText(status), status)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, err = buf.WriteTo(w)
if err != nil {
s.log.Error("failed to write error page", "error", err)
}
}
// errorPageText is the line the error page shows for status. It is
// fixed per status, so the page tells the reader no more than the
// plain-text answers it replaced did.
func errorPageText(status int) string {
switch status {
case http.StatusBadRequest:
return "The request could not be read."
case http.StatusForbidden:
return "The request was refused. If it came from a form " +
"left open for a long time, reload the page and try " +
"again."
case http.StatusNotFound:
return "There is nothing here. It may have been deleted, " +
"or the address may be wrong."
case http.StatusServiceUnavailable:
return "The server is busy. Please try again in a moment."
default: // http.StatusInternalServerError
return "Something went wrong on the server. Please try " +
"again."
}
} }
// UserInfo represents user information for templates // UserInfo represents user information for templates
@@ -185,6 +283,7 @@ type templateDataWrapper struct {
User *UserInfo User *UserInfo
CSRFToken string CSRFToken string
Version string Version string
Notice *notice
Data any Data any
} }
@@ -211,12 +310,26 @@ func (s *Handlers) getUserInfo(
} }
// renderTemplate renders a pre-parsed template with common // renderTemplate renders a pre-parsed template with common
// data // data and answers 200.
func (s *Handlers) renderTemplate( func (s *Handlers) renderTemplate(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
pageTemplate string, pageTemplate string,
data any, data any,
) {
s.renderTemplateStatus(w, r, pageTemplate, data, http.StatusOK)
}
// renderTemplateStatus is renderTemplate answering with status, for a
// form shown again with an error. Call it instead of WriteHeader
// followed by renderTemplate: the status is written only once the page
// has rendered, so a failed render can still answer 500.
func (s *Handlers) renderTemplateStatus(
w http.ResponseWriter,
r *http.Request,
pageTemplate string,
data any,
status int,
) { ) {
tmpl, ok := s.templates[pageTemplate] tmpl, ok := s.templates[pageTemplate]
if !ok { if !ok {
@@ -224,14 +337,22 @@ func (s *Handlers) renderTemplate(
"template not found", "template not found",
"template", pageTemplate, "template", pageTemplate,
) )
http.Error( s.renderError(w, r, http.StatusInternalServerError)
w, "Internal server error",
http.StatusInternalServerError,
)
return return
} }
s.executeTemplate(
w, r, tmpl, s.pageData(r, data, noticeFor(r)), status,
)
}
// pageData adds the fields the shared layout renders to a page's own
// data. The layout shows the notice, when there is one, above the
// page.
func (s *Handlers) pageData(
r *http.Request, data any, pageNotice *notice,
) any {
userInfo := s.getUserInfo(r) userInfo := s.getUserInfo(r)
csrfToken := middleware.CSRFToken(r) csrfToken := middleware.CSRFToken(r)
@@ -245,33 +366,34 @@ func (s *Handlers) renderTemplate(
m["User"] = userInfo m["User"] = userInfo
m["CSRFToken"] = csrfToken m["CSRFToken"] = csrfToken
m["Version"] = version m["Version"] = version
s.executeTemplate(w, tmpl, m) m["Notice"] = pageNotice
return return m
} }
wrapper := templateDataWrapper{ return templateDataWrapper{
User: userInfo, User: userInfo,
CSRFToken: csrfToken, CSRFToken: csrfToken,
Version: version, Version: version,
Notice: pageNotice,
Data: data, Data: data,
} }
s.executeTemplate(w, tmpl, wrapper)
} }
// executeTemplate renders the template into a buffer and writes to // executeTemplate renders the template into a buffer and writes status
// the response only once rendering has fully succeeded. Executing // and the page to the response only once rendering has fully
// straight into the ResponseWriter commits a partial body and a 200 // succeeded. Executing straight into the ResponseWriter commits a
// status before a mid-render error can be reported, leaving no way // partial body and the status before a mid-render error can be
// to serve a 500. Buffering makes a page's rendered size resident // reported, leaving no way to serve a 500. Buffering makes a page's
// memory per concurrent viewer, so every page owes it a bound: the // rendered size resident memory per concurrent viewer, so every page
// event log caps each stored body at maxRenderedBodyBytes for exactly // owes it a bound: the event log caps each stored body at
// this reason. // maxRenderedBodyBytes for exactly this reason.
func (s *Handlers) executeTemplate( func (s *Handlers) executeTemplate(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request,
tmpl *template.Template, tmpl *template.Template,
data any, data any,
status int,
) { ) {
var buf bytes.Buffer var buf bytes.Buffer
@@ -280,15 +402,13 @@ func (s *Handlers) executeTemplate(
s.log.Error( s.log.Error(
"failed to execute template", "error", err, "failed to execute template", "error", err,
) )
http.Error( s.renderError(w, r, http.StatusInternalServerError)
w, "Internal server error",
http.StatusInternalServerError,
)
return return
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, err = buf.WriteTo(w) _, err = buf.WriteTo(w)
if err != nil { if err != nil {
+145 -17
View File
@@ -3,6 +3,7 @@ package handlers_test
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"html/template" "html/template"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -20,6 +21,7 @@ import (
"sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/healthcheck" "sneak.berlin/go/webhooker/internal/healthcheck"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/metrics"
"sneak.berlin/go/webhooker/internal/middleware" "sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/session" "sneak.berlin/go/webhooker/internal/session"
) )
@@ -51,23 +53,103 @@ func (n *recordingNotifier) Tasks() []delivery.Task {
return out return out
} }
// recordingEvictor is a delivery.WebhookEvictor that records // recordingArchives is a delivery.Archives that records what it
// the webhook ids it was asked to evict, so a test can prove // was asked to do, so a test can prove that a deletion or rename
// that a deletion path reached the delivery engine. // path reached the delivery engine. After FailRenames, every
type recordingEvictor struct { // rename of that target fails with the given error. After
// BlockNextRename, the next rename is recorded and then waits.
type recordingArchives struct {
mu sync.Mutex mu sync.Mutex
evicted []string evicted []string
evictedTargets []string
renames []archiveRename
renameErrs map[string]error
entered chan struct{}
release chan struct{}
} }
func (r *recordingEvictor) EvictWebhook(webhookID string) { // errInjectedRename is the failure a test hands FailRenames.
var errInjectedRename = errors.New("injected rename failure")
// errNameTaken is what the delivery engine returns when a file
// already has an archive's new name, here archive-taken.db.
var errNameTaken = fmt.Errorf(
"%w: archive-taken.db", delivery.ErrArchiveNameTaken,
)
// archiveRename is one recorded Rename call.
type archiveRename struct {
TargetID string
WebhookName string
TargetName string
}
func (r *recordingArchives) EvictWebhook(webhookID string) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
r.evicted = append(r.evicted, webhookID) r.evicted = append(r.evicted, webhookID)
} }
func (r *recordingArchives) EvictTarget(targetID string) {
r.mu.Lock()
defer r.mu.Unlock()
r.evictedTargets = append(r.evictedTargets, targetID)
}
func (r *recordingArchives) Rename(
targetID, webhookName, targetName string,
) error {
r.mu.Lock()
r.renames = append(r.renames, archiveRename{
TargetID: targetID,
WebhookName: webhookName,
TargetName: targetName,
})
err := r.renameErrs[targetID]
entered, release := r.entered, r.release
r.entered, r.release = nil, nil
r.mu.Unlock()
if entered != nil {
close(entered)
<-release
}
return err
}
// BlockNextRename makes the next rename, once recorded, wait until
// the returned release is called. The returned channel is closed
// when that rename starts waiting.
func (r *recordingArchives) BlockNextRename() (<-chan struct{}, func()) {
entered := make(chan struct{})
release := make(chan struct{})
r.mu.Lock()
r.entered, r.release = entered, release
r.mu.Unlock()
return entered, func() { close(release) }
}
// FailRenames makes every later rename of targetID fail with err.
func (r *recordingArchives) FailRenames(targetID string, err error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.renameErrs == nil {
r.renameErrs = map[string]error{}
}
r.renameErrs[targetID] = err
}
// Evicted returns a copy of the recorded webhook ids. // Evicted returns a copy of the recorded webhook ids.
func (r *recordingEvictor) Evicted() []string { func (r *recordingArchives) Evicted() []string {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
@@ -77,22 +159,62 @@ func (r *recordingEvictor) Evicted() []string {
return out return out
} }
// EvictedTargets returns a copy of the recorded target ids.
func (r *recordingArchives) EvictedTargets() []string {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]string, len(r.evictedTargets))
copy(out, r.evictedTargets)
return out
}
// Renames returns a copy of the recorded renames.
func (r *recordingArchives) Renames() []archiveRename {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]archiveRename, len(r.renames))
copy(out, r.renames)
return out
}
// newTestApp returns an app whose RequireStart fails the test when
// starting takes longer than fx's default start timeout of 15s. That
// limit catches a start that hangs, not a busy host: measured with make
// test on 2026-10-02 at host load 58-69 on 48 cores, the slowest of this
// package's starts took 0.49s.
func newTestApp( func newTestApp(
t *testing.T, t *testing.T,
targets ...any, targets ...any,
) *fxtest.App { ) *fxtest.App {
t.Helper() t.Helper()
return newTestAppWithConfig(
t, &config.Config{DataDir: t.TempDir()}, targets...,
)
}
// newTestAppWithConfig is newTestApp over a caller-supplied Config.
func newTestAppWithConfig(
t *testing.T,
cfg *config.Config,
targets ...any,
) *fxtest.App {
t.Helper()
return fxtest.New( return fxtest.New(
t, t,
// fx's own log is discarded, not sent to t.Logf: a hook still
// running after a start or stop timeout would write there after
// the test has returned.
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
func() *config.Config { func() *config.Config { return cfg },
return &config.Config{
DataDir: t.TempDir(),
}
},
database.New, database.New,
database.NewWebhookDBManager, database.NewWebhookDBManager,
healthcheck.New, healthcheck.New,
@@ -103,12 +225,14 @@ func newTestApp(
func(n *recordingNotifier) delivery.Notifier { func(n *recordingNotifier) delivery.Notifier {
return n return n
}, },
func() *recordingEvictor { func() *recordingArchives {
return &recordingEvictor{} return &recordingArchives{}
}, },
func(r *recordingEvictor) delivery.WebhookEvictor { func(r *recordingArchives) delivery.Archives {
return r return r
}, },
metrics.NewRegistry,
metrics.New,
middleware.New, middleware.New,
delivery.NewGuard, delivery.NewGuard,
handlers.New, handlers.New,
@@ -176,7 +300,7 @@ func TestHandleIndex_Authenticated(t *testing.T) {
assert.Equal(t, http.StatusSeeOther, w2.Code) assert.Equal(t, http.StatusSeeOther, w2.Code)
assert.Equal( assert.Equal(
t, "/sources", w2.Header().Get("Location"), t, "/hooks", w2.Header().Get("Location"),
) )
} }
@@ -307,10 +431,14 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
t, http.StatusInternalServerError, w.Code, t, http.StatusInternalServerError, w.Code,
"a failed render must report a 500", "a failed render must report a 500",
) )
assert.Equal( assert.NotContains(
t, "Internal server error\n", w.Body.String(), t, w.Body.String(), partialPageMarker,
"the response must carry no part of the aborted page", "the response must carry no part of the aborted page",
) )
assert.Contains(
t, w.Body.String(), "500 Internal Server Error",
"a failed render must answer with the error page",
)
} }
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) { func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
+2 -2
View File
@@ -5,13 +5,13 @@ import (
) )
// HandleIndex returns a handler for the root path that redirects // HandleIndex returns a handler for the root path that redirects
// based on authentication state: authenticated users go to /sources // based on authentication state: authenticated users go to /hooks
// (the dashboard), unauthenticated users go to the login page. // (the dashboard), unauthenticated users go to the login page.
func (s *Handlers) HandleIndex() http.HandlerFunc { func (s *Handlers) HandleIndex() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
sess, err := s.session.Get(r) sess, err := s.session.Get(r)
if err == nil && s.session.IsAuthenticated(sess) { if err == nil && s.session.IsAuthenticated(sess) {
http.Redirect(w, r, "/sources", http.StatusSeeOther) http.Redirect(w, r, "/hooks", http.StatusSeeOther)
return return
} }
+8 -10
View File
@@ -4,7 +4,7 @@ package handlers_test
// this package reach a value an UNAUTHENTICATED client picks outright // this package reach a value an UNAUTHENTICATED client picks outright
// and of a length it picks outright: // and of a length it picks outright:
// //
// - the unknown-entrypoint DEBUG line on /webhook/{uuid}, whose // - the unknown-entrypoint DEBUG line on /h/{uuid}, whose
// path segment matched no stored entrypoint and so is bounded by // path segment matched no stored entrypoint and so is bounded by
// nothing; // nothing;
// - the failed-login DEBUG lines, whose username is a form field. // - the failed-login DEBUG lines, whose username is a form field.
@@ -190,12 +190,12 @@ func assertNoClientText(t *testing.T, buf *bytes.Buffer) {
// route pattern. // route pattern.
func receiverRouter(h *handlers.Handlers) *chi.Mux { func receiverRouter(h *handlers.Handlers) *chi.Mux {
router := chi.NewRouter() router := chi.NewRouter()
router.Post("/webhook/{uuid}", h.HandleWebhook()) router.Post("/h/{uuid}", h.HandleWebhook())
return router return router
} }
// postReceiver sends one POST at /webhook/<segment>. // postReceiver sends one POST at /h/<segment>.
// //
// RawPath is cleared after parsing so chi routes on the decoded path // RawPath is cleared after parsing so chi routes on the decoded path
// and the handler sees the raw bytes rather than their percent-escaped // and the handler sees the raw bytes rather than their percent-escaped
@@ -210,7 +210,7 @@ func postReceiver(
req := httptest.NewRequestWithContext( req := httptest.NewRequestWithContext(
context.Background(), context.Background(),
http.MethodPost, http.MethodPost,
"/webhook/"+url.PathEscape(segment), "/h/"+url.PathEscape(segment),
strings.NewReader(""), strings.NewReader(""),
) )
req.URL.RawPath = "" req.URL.RawPath = ""
@@ -339,11 +339,9 @@ const storedUserPassword = "correct-horse-battery-staple"
// storedFillBytes is the raw length of the client-chosen value in // storedFillBytes is the raw length of the client-chosen value in
// those accounts' usernames. It is well past the 512-byte field // those accounts' usernames. It is well past the 512-byte field
// budget, so the line is still truncated, but short enough that the // budget, so the line is still truncated, but short enough that the
// session cookie a successful login writes stays inside // whole username, markers and fill name included, stays within
// securecookie's 4 KB limit: the cookie is written BEFORE the // database.MaxUsernameBytes.
// "user logged in" line, so an 8 KB username answers 500 and never const storedFillBytes = 960
// reaches it.
const storedFillBytes = 1024
// storedFill builds a username fill of storedFillBytes raw bytes out // storedFill builds a username fill of storedFillBytes raw bytes out
// of repetitions of ch, with both markers at its far end. // of repetitions of ch, with both markers at its far end.
@@ -509,7 +507,7 @@ func TestVerificationCapacity_LogLineDoesNotTrackPathSize(
http.StatusServiceUnavailable, http.StatusServiceUnavailable,
postLoginAtPath( postLoginAtPath(
t, h, t, h,
"/source/"+url.PathEscape( "/hook/"+url.PathEscape(
oversizedFill(fill), oversizedFill(fill),
)+"/login", )+"/login",
), ),
+21
View File
@@ -0,0 +1,21 @@
package handlers
import (
"net/http"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// HandleMetrics returns the Prometheus scrape handler for the
// registry built by metrics.NewRegistry, which the HTTP, delivery, Go
// runtime and process collectors register on. It is what
// promhttp.Handler builds for the global default registry, including
// the promhttp_metric_handler_* series that count scrapes, pointed at
// that registry instead.
func (s *Handlers) HandleMetrics() http.HandlerFunc {
reg := s.params.Registry
return promhttp.InstrumentMetricHandler(
reg, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}),
).ServeHTTP
}
+109
View File
@@ -0,0 +1,109 @@
package handlers
import "net/http"
// noticeParam is the query parameter an action's redirect carries its
// notice code in.
const noticeParam = "notice"
// noticeCode names one of the fixed lines noticeFor knows. An action
// redirects with the code rather than the line, so nothing a client
// puts in the URL reaches the page: a code noticeFor does not know
// shows nothing.
type noticeCode string
// The codes of the actions on the webhook pages and of signing out.
// Replay's codes, with the reasons a replay can be refused, and
// resubmit's codes are defined beside those actions.
const (
webhookCreated noticeCode = "webhook-created"
webhookSaved noticeCode = "webhook-saved"
webhookDeleted noticeCode = "webhook-deleted"
entrypointAdded noticeCode = "entrypoint-added"
entrypointDeleted noticeCode = "entrypoint-deleted"
entrypointActivated noticeCode = "entrypoint-activated"
entrypointDeactivated noticeCode = "entrypoint-deactivated"
targetAdded noticeCode = "target-added"
targetSaved noticeCode = "target-saved"
targetDeleted noticeCode = "target-deleted"
targetActivated noticeCode = "target-activated"
targetDeactivated noticeCode = "target-deactivated"
signedOut noticeCode = "signed-out"
)
// notice is the line templates/notice.html shows above a page to say
// what an action did.
type notice struct {
Text string
// Failed shows the line as an error: the action was refused.
Failed bool
}
// noticeFor returns the notice the request's URL names, or nil when it
// names none or an unknown code.
func noticeFor(r *http.Request) *notice {
n, ok := map[noticeCode]notice{
webhookCreated: {Text: "Webhook created."},
webhookSaved: {Text: "Webhook saved."},
webhookDeleted: {Text: "Webhook deleted."},
entrypointAdded: {Text: "Entrypoint added."},
entrypointDeleted: {Text: "Entrypoint deleted."},
entrypointActivated: {Text: "Entrypoint activated."},
entrypointDeactivated: {Text: "Entrypoint deactivated."},
targetAdded: {Text: "Target added."},
targetSaved: {Text: "Target saved."},
targetDeleted: {Text: "Target deleted."},
targetActivated: {Text: "Target activated."},
targetDeactivated: {Text: "Target deactivated."},
signedOut: {Text: "Signed out."},
replayQueued: {
Text: "Replay queued: a new delivery was created " +
"against the target's current configuration.",
},
replayTargetDeleted: {
Text: "Not replayed: the target this delivery was for " +
"has been deleted. Recreate the target, then replay.",
Failed: true,
},
replayTargetMissing: {
Text: "Not replayed: the target this delivery was for " +
"no longer exists.",
Failed: true,
},
replayTargetInactive: {
Text: "Not replayed: the target this delivery was for " +
"is deactivated. Activate it, then replay.",
Failed: true,
},
replayNotTerminal: {
Text: "Not replayed: this delivery has not finished yet.",
Failed: true,
},
replayInFlight: {
Text: "Not replayed: a delivery of this event to this " +
"target is already in flight.",
Failed: true,
},
resubmitQueued: {
Text: "Resubmitted: a new event was created from the " +
"stored one and queued to every active target.",
},
resubmitNoTargets: {
Text: "Resubmitted: a new event was created, but this " +
"source has no active targets, so nothing was queued.",
},
}[noticeCode(r.URL.Query().Get(noticeParam))]
if !ok {
return nil
}
return &n
}
// withNotice returns path with code added as its notice.
func withNotice(path string, code noticeCode) string {
return path + "?" + noticeParam + "=" + string(code)
}
+16 -28
View File
@@ -1,7 +1,6 @@
package handlers package handlers
import ( import (
"context"
"net/http" "net/http"
"github.com/go-chi/chi" "github.com/go-chi/chi"
@@ -37,14 +36,14 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
h.log.Error("failed to parse form", "error", err) h.log.Error("failed to parse form", "error", err)
http.Error(w, "Bad request", http.StatusBadRequest) h.renderError(w, r, http.StatusBadRequest)
return return
} }
successMessage, errorMessage, handled := h.applyPasswordChange( successMessage, errorMessage, handled := h.applyPasswordChange(
r.Context(),
w, w,
r,
sessionUsername, sessionUsername,
// PostFormValue, not FormValue: the credential must // PostFormValue, not FormValue: the credential must
// come from the body, never from the query string. // come from the body, never from the query string.
@@ -66,12 +65,12 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
// applyPasswordChange verifies the current password and, on success, // applyPasswordChange verifies the current password and, on success,
// persists a fresh hash for the user, reusing the same helpers that // persists a fresh hash for the user, reusing the same helpers that
// bootstrap the admin user. It returns the success and error messages // bootstrap the admin user. It returns the success and error messages
// to display on the profile page. On an internal failure it writes a // to display on the profile page. On an internal failure it writes the
// 500 response itself and returns handled=false, signalling the caller // error page itself and returns handled=false, signalling the caller
// to stop without re-rendering the page. // to stop without re-rendering the page.
func (h *Handlers) applyPasswordChange( func (h *Handlers) applyPasswordChange(
ctx context.Context,
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request,
username, currentPassword, newPassword, confirmPassword string, username, currentPassword, newPassword, confirmPassword string,
) (string, string, bool) { ) (string, string, bool) {
// This endpoint verifies one password and hashes another, at // This endpoint verifies one password and hashes another, at
@@ -79,15 +78,10 @@ func (h *Handlers) applyPasswordChange(
// endpoint uses. The bound is per hash, not per endpoint: leaving // endpoint uses. The bound is per hash, not per endpoint: leaving
// this path outside it would leave a hole in it. The slot is held // this path outside it would leave a hole in it. The slot is held
// across both hashes. // across both hashes.
release, ok := h.mw.BeginPasswordVerification(ctx) release, ok := h.mw.BeginPasswordVerification(r.Context())
if !ok { if !ok {
h.log.Warn("password verification capacity exhausted") h.log.Warn("password verification capacity exhausted")
http.Error( h.renderError(w, r, http.StatusServiceUnavailable)
w,
"The server is busy verifying credentials. "+
"Please try again.",
http.StatusServiceUnavailable,
)
return "", "", false return "", "", false
} }
@@ -103,7 +97,7 @@ func (h *Handlers) applyPasswordChange(
).First(&user).Error ).First(&user).Error
if err != nil { if err != nil {
h.serverError( h.serverError(
w, "failed to load user for password change", err, w, r, "failed to load user for password change", err,
) )
return "", "", false return "", "", false
@@ -113,7 +107,7 @@ func (h *Handlers) applyPasswordChange(
currentPassword, user.Password, currentPassword, user.Password,
) )
if err != nil { if err != nil {
h.serverError(w, "failed to verify password", err) h.serverError(w, r, "failed to verify password", err)
return "", "", false return "", "", false
} }
@@ -132,7 +126,7 @@ func (h *Handlers) applyPasswordChange(
hashedPassword, err := database.HashPassword(newPassword) hashedPassword, err := database.HashPassword(newPassword)
if err != nil { if err != nil {
h.serverError(w, "failed to hash new password", err) h.serverError(w, r, "failed to hash new password", err)
return "", "", false return "", "", false
} }
@@ -141,7 +135,7 @@ func (h *Handlers) applyPasswordChange(
"password", hashedPassword, "password", hashedPassword,
).Error ).Error
if err != nil { if err != nil {
h.serverError(w, "failed to update password", err) h.serverError(w, r, "failed to update password", err)
return "", "", false return "", "", false
} }
@@ -162,7 +156,7 @@ func (h *Handlers) profileOwnerOrDeny(
) (string, string, bool) { ) (string, string, bool) {
requestedUsername := chi.URLParam(r, "username") requestedUsername := chi.URLParam(r, "username")
if requestedUsername == "" { if requestedUsername == "" {
http.NotFound(w, r) h.renderError(w, r, http.StatusNotFound)
return "", "", false return "", "", false
} }
@@ -172,7 +166,7 @@ func (h *Handlers) profileOwnerOrDeny(
// unexpected retrieval error. // unexpected retrieval error.
sess, err := h.session.Get(r) sess, err := h.session.Get(r)
if err != nil { if err != nil {
h.serverError(w, "failed to get session", err) h.serverError(w, r, "failed to get session", err)
return "", "", false return "", "", false
} }
@@ -180,10 +174,7 @@ func (h *Handlers) profileOwnerOrDeny(
sessionUsername, ok := h.session.GetUsername(sess) sessionUsername, ok := h.session.GetUsername(sess)
if !ok { if !ok {
h.log.Error("authenticated session missing username") h.log.Error("authenticated session missing username")
http.Error( h.renderError(w, r, http.StatusInternalServerError)
w, "Internal server error",
http.StatusInternalServerError,
)
return "", "", false return "", "", false
} }
@@ -191,17 +182,14 @@ func (h *Handlers) profileOwnerOrDeny(
sessionUserID, ok := h.session.GetUserID(sess) sessionUserID, ok := h.session.GetUserID(sess)
if !ok { if !ok {
h.log.Error("authenticated session missing user ID") h.log.Error("authenticated session missing user ID")
http.Error( h.renderError(w, r, http.StatusInternalServerError)
w, "Internal server error",
http.StatusInternalServerError,
)
return "", "", false return "", "", false
} }
// Only allow users to act on their own profile. // Only allow users to act on their own profile.
if requestedUsername != sessionUsername { if requestedUsername != sessionUsername {
http.Error(w, "Forbidden", http.StatusForbidden) h.renderError(w, r, http.StatusForbidden)
return "", "", false return "", "", false
} }
+10 -3
View File
@@ -88,6 +88,8 @@ func TestHandleProfile_OwnProfile_OK(t *testing.T) {
h.HandleProfile().ServeHTTP(w, req) h.HandleProfile().ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "Account Information")
assert.NotContains(t, w.Body.String(), "Account Type")
} }
func TestHandleProfile_OtherProfile_Forbidden(t *testing.T) { func TestHandleProfile_OtherProfile_Forbidden(t *testing.T) {
@@ -126,7 +128,9 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
var sess *session.Session var sess *session.Session
app := newTestApp(t, &log, &cfg, &sess) var h *handlers.Handlers
app := newTestApp(t, &log, &cfg, &sess, &h)
app.RequireStart() app.RequireStart()
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
@@ -137,7 +141,7 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
router := chi.NewRouter() router := chi.NewRouter()
router.Route("/user/{username}", func(r chi.Router) { router.Route("/user/{username}", func(r chi.Router) {
r.Use(mw.CSRF()) r.Use(mw.CSRF(h.HandleErrorPage(http.StatusForbidden)))
r.Use(mw.RequireAuth()) r.Use(mw.RequireAuth())
r.Get("/", func(w http.ResponseWriter, _ *http.Request) { r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
handlerReached = true handlerReached = true
@@ -158,7 +162,10 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
"handler must not be reached for unauthenticated request", "handler must not be reached for unauthenticated request",
) )
assert.Equal(t, http.StatusSeeOther, w.Code) assert.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(t, "/pages/login", w.Header().Get("Location")) assert.Equal(
t, "/pages/login?next=%2Fuser%2Ftestuser",
w.Header().Get("Location"),
)
} }
// passwordChangeRequest builds a POST request to the password-change // passwordChangeRequest builds a POST request to the password-change
+293
View File
@@ -0,0 +1,293 @@
package handlers
import (
"net/http"
"slices"
"strconv"
"time"
"github.com/dustin/go-humanize"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
// recentEventColumns is the recent events list's projection. It
// leaves out the body, for the reason maxRenderedBodyBytes gives,
// and reads its size from body_bytes, recorded when the event was
// stored.
const recentEventColumns = "id, created_at, method, content_type, " +
"resubmitted_from_id, body_bytes"
// recentAttemptColumns is the part of a recorded attempt the list
// uses. The event log's deliveryResultColumns also reads response
// bodies, which the list does not show.
const recentAttemptColumns = "delivery_id, status_code, created_at"
// RecentEventView is one row of the recent events list on a
// webhook's page.
type RecentEventView struct {
Method string
ContentType string
// ResubmittedFromID names the event this one was copied from,
// empty for an event that arrived on the receiver.
ResubmittedFromID string
// Received is how long ago the event arrived, and ReceivedUTC
// the full timestamp the page shows on hover.
Received string
ReceivedUTC string
// Size is the size of the stored body.
Size string
// ProcessingTime is how long the event's slowest delivery
// took; see processingTime.
ProcessingTime string
// Status is what the webhook's HTTP target answered, and
// StatusClass its colour; see targetStatus. Both are empty
// unless the webhook has exactly one HTTP target.
Status string
StatusClass string
}
// recentEventRow is one row of recentEventColumns.
type recentEventRow struct {
ID string
CreatedAt time.Time
Method string
ContentType string
ResubmittedFromID *string
BodyBytes uint64
}
// recentAttemptRow is one row of recentAttemptColumns. CreatedAt is
// when the attempt's result was recorded, which is when the attempt
// finished.
type recentAttemptRow struct {
DeliveryID string
StatusCode int
CreatedAt time.Time
}
// singleHTTPTargetID returns the ID of the webhook's HTTP target
// when it has exactly one, and "" when it has none or several.
func singleHTTPTargetID(targets []database.Target) string {
id := ""
count := 0
for i := range targets {
if targets[i].Type == database.TargetTypeHTTP {
id = targets[i].ID
count++
}
}
if count != 1 {
return ""
}
return id
}
// loadRecentEvents loads the webhook's recentEventLimit newest
// events for its page, newest first. statusTargetID is the
// webhook's only HTTP target, or "" when the list shows no status.
func loadRecentEvents(
webhookDB *gorm.DB, webhookID, statusTargetID string,
) ([]RecentEventView, error) {
var rows []recentEventRow
err := webhookDB.Model(&database.Event{}).
Select(recentEventColumns).
Where("webhook_id = ?", webhookID).
Order("created_at DESC").
Limit(recentEventLimit).
Find(&rows).Error
if err != nil {
return nil, err
}
eventIDs := make([]string, len(rows))
for i := range rows {
eventIDs[i] = rows[i].ID
}
// Oldest first, so an event's last delivery to a target is its
// newest: a replay adds a delivery rather than changing the
// earlier one.
var deliveries []database.Delivery
err = webhookDB.
Select("id, event_id, target_id, status, created_at").
Where("event_id IN ?", eventIDs).
Order("created_at ASC").
Find(&deliveries).Error
if err != nil {
return nil, err
}
byEvent := make(map[string][]database.Delivery, len(rows))
deliveryIDs := make([]string, len(deliveries))
for i := range deliveries {
eventID := deliveries[i].EventID
byEvent[eventID] = append(byEvent[eventID], deliveries[i])
deliveryIDs[i] = deliveries[i].ID
}
attempts, err := loadRecentAttempts(webhookDB, deliveryIDs)
if err != nil {
return nil, err
}
views := make([]RecentEventView, len(rows))
for i := range rows {
views[i] = rows[i].view(
byEvent[rows[i].ID], attempts, statusTargetID,
)
}
return views, nil
}
// loadRecentAttempts loads the recorded attempts of the listed
// events' deliveries, keyed by delivery ID, each delivery's in
// attempt order. The IDs go in chunks for the reason
// deliveryIDChunkSize gives.
func loadRecentAttempts(
webhookDB *gorm.DB, deliveryIDs []string,
) (map[string][]recentAttemptRow, error) {
byDelivery := make(map[string][]recentAttemptRow)
for chunk := range slices.Chunk(deliveryIDs, deliveryIDChunkSize) {
var rows []recentAttemptRow
err := webhookDB.Model(&database.DeliveryResult{}).
Select(recentAttemptColumns).
Where("delivery_id IN ?", chunk).
Order("attempt_num ASC").
Find(&rows).Error
if err != nil {
return nil, err
}
for i := range rows {
id := rows[i].DeliveryID
byDelivery[id] = append(byDelivery[id], rows[i])
}
}
return byDelivery, nil
}
// view projects a loaded row for rendering. deliveries is the
// event's deliveries, oldest first, and attempts their recorded
// attempts keyed by delivery ID.
func (r *recentEventRow) view(
deliveries []database.Delivery,
attempts map[string][]recentAttemptRow,
statusTargetID string,
) RecentEventView {
v := RecentEventView{
Method: r.Method,
ContentType: r.ContentType,
Received: humanize.Time(r.CreatedAt),
ReceivedUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
Size: humanize.Bytes(r.BodyBytes),
ProcessingTime: processingTime(deliveries, attempts),
}
if r.ResubmittedFromID != nil {
v.ResubmittedFromID = *r.ResubmittedFromID
}
if statusTargetID != "" {
v.Status, v.StatusClass = targetStatus(
deliveries, attempts, statusTargetID,
)
}
return v
}
// processingTime is how long the event's slowest delivery took,
// from being queued to its last recorded attempt, time spent
// waiting between retries included. A delivery is queued when its
// event is received, or when an operator replays it, so a replay
// is timed from the replay rather than from the event's arrival.
// It is "in progress" while any delivery is pending or retrying,
// and empty for an event with no deliveries.
func processingTime(
deliveries []database.Delivery,
attempts map[string][]recentAttemptRow,
) string {
if len(deliveries) == 0 {
return ""
}
var slowest time.Duration
for i := range deliveries {
if !deliveries[i].Status.Terminal() {
return "in progress"
}
tries := attempts[deliveries[i].ID]
if len(tries) == 0 {
continue
}
last := tries[len(tries)-1].CreatedAt
slowest = max(slowest, last.Sub(deliveries[i].CreatedAt))
}
return slowest.Round(time.Millisecond).String()
}
// targetStatus is what the target answered for the event, and the
// colour to show it in: the HTTP status code of the last attempt of
// the event's newest delivery to the target. Without a code it is
// "no response" when that attempt failed before a response
// arrived, the delivery's status ("pending") before any attempt,
// and "not sent" when the event has no delivery to the target.
func targetStatus(
deliveries []database.Delivery,
attempts map[string][]recentAttemptRow,
targetID string,
) (string, string) {
newest := -1
for i := range deliveries {
if deliveries[i].TargetID == targetID {
newest = i
}
}
if newest < 0 {
return "not sent", "text-gray-400"
}
tries := attempts[deliveries[newest].ID]
if len(tries) == 0 {
return string(deliveries[newest].Status), "text-gray-400"
}
code := tries[len(tries)-1].StatusCode
switch {
case code == 0:
return "no response", "text-red-600"
case code >= http.StatusInternalServerError:
return strconv.Itoa(code), "text-red-600"
case code >= http.StatusBadRequest:
return strconv.Itoa(code), "text-yellow-600"
case code >= http.StatusMultipleChoices:
return strconv.Itoa(code), "text-gray-500"
case code >= http.StatusOK:
return strconv.Itoa(code), "text-green-600"
default:
return strconv.Itoa(code), "text-gray-500"
}
}
+362
View File
@@ -0,0 +1,362 @@
package handlers_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/session"
)
// statusTitle marks the status column's cell in a recent events
// row; it is absent from the page when the column is not shown.
const statusTitle = `title="HTTP status from the HTTP target"`
// recentEventsFixture is one started app and a webhook whose
// recent events list a test fills.
type recentEventsFixture struct {
h *handlers.Handlers
sess *session.Session
db *database.Database
webhook *database.Webhook
webhookDB *gorm.DB
}
func newRecentEventsFixture(t *testing.T) *recentEventsFixture {
t.Helper()
f := &recentEventsFixture{}
var dbMgr *database.WebhookDBManager
app := newTestApp(t, &f.h, &f.sess, &f.db, &dbMgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
f.webhook = seedWebhook(t, f.db)
webhookDB, err := dbMgr.GetDB(f.webhook.ID)
require.NoError(t, err)
f.webhookDB = webhookDB
return f
}
func (f *recentEventsFixture) render(t *testing.T) string {
t.Helper()
return renderSourceDetailPage(t, f.h, f.sess, f.webhook.ID)
}
// event records an event received at receivedAt, with its body's
// size as the receiver records it.
func (f *recentEventsFixture) event(
t *testing.T, contentType, body string, receivedAt time.Time,
) *database.Event {
t.Helper()
event := &database.Event{
WebhookID: f.webhook.ID,
Method: http.MethodPost,
Body: body,
BodyBytes: int64(len(body)),
ContentType: contentType,
}
event.CreatedAt = receivedAt
require.NoError(t, f.webhookDB.Omit(
clause.Associations,
).Create(event).Error)
return event
}
// delivery records a delivery of the event to the target, queued
// when the event was received.
func (f *recentEventsFixture) delivery(
t *testing.T,
event *database.Event,
targetID string,
status database.DeliveryStatus,
) *database.Delivery {
t.Helper()
return f.deliveryQueuedAt(
t, event, targetID, status, event.CreatedAt,
)
}
// deliveryQueuedAt records a delivery of the event to the target,
// queued at queuedAt, as a replay is.
func (f *recentEventsFixture) deliveryQueuedAt(
t *testing.T,
event *database.Event,
targetID string,
status database.DeliveryStatus,
queuedAt time.Time,
) *database.Delivery {
t.Helper()
dlv := &database.Delivery{
EventID: event.ID,
TargetID: targetID,
Status: status,
}
dlv.CreatedAt = queuedAt
require.NoError(t, f.webhookDB.Omit(
clause.Associations,
).Create(dlv).Error)
return dlv
}
// attempt records one attempt of the delivery that finished took
// after the delivery was queued, with HTTP status code (0 for no
// response).
func (f *recentEventsFixture) attempt(
t *testing.T, dlv *database.Delivery, code int, took time.Duration,
) {
t.Helper()
result := &database.DeliveryResult{
DeliveryID: dlv.ID,
AttemptNum: 1,
StatusCode: code,
}
result.CreatedAt = dlv.CreatedAt.Add(took)
require.NoError(t, f.webhookDB.Omit(
clause.Associations,
).Create(result).Error)
}
// statusCell is the status column's cell as the page renders it.
func statusCell(class, text string) string {
return `<span class="font-medium ` + class + `" ` + statusTitle +
`>` + text + `</span>`
}
// TestHandleSourceDetail_ShowsFiftyNewestEvents proves the list is
// headed "50 Most Recent Events" and holds the 50 newest events,
// newest first, and not one more.
func TestHandleSourceDetail_ShowsFiftyNewestEvents(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
base := time.Now().Add(-time.Hour)
for i := range 51 {
f.event(
t, fmt.Sprintf("application/x-recent-%02d", i), "{}",
base.Add(time.Duration(i)*time.Second),
)
}
body := f.render(t)
assert.Contains(t, body, ">50 Most Recent Events</h2>")
assert.Equal(t, 50, strings.Count(body, `title="Body size"`))
assert.NotContains(t, body, "application/x-recent-00")
assert.Contains(t, body, "application/x-recent-01")
assert.Less(
t,
strings.Index(body, "application/x-recent-50"),
strings.Index(body, "application/x-recent-49"),
)
}
// TestHandleSourceDetail_RecentEventColumns proves a row shows its
// time relative with the UTC timestamp on hover, its body size,
// and its processing time once every delivery has finished.
func TestHandleSourceDetail_RecentEventColumns(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
logTarget := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
receivedAt := time.Now().Add(-210 * time.Second).
UTC().Truncate(time.Second)
done := f.event(
t, contentTypeJSON, strings.Repeat("x", 2048), receivedAt,
)
f.attempt(
t,
f.delivery(t, done, logTarget.ID, database.DeliveryStatusDelivered),
0, 1500*time.Millisecond,
)
waiting := f.event(t, "text/plain", "{}", receivedAt)
f.delivery(t, waiting, logTarget.ID, database.DeliveryStatusPending)
body := f.render(t)
assert.Contains(
t, body,
`<span title="`+receivedAt.Format(time.DateTime)+
` UTC">3 minutes ago</span>`,
)
assert.Contains(t, body, `<span title="Body size">2.0 kB</span>`)
assert.Contains(t, body, ">1.5s</span>")
assert.Contains(t, body, ">in progress</span>")
}
// TestHandleSourceDetail_StatusWithSingleHTTPTarget proves that a
// webhook with exactly one HTTP target shows, colour-coded, what
// that target answered for each event. The log target beside it
// does not count against "exactly one".
func TestHandleSourceDetail_StatusWithSingleHTTPTarget(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
now := time.Now()
for _, code := range []int{204, 302, 404, 503, 0} {
dlv := f.delivery(
t, f.event(t, contentTypeJSON, "{}", now), target.ID,
database.DeliveryStatusDelivered,
)
f.attempt(t, dlv, code, time.Second)
}
f.delivery(
t, f.event(t, contentTypeJSON, "{}", now), target.ID,
database.DeliveryStatusPending,
)
f.event(t, contentTypeJSON, "{}", now)
// A replay is a newer delivery, and its answer is the one shown.
replayed := f.event(t, contentTypeJSON, "{}", now)
f.attempt(t, f.delivery(
t, replayed, target.ID, database.DeliveryStatusFailed,
), 502, time.Second)
f.attempt(t, f.deliveryQueuedAt(
t, replayed, target.ID, database.DeliveryStatusDelivered,
now.Add(time.Minute),
), 200, time.Second)
body := f.render(t)
assert.Contains(t, body, statusCell("text-green-600", "204"))
assert.Contains(t, body, statusCell("text-gray-500", "302"))
assert.Contains(t, body, statusCell("text-yellow-600", "404"))
assert.Contains(t, body, statusCell("text-red-600", "503"))
assert.Contains(t, body, statusCell("text-red-600", "no response"))
assert.Contains(t, body, statusCell("text-gray-400", "pending"))
assert.Contains(t, body, statusCell("text-gray-400", "not sent"))
assert.Contains(t, body, statusCell("text-green-600", "200"))
assert.NotContains(t, body, ">502<")
}
// TestHandleSourceDetail_NoStatusWithoutSingleHTTPTarget proves the
// status column is absent when the webhook has no HTTP target or
// more than one.
func TestHandleSourceDetail_NoStatusWithoutSingleHTTPTarget(
t *testing.T,
) {
t.Parallel()
cases := map[string][]database.TargetType{
"none": {database.TargetTypeLog},
"several": {database.TargetTypeHTTP, database.TargetTypeHTTP},
}
for name, types := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
event := f.event(t, contentTypeJSON, "{}", time.Now())
for _, tt := range types {
target := seedTarget(t, f.db, f.webhook.ID, tt)
f.attempt(t, f.delivery(
t, event, target.ID,
database.DeliveryStatusDelivered,
), 200, time.Second)
}
body := f.render(t)
assert.Contains(t, body, `title="Body size"`)
assert.NotContains(t, body, statusTitle)
})
}
}
// TestHandleWebhook_RecordsBodySize proves the receiver records the
// body's size in bytes, not characters, with the event it stores.
func TestHandleWebhook_RecordsBodySize(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
seedEntrypoint(t, f.db, f.webhook.ID)
// Two bytes per character.
body := strings.Repeat("é", 1024)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost, "/h/x",
strings.NewReader(body),
)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("uuid", "ep-"+f.webhook.ID)
req = req.WithContext(context.WithValue(
req.Context(), chi.RouteCtxKey, rctx,
))
w := httptest.NewRecorder()
f.h.HandleWebhook().ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var stored database.Event
require.NoError(t, f.webhookDB.First(&stored).Error)
assert.Equal(t, int64(2048), stored.BodyBytes)
}
// TestHandleSourceDetail_FailedLoadIsAnError proves that when the
// list cannot be loaded the page answers with an error, rather than
// an empty list claiming the webhook has no events.
func TestHandleSourceDetail_FailedLoadIsAnError(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
f.attempt(t, f.delivery(
t, f.event(t, contentTypeJSON, "{}", time.Now()), target.ID,
database.DeliveryStatusDelivered,
), 200, time.Second)
// The attempts are the list's last query, so its events and
// deliveries have already loaded when it fails.
require.NoError(t, f.webhookDB.Exec(
"DROP TABLE delivery_results",
).Error)
w := serveSourceDetailPage(t, f.h, f.sess, f.webhook.ID)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.NotContains(t, w.Body.String(), "No events received yet.")
}
+129
View File
@@ -0,0 +1,129 @@
package handlers
import (
"net/http"
"net/netip"
"strconv"
"strings"
"sneak.berlin/go/webhooker/internal/config"
)
// notSet is what the Settings page shows for a value that is empty.
const notSet = "not set"
// settingRow is one line of the Settings page: an environment
// variable, what it controls, and the value the server loaded for it.
type settingRow struct {
Name string
Description string
Value string
}
// HandleSettings returns a handler for the read-only Settings page,
// which lists the configuration the server started with.
func (h *Handlers) HandleSettings() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.renderTemplate(w, r, "settings.html", map[string]any{
"Settings": settingRows(h.params.Config),
})
}
}
// settingRows lists every field of cfg under the environment variable
// it is read from, with the description the README's configuration
// table gives it (less its pointers to other README sections), in the
// table's order. METRICS_PASSWORD and SENTRY_DSN are credentials, so
// their values never reach the page: only whether they are set.
func settingRows(cfg *config.Config) []settingRow {
metricsUsername := cfg.MetricsUsername
if metricsUsername == "" {
metricsUsername = notSet
}
return []settingRow{
{"WEBHOOKER_ENVIRONMENT", "dev or prod", cfg.Environment},
{"PORT", "HTTP listen port", strconv.Itoa(cfg.Port)},
{
"BIND_ADDRESS",
"IP address the HTTP listener binds. Loopback by default, " +
"so the cleartext listener is not published on every " +
"interface. The Docker image ships 0.0.0.0 instead",
cfg.BindAddress,
},
{"DATA_DIR", "Directory for all SQLite databases", cfg.DataDir},
{"DEBUG", "Enable debug logging", strconv.FormatBool(cfg.Debug)},
{
"METRICS_USERNAME",
"Basic auth username for /metrics. Must be set together " +
"with METRICS_PASSWORD; one without the other fails " +
"startup",
metricsUsername,
},
{
"METRICS_PASSWORD",
"Basic auth password for /metrics. Must be set together " +
"with METRICS_USERNAME; one without the other fails " +
"startup",
setOrNotSet(cfg.MetricsPassword),
},
{
"SENTRY_DSN",
"Sentry error reporting DSN. Unset leaves error reporting " +
"off; a value the Sentry SDK cannot parse fails startup " +
"rather than serving with reporting silently off",
setOrNotSet(cfg.SentryDSN),
},
{
"RETENTION_SWEEP_INTERVAL",
"How often the retention reaper and archive sweeper run " +
"(Go duration, must be positive). A value that does " +
"not parse, or is zero or negative, fails startup",
cfg.RetentionSweepInterval.String(),
},
{
"SESSION_IDLE_TIMEOUT",
"Idle session timeout (Go duration)",
cfg.SessionIdleTimeout.String(),
},
{
"RECEIVER_RATE_LIMIT",
"Receiver requests/minute per IP per entrypoint " +
"(10x that per IP across the route)",
strconv.Itoa(cfg.ReceiverRateLimit),
},
{
"TRUSTED_PROXIES",
"CIDRs whose forwarded headers are trusted. A set value " +
"replaces the default. If any client can reach webhooker, " +
"or the proxy in front of it, from an RFC 1918 source " +
"address, set it to the proxy's address alone",
cidrList(cfg.TrustedProxies),
},
{
"ALLOWED_EGRESS_CIDRS",
"CIDRs that delivery targets may reach despite the " +
"SSRF blocklist",
cidrList(cfg.AllowedEgressCIDRs),
},
}
}
// setOrNotSet is how the Settings page shows a credential: whether it
// has a value, never the value itself.
func setOrNotSet(value string) string {
if value == "" {
return notSet
}
return "set"
}
// cidrList renders a CIDR list setting for the Settings page.
func cidrList(prefixes []netip.Prefix) string {
if len(prefixes) == 0 {
return "none"
}
return strings.Join(config.PrefixStrings(prefixes), ", ")
}
+148
View File
@@ -0,0 +1,148 @@
package handlers_test
import (
"context"
"html"
"net/http"
"net/http/httptest"
"net/netip"
"regexp"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/session"
)
// settingsShown renders the Settings page over cfg as a logged-in user
// and returns the value it shows for each variable name, plus the
// whole page.
func settingsShown(
t *testing.T, cfg *config.Config,
) (map[string]string, string) {
t.Helper()
var h *handlers.Handlers
var sess *session.Session
app := newTestAppWithConfig(t, cfg, &h, &sess)
app.RequireStart()
t.Cleanup(app.RequireStop)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/settings", nil,
)
for _, c := range authenticatedCookies(t, sess, "id", "admin") {
req.AddCookie(c)
}
w := httptest.NewRecorder()
h.HandleSettings().ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
body := w.Body.String()
row := regexp.MustCompile(
`<code[^>]*>([A-Z_]+)</code>\s*<code[^>]*>([^<]*)</code>`,
)
shown := map[string]string{}
for _, match := range row.FindAllStringSubmatch(body, -1) {
shown[match[1]] = html.UnescapeString(match[2])
}
return shown, body
}
func TestSettingsPageShowsLoadedConfiguration(t *testing.T) {
t.Parallel()
// Each of METRICS_USERNAME, METRICS_PASSWORD and SENTRY_DSN is the
// only one of the three set in one of the content tests, so each
// row is checked against its own field.
cfg := &config.Config{
DataDir: t.TempDir(),
Debug: true,
Environment: config.EnvironmentDev,
MetricsUsername: "scraper",
MetricsPassword: "",
Port: 9123,
SentryDSN: "",
BindAddress: "192.0.2.10",
RetentionSweepInterval: 17 * time.Minute,
SessionIdleTimeout: 3 * time.Hour,
ReceiverRateLimit: 77,
TrustedProxies: []netip.Prefix{
netip.MustParsePrefix("10.1.0.0/16"),
},
AllowedEgressCIDRs: []netip.Prefix{
netip.MustParsePrefix("192.168.5.0/24"),
netip.MustParsePrefix("fd00::/8"),
},
}
shown, body := settingsShown(t, cfg)
assert.Equal(t, map[string]string{
"WEBHOOKER_ENVIRONMENT": "dev",
"PORT": "9123",
"BIND_ADDRESS": "192.0.2.10",
"DATA_DIR": cfg.DataDir,
"DEBUG": "true",
"METRICS_USERNAME": "scraper",
"METRICS_PASSWORD": "not set",
"SENTRY_DSN": "not set",
"RETENTION_SWEEP_INTERVAL": "17m0s",
"SESSION_IDLE_TIMEOUT": "3h0m0s",
"RECEIVER_RATE_LIMIT": "77",
"TRUSTED_PROXIES": "10.1.0.0/16",
"ALLOWED_EGRESS_CIDRS": "192.168.5.0/24, fd00::/8",
}, shown)
assert.Contains(
t, body, `href="/settings"`,
"the navigation bar links to the page",
)
}
func TestSettingsPageShowsUnsetValues(t *testing.T) {
t.Parallel()
const metricsPassword = "metrics-password-1f9a"
shown, body := settingsShown(t, &config.Config{
DataDir: t.TempDir(),
MetricsPassword: metricsPassword,
})
assert.Equal(t, "not set", shown["METRICS_USERNAME"])
assert.Equal(t, "set", shown["METRICS_PASSWORD"])
assert.Equal(t, "not set", shown["SENTRY_DSN"])
assert.NotContains(t, body, metricsPassword)
assert.Equal(t, "none", shown["TRUSTED_PROXIES"])
assert.Equal(t, "none", shown["ALLOWED_EGRESS_CIDRS"])
}
func TestSettingsPageShowsSentryDSNOnlyAsSet(t *testing.T) {
t.Parallel()
const (
sentryKey = "dsnkey7c2e"
sentryDSN = "https://" + sentryKey + "@errors.example.com/42"
)
shown, body := settingsShown(t, &config.Config{
DataDir: t.TempDir(),
SentryDSN: sentryDSN,
})
assert.Equal(t, "not set", shown["METRICS_USERNAME"])
assert.Equal(t, "not set", shown["METRICS_PASSWORD"])
assert.Equal(t, "set", shown["SENTRY_DSN"])
assert.NotContains(t, body, sentryKey)
}
+68 -87
View File
@@ -15,6 +15,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/session" "sneak.berlin/go/webhooker/internal/session"
) )
@@ -79,6 +80,10 @@ func seedTarget(
// from a delete statement. // from a delete statement.
var errInjectedDelete = errors.New("injected delete failure") var errInjectedDelete = errors.New("injected delete failure")
// errInjectedSave is the failure failSaveOnTable reports from a
// save of an existing row.
var errInjectedSave = errors.New("injected save failure")
// seedEntrypoint inserts an entrypoint for a webhook. // seedEntrypoint inserts an entrypoint for a webhook.
func seedEntrypoint( func seedEntrypoint(
t *testing.T, t *testing.T,
@@ -146,19 +151,42 @@ func failDeleteOnTable(
) )
} }
// failSaveOnTable is failDeleteOnTable for saves: every update of
// an existing row in the named table fails.
func failSaveOnTable(
t *testing.T,
db *database.Database,
table string,
) {
t.Helper()
require.NoError(t, db.DB().Callback().Update().
Before("gorm:update").
Register(
"test:fail_save_"+table,
func(tx *gorm.DB) {
if tx.Statement.Table == table {
_ = tx.AddError(errInjectedSave)
}
},
),
)
}
// archivePathFor returns the archive database path the // archivePathFor returns the archive database path the
// delivery engine would use for a webhook: beside the webhook's // delivery engine would use for a database target: beside the
// event database in the data directory. // webhook's event database in the data directory.
func archivePathFor( func archivePathFor(
t *testing.T, t *testing.T,
mgr *database.WebhookDBManager, mgr *database.WebhookDBManager,
webhookID string, wh *database.Webhook,
tgt *database.Target,
) string { ) string {
t.Helper() t.Helper()
return filepath.Join( return filepath.Join(
filepath.Dir(mgr.DBPath(webhookID)), filepath.Dir(mgr.DBPath(wh.ID)),
"archive-"+webhookID+".db", delivery.ArchiveFileName(wh.Name, tgt.Name, tgt.ID),
) )
} }
@@ -195,8 +223,8 @@ func postRequest(
// TestHandleSourceDelete_EvictsArchiveWriter proves that // TestHandleSourceDelete_EvictsArchiveWriter proves that
// deleting a webhook reaches the delivery engine and releases // deleting a webhook reaches the delivery engine and releases
// the webhook's archive writer, exercised through the real // the webhook's archive writers, exercised through the real
// deletion handler rather than by calling the evictor directly. // deletion handler rather than by calling the engine directly.
func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) { func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) {
t.Parallel() t.Parallel()
@@ -204,7 +232,7 @@ func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) {
h *handlers.Handlers h *handlers.Handlers
sess *session.Session sess *session.Session
db *database.Database db *database.Database
ev *recordingEvictor ev *recordingArchives
) )
app := newTestApp(t, &h, &sess, &db, &ev) app := newTestApp(t, &h, &sess, &db, &ev)
@@ -220,7 +248,7 @@ func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) {
) )
req := postRequest( req := postRequest(
"/source/"+wh.ID+"/delete", "/hook/"+wh.ID+"/delete",
cookies, cookies,
map[string]string{paramSourceID: wh.ID}, map[string]string{paramSourceID: wh.ID},
) )
@@ -254,9 +282,10 @@ func TestHandleSourceDelete_KeepsArchiveFile(t *testing.T) {
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
tgt := seedTarget(t, db, wh.ID, database.TargetTypeDatabase)
// Place an archive file where the delivery engine would. // Place an archive file where the delivery engine would.
archivePath := archivePathFor(t, mgr, wh.ID) archivePath := archivePathFor(t, mgr, wh, tgt)
require.NoError( require.NoError(
t, t,
writeArchivePlaceholder(archivePath), writeArchivePlaceholder(archivePath),
@@ -267,7 +296,7 @@ func TestHandleSourceDelete_KeepsArchiveFile(t *testing.T) {
) )
req := postRequest( req := postRequest(
"/source/"+wh.ID+"/delete", "/hook/"+wh.ID+"/delete",
cookies, cookies,
map[string]string{paramSourceID: wh.ID}, map[string]string{paramSourceID: wh.ID},
) )
@@ -323,7 +352,7 @@ func TestHandleSourceDelete_FailedDeleteKeepsEverything(
) )
req := postRequest( req := postRequest(
"/source/"+wh.ID+"/delete", "/hook/"+wh.ID+"/delete",
cookies, cookies,
map[string]string{paramSourceID: wh.ID}, map[string]string{paramSourceID: wh.ID},
) )
@@ -337,7 +366,7 @@ func TestHandleSourceDelete_FailedDeleteKeepsEverything(
) )
assert.Empty( assert.Empty(
t, w.Header().Get("Location"), t, w.Header().Get("Location"),
"a failed deletion must not redirect to /sources", "a failed deletion must not redirect to /hooks",
) )
assert.Equal( assert.Equal(
@@ -402,7 +431,7 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
) )
req := postRequest( req := postRequest(
"/source/"+wh.ID+"/delete", "/hook/"+wh.ID+"/delete",
cookies, cookies,
map[string]string{paramSourceID: wh.ID}, map[string]string{paramSourceID: wh.ID},
) )
@@ -411,7 +440,9 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
h.HandleSourceDelete().ServeHTTP(w, req) h.HandleSourceDelete().ServeHTTP(w, req)
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(t, "/sources", w.Header().Get("Location")) assert.Equal(
t, "/hooks?notice=webhook-deleted", w.Header().Get("Location"),
)
assert.Equal( assert.Equal(
t, int64(0), t, int64(0),
@@ -435,68 +466,17 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
) )
} }
// TestHandleTargetDelete_EvictsWhenLastDatabaseTargetGone // TestHandleTargetDelete_EvictsThatTarget proves that deleting a
// proves that removing the last database target releases the // database target releases that target's archive writer and no
// archive writer. // other: the webhook's other database target keeps its own.
func TestHandleTargetDelete_EvictsWhenLastDatabaseTargetGone( func TestHandleTargetDelete_EvictsThatTarget(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
var ( var (
h *handlers.Handlers h *handlers.Handlers
sess *session.Session sess *session.Session
db *database.Database db *database.Database
ev *recordingEvictor ev *recordingArchives
)
app := newTestApp(t, &h, &sess, &db, &ev)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
tgt := seedTarget(
t, db, wh.ID, database.TargetTypeDatabase,
)
cookies := authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
)
req := postRequest(
"/source/"+wh.ID+"/targets/"+tgt.ID+"/delete",
cookies,
map[string]string{
paramSourceID: wh.ID,
paramTargetID: tgt.ID,
},
)
w := httptest.NewRecorder()
h.HandleTargetDelete().ServeHTTP(w, req)
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t, []string{wh.ID}, ev.Evicted(),
"removing the last database target should evict",
)
}
// TestHandleTargetDelete_KeepsWriterWhenDatabaseTargetRemains
// proves that deleting one of several database targets leaves
// the still-needed archive writer alone: the surviving target
// keeps archiving to the same file, so the writer must stay.
func TestHandleTargetDelete_KeepsWriterWhenDatabaseTargetRemains(
t *testing.T,
) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
ev *recordingEvictor
) )
app := newTestApp(t, &h, &sess, &db, &ev) app := newTestApp(t, &h, &sess, &db, &ev)
@@ -515,7 +495,7 @@ func TestHandleTargetDelete_KeepsWriterWhenDatabaseTargetRemains(
) )
req := postRequest( req := postRequest(
"/source/"+wh.ID+"/targets/"+doomed.ID+"/delete", "/hook/"+wh.ID+"/targets/"+doomed.ID+"/delete",
cookies, cookies,
map[string]string{ map[string]string{
paramSourceID: wh.ID, paramSourceID: wh.ID,
@@ -527,17 +507,17 @@ func TestHandleTargetDelete_KeepsWriterWhenDatabaseTargetRemains(
h.HandleTargetDelete().ServeHTTP(w, req) h.HandleTargetDelete().ServeHTTP(w, req)
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
assert.Empty( assert.Equal(
t, ev.Evicted(), t, []string{doomed.ID}, ev.EvictedTargets(),
"a second database target still needs the writer", "deleting a database target should evict its writer",
) )
assert.Empty(t, ev.Evicted(), "the webhook is not deleted")
} }
// TestHandleTargetDelete_KeepsWriterWhenOtherTypeDeleted proves // TestHandleTargetDelete_IgnoresAnotherWebhooksTarget proves that
// that deleting a target of an unrelated type leaves a // a target id from the URL that is not a target of the webhook
// still-needed archive writer alone: the webhook's database // deletes nothing and so evicts nothing.
// target is untouched, so its writer must stay. func TestHandleTargetDelete_IgnoresAnotherWebhooksTarget(
func TestHandleTargetDelete_KeepsWriterWhenOtherTypeDeleted(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
@@ -546,7 +526,7 @@ func TestHandleTargetDelete_KeepsWriterWhenOtherTypeDeleted(
h *handlers.Handlers h *handlers.Handlers
sess *session.Session sess *session.Session
db *database.Database db *database.Database
ev *recordingEvictor ev *recordingArchives
) )
app := newTestApp(t, &h, &sess, &db, &ev) app := newTestApp(t, &h, &sess, &db, &ev)
@@ -555,19 +535,20 @@ func TestHandleTargetDelete_KeepsWriterWhenOtherTypeDeleted(
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
seedTarget(t, db, wh.ID, database.TargetTypeDatabase) elsewhere := seedTarget(
other := seedTarget(t, db, wh.ID, database.TargetTypeLog) t, db, seedWebhook(t, db).ID, database.TargetTypeDatabase,
)
cookies := authenticatedCookies( cookies := authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername, t, sess, deleteTestUserID, deleteTestUsername,
) )
req := postRequest( req := postRequest(
"/source/"+wh.ID+"/targets/"+other.ID+"/delete", "/hook/"+wh.ID+"/targets/"+elsewhere.ID+"/delete",
cookies, cookies,
map[string]string{ map[string]string{
paramSourceID: wh.ID, paramSourceID: wh.ID,
paramTargetID: other.ID, paramTargetID: elsewhere.ID,
}, },
) )
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -576,7 +557,7 @@ func TestHandleTargetDelete_KeepsWriterWhenOtherTypeDeleted(
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
assert.Empty( assert.Empty(
t, ev.Evicted(), t, ev.EvictedTargets(),
"a surviving database target must keep its writer", "another webhook's target must not be evicted",
) )
} }
@@ -81,7 +81,7 @@ func (f *baseURLFixture) entrypointURL(
req := httptest.NewRequestWithContext( req := httptest.NewRequestWithContext(
context.Background(), context.Background(),
http.MethodGet, http.MethodGet,
"/source/"+f.webhook, "/hook/"+f.webhook,
nil, nil,
) )
req.Host = host req.Host = host
@@ -213,7 +213,7 @@ func TestSourceDetailBaseURL_ForwardedProtoSpellings(t *testing.T) {
assert.Equal( assert.Equal(
t, t,
tc.scheme+"://"+host+"/webhook/"+fixture.path, tc.scheme+"://"+host+"/h/"+fixture.path,
fixture.entrypointURL( fixture.entrypointURL(
t, host, forwardedProto(tc.header), t, host, forwardedProto(tc.header),
), ),
@@ -244,7 +244,7 @@ func TestSourceDetailBaseURL_DirectTLSBeatsPlaintextHeader(
assert.Equal( assert.Equal(
t, t,
"https://"+host+"/webhook/"+fixture.path, "https://"+host+"/h/"+fixture.path,
got, got,
"a connection this process terminated with TLS "+ "a connection this process terminated with TLS "+
"outranks a header claiming plaintext", "outranks a header claiming plaintext",
@@ -272,7 +272,7 @@ func TestSourceDetailBaseURL_KeepsHostAuthority(t *testing.T) {
assert.Equal( assert.Equal(
t, t,
"https://"+host+"/webhook/"+fixture.path, "https://"+host+"/h/"+fixture.path,
fixture.entrypointURL( fixture.entrypointURL(
t, host, forwardedProto("HTTPS"), t, host, forwardedProto("HTTPS"),
), ),
+53 -4
View File
@@ -62,10 +62,27 @@ func renderSourceDetailPage(
) string { ) string {
t.Helper() t.Helper()
w := serveSourceDetailPage(t, h, sess, webhookID)
require.Equal(t, http.StatusOK, w.Code)
return w.Body.String()
}
// serveSourceDetailPage runs the real source detail handler for a
// webhook and returns its response, whatever its status.
func serveSourceDetailPage(
t *testing.T,
h *handlers.Handlers,
sess *session.Session,
webhookID string,
) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequestWithContext( req := httptest.NewRequestWithContext(
context.Background(), context.Background(),
http.MethodGet, http.MethodGet,
"/source/"+webhookID, "/hook/"+webhookID,
nil, nil,
) )
@@ -87,9 +104,7 @@ func renderSourceDetailPage(
w := httptest.NewRecorder() w := httptest.NewRecorder()
h.HandleSourceDetail().ServeHTTP(w, req) h.HandleSourceDetail().ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code) return w
return w.Body.String()
} }
// TestHandleSourceDetail_MasksSlackWebhookURL is the // TestHandleSourceDetail_MasksSlackWebhookURL is the
@@ -226,3 +241,37 @@ func TestHandleSourceDetail_RendersNamedTargetFields(
assert.Contains(t, body, "(unavailable)") assert.Contains(t, body, "(unavailable)")
assert.NotContains(t, body, "beak") assert.NotContains(t, body, "beak")
} }
// TestHandleSourceDetail_FitsWideAndNarrowWindows pins the webhook
// page's maximum width at 108rem (1728 px), half again the 72rem of
// max-w-6xl that the webhook list and the event log use, so an
// entrypoint URL fits on one line in a 1920-pixel window; and the
// wrapping of its title row, so the buttons beside the title do not
// push a phone-width window into scrolling sideways.
func TestHandleSourceDetail_FitsWideAndNarrowWindows(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
)
app := newTestApp(t, &h, &sess, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.Contains(
t, body,
`<div class="mx-auto px-6 py-8" style="max-width: 108rem"`,
)
assert.Contains(
t, body,
`<div class="flex flex-wrap justify-between items-center gap-2 mt-2">`,
)
}
+467
View File
@@ -0,0 +1,467 @@
package handlers_test
import (
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/session"
)
// failedHighlight is how the list marks a number of failed deliveries
// that is not zero.
const failedHighlight = `class="font-medium text-red-600"`
// listWebhook adds a webhook with the given name, owned by the test
// user.
func listWebhook(
t *testing.T, db *database.Database, name string,
) *database.Webhook {
t.Helper()
wh := &database.Webhook{UserID: deleteTestUserID, Name: name}
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
return wh
}
// addEntrypoints adds the given number of entrypoints, all active or
// all inactive, to a webhook and returns their paths.
func addEntrypoints(
t *testing.T, db *database.Database, webhookID string,
count int, active bool,
) []string {
t.Helper()
paths := make([]string, count)
for i := range paths {
paths[i] = statsEntrypoint(t, db, webhookID, active)
}
return paths
}
// addTargets adds the given number of targets, all active or all
// inactive, to a webhook and returns them.
func addTargets(
t *testing.T, db *database.Database, webhookID string,
count int, active bool,
) []*database.Target {
t.Helper()
targets := make([]*database.Target, count)
for i := range targets {
targets[i] = seedTarget(t, db, webhookID, database.TargetTypeLog)
require.NoError(t, db.DB().Model(targets[i]).
Update("active", active).Error)
}
return targets
}
// renderWebhookList runs the real webhook list handler as the test user
// and returns the rendered page.
func renderWebhookList(
t *testing.T, h *handlers.Handlers, sess *session.Session,
) string {
t.Helper()
cookies := authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
)
w := httptest.NewRecorder()
h.HandleSourceList().ServeHTTP(
w, getRequest(t, "/hooks", cookies, nil),
)
require.Equal(t, http.StatusOK, w.Code)
return w.Body.String()
}
// listCard returns one webhook's entry in a rendered webhook list, its
// markup as rendered and its text with the markup taken out and each
// run of space made one space.
func listCard(t *testing.T, page, webhookID string) (string, string) {
t.Helper()
_, card, found := strings.Cut(page, `href="/hook/`+webhookID+`"`)
require.True(t, found, "the list has no entry for %s", webhookID)
card, _, _ = strings.Cut(card, "</a>")
text := regexp.MustCompile(`<[^>]*>`).ReplaceAllString(card, " ")
return card, strings.Join(strings.Fields(text), " ")
}
// receiveEvents posts the given number of events to an entrypoint
// through the real receiver, and returns the webhook's event database
// and its events, oldest first.
func receiveEvents(
t *testing.T,
h *handlers.Handlers,
dbMgr *database.WebhookDBManager,
webhookID, path string,
count int,
) (*gorm.DB, []database.Event) {
t.Helper()
router := receiverRouter(h)
for range count {
require.Equal(t, http.StatusOK, postReceiver(t, router, path))
}
webhookDB, err := dbMgr.GetDB(webhookID)
require.NoError(t, err)
events := listEvents(t, webhookDB)
require.Len(t, events, count)
return webhookDB, events
}
// seedFailingWebhook adds a webhook with six entrypoints, two of them
// inactive, and seven targets, five of them inactive. Four events reach
// its two active targets, arriving 31, 5, 4 and 3 hours ago, and its
// event totals row records the last one. Three deliveries failed in the
// last 24 hours, two to the first target and one to the second, one
// failed 30 hours ago, two were delivered, and two are still pending.
// It returns the webhook and when its last event arrived.
func seedFailingWebhook(
t *testing.T,
h *handlers.Handlers,
db *database.Database,
dbMgr *database.WebhookDBManager,
) (*database.Webhook, time.Time) {
t.Helper()
wh := listWebhook(t, db, "failing")
paths := addEntrypoints(t, db, wh.ID, 4, true)
addEntrypoints(t, db, wh.ID, 2, false)
active := addTargets(t, db, wh.ID, 2, true)
first, second := active[0], active[1]
addTargets(t, db, wh.ID, 5, false)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, paths[0], 4)
now := time.Now()
lastEventAt := now.Add(-3 * time.Hour)
statsAge(t, webhookDB, events[0].ID, now.Add(-31*time.Hour))
statsAge(t, webhookDB, events[1].ID, now.Add(-5*time.Hour))
statsAge(t, webhookDB, events[2].ID, now.Add(-4*time.Hour))
statsAge(t, webhookDB, events[3].ID, lastEventAt)
require.NoError(t, database.AddEventTotals(webhookDB,
database.EventTotals{LastEventAt: &lastEventAt}))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, events[0].ID, first.ID),
database.DeliveryStatusFailed, now.Add(-30*time.Hour))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, events[0].ID, second.ID),
database.DeliveryStatusDelivered, now.Add(-30*time.Hour))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, events[1].ID, first.ID),
database.DeliveryStatusFailed, now.Add(-time.Hour))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, events[2].ID, first.ID),
database.DeliveryStatusFailed, now.Add(-time.Minute))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, events[2].ID, second.ID),
database.DeliveryStatusFailed, now.Add(-time.Minute))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, events[3].ID, second.ID),
database.DeliveryStatusDelivered, now.Add(-time.Minute))
return wh, lastEventAt
}
// seedHealthyWebhook adds a webhook with four entrypoints and two
// targets, all active, and three events, arriving 8, 7 and 6 hours ago
// and each delivered to both targets. Its event totals row records the
// last event. It returns the webhook and when its last event arrived.
func seedHealthyWebhook(
t *testing.T,
h *handlers.Handlers,
db *database.Database,
dbMgr *database.WebhookDBManager,
) (*database.Webhook, time.Time) {
t.Helper()
wh := listWebhook(t, db, "healthy")
paths := addEntrypoints(t, db, wh.ID, 4, true)
targets := addTargets(t, db, wh.ID, 2, true)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, paths[0], 3)
now := time.Now()
lastEventAt := now.Add(-6 * time.Hour)
statsAge(t, webhookDB, events[0].ID, now.Add(-8*time.Hour))
statsAge(t, webhookDB, events[1].ID, now.Add(-7*time.Hour))
statsAge(t, webhookDB, events[2].ID, lastEventAt)
require.NoError(t, database.AddEventTotals(webhookDB,
database.EventTotals{LastEventAt: &lastEventAt}))
for _, ev := range events {
for _, target := range targets {
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, ev.ID, target.ID),
database.DeliveryStatusDelivered, now)
}
}
return wh, lastEventAt
}
// lastEventText is how the list shows when the last event arrived.
func lastEventText(at time.Time) string {
return at.UTC().Format("2006-01-02 15:04:05 UTC")
}
// TestSourceList_ShowsActivityOfEachWebhook checks the figures the list
// shows for a webhook with recent failures, a healthy one, a new one
// that has received no event, and one without an event database.
func TestSourceList_ShowsActivityOfEachWebhook(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)
failing, failingLastEvent := seedFailingWebhook(t, h, db, dbMgr)
healthy, healthyLastEvent := seedHealthyWebhook(t, h, db, dbMgr)
// Creating a webhook creates its event database.
fresh := listWebhook(t, db, "fresh")
require.NoError(t, dbMgr.CreateDB(fresh.ID))
addEntrypoints(t, db, fresh.ID, 2, true)
addTargets(t, db, fresh.ID, 3, true)
quiet := listWebhook(t, db, "quiet")
addEntrypoints(t, db, quiet.ID, 2, true)
addTargets(t, db, quiet.ID, 3, true)
page := renderWebhookList(t, h, sess)
card, text := listCard(t, page, failing.ID)
assert.Contains(t, text, "6 entrypoints, 2 inactive")
assert.Contains(t, text, "7 targets, 5 inactive")
assert.Contains(t, text, "4 events within retention")
assert.Contains(t, text, "Last event "+lastEventText(failingLastEvent))
assert.Contains(t, card,
failedHighlight+">3 failed deliveries in the last 24 hours<")
card, text = listCard(t, page, healthy.ID)
assert.Contains(t, text, "4 entrypoints")
assert.Contains(t, text, "2 targets")
assert.Contains(t, text, "3 events within retention")
assert.Contains(t, text, "Last event "+lastEventText(healthyLastEvent))
assert.Contains(t, text, "0 failed deliveries in the last 24 hours")
assert.NotContains(t, text, "inactive")
assert.NotContains(t, card, failedHighlight)
card, text = listCard(t, page, fresh.ID)
assert.Contains(t, text, "2 entrypoints")
assert.Contains(t, text, "3 targets")
assert.Contains(t, text, "0 events within retention")
assert.Contains(t, text, "No events yet")
assert.Contains(t, text, "0 failed deliveries in the last 24 hours")
assert.NotContains(t, card, failedHighlight)
card, text = listCard(t, page, quiet.ID)
assert.Contains(t, text, "2 entrypoints")
assert.Contains(t, text, "3 targets")
assert.Contains(t, text, "0 events within retention")
assert.Contains(t, text, "No events yet")
assert.Contains(t, text, "0 failed deliveries in the last 24 hours")
assert.NotContains(t, card, failedHighlight)
assert.False(t, dbMgr.DBExists(quiet.ID),
"showing the list must not create an event database")
}
// TestSourceList_CountsOnlyEventsWithinRetention checks that once
// retention has removed one of a webhook's three events, the list
// counts the two still stored.
func TestSourceList_CountsOnlyEventsWithinRetention(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
log *logger.Logger
)
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := &database.Webhook{
UserID: deleteTestUserID, Name: "pruned", RetentionDays: 14,
}
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
paths := addEntrypoints(t, db, wh.ID, 3, true)
addTargets(t, db, wh.ID, 4, true)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, paths[0], 3)
statsAge(t, webhookDB, events[0].ID, time.Now().Add(-15*24*time.Hour))
statsPrune(t, db, dbMgr, log, webhookDB)
require.Len(t, listEvents(t, webhookDB), 2)
_, text := listCard(t, renderWebhookList(t, h, sess), wh.ID)
assert.Contains(t, text, "3 entrypoints")
assert.Contains(t, text, "4 targets")
assert.Contains(t, text, "2 events within retention")
}
// TestSourceList_LastEventSurvivesPruningEveryEvent checks that once
// retention has removed every event of a webhook, the list still shows
// when the last one arrived rather than "No events yet".
func TestSourceList_LastEventSurvivesPruningEveryEvent(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
log *logger.Logger
)
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := &database.Webhook{
UserID: deleteTestUserID, Name: "emptied", RetentionDays: 1,
}
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
paths := addEntrypoints(t, db, wh.ID, 2, true)
addTargets(t, db, wh.ID, 3, true)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, paths[0], 1)
lastEventAt := time.Now().Add(-50 * time.Hour)
statsAge(t, webhookDB, events[0].ID, lastEventAt)
require.NoError(t, database.AddEventTotals(webhookDB,
database.EventTotals{LastEventAt: &lastEventAt}))
statsPrune(t, db, dbMgr, log, webhookDB)
require.Empty(t, listEvents(t, webhookDB))
_, text := listCard(t, renderWebhookList(t, h, sess), wh.ID)
assert.Contains(t, text, "0 events within retention")
assert.Contains(t, text, "Last event "+lastEventText(lastEventAt))
assert.NotContains(t, text, "No events yet")
}
// TestSourceList_CountsOfOneInSingular checks that a webhook with one
// entrypoint, one target, one event within retention and one failed
// delivery in the last 24 hours has each written in the singular.
func TestSourceList_CountsOfOneInSingular(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 := listWebhook(t, db, "single")
paths := addEntrypoints(t, db, wh.ID, 1, true)
targets := addTargets(t, db, wh.ID, 1, true)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, paths[0], 1)
now := time.Now()
lastEventAt := now.Add(-9 * time.Hour)
statsAge(t, webhookDB, events[0].ID, lastEventAt)
require.NoError(t, database.AddEventTotals(webhookDB,
database.EventTotals{LastEventAt: &lastEventAt}))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, events[0].ID, targets[0].ID),
database.DeliveryStatusFailed, now.Add(-time.Hour))
card, text := listCard(t, renderWebhookList(t, h, sess), wh.ID)
assert.Contains(t, card, ">1 entrypoint<")
assert.Contains(t, card, ">1 target<")
assert.Contains(t, card, ">1 event within retention<")
assert.Contains(t, text, "Last event "+lastEventText(lastEventAt))
assert.Contains(t, card,
failedHighlight+">1 failed delivery in the last 24 hours<")
}
// TestSourceList_UnreadableEventDatabase checks that a webhook whose
// event database cannot be read says so in its entry instead of
// showing zeros, and that the rest of the list is still shown.
func TestSourceList_UnreadableEventDatabase(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)
broken := listWebhook(t, db, "broken")
addEntrypoints(t, db, broken.ID, 2, true)
addTargets(t, db, broken.ID, 3, true)
brokenDB, err := dbMgr.GetDB(broken.ID)
require.NoError(t, err)
require.NoError(t,
brokenDB.Migrator().DropTable(&database.EventTotals{}))
quiet := listWebhook(t, db, "quiet")
addEntrypoints(t, db, quiet.ID, 2, true)
addTargets(t, db, quiet.ID, 3, true)
page := renderWebhookList(t, h, sess)
_, text := listCard(t, page, broken.ID)
assert.Contains(t, text, "2 entrypoints")
assert.Contains(t, text, "3 targets")
assert.Contains(t, text, "The event figures could not be read.")
assert.NotContains(t, text, "events")
assert.NotContains(t, text, "failed")
_, text = listCard(t, page, quiet.ID)
assert.Contains(t, text, "No events yet")
}
@@ -28,7 +28,7 @@ func deleteTargetThroughHandler(
t.Helper() t.Helper()
req := postRequest( req := postRequest(
"/source/"+webhookID+"/targets/"+targetID+"/delete", "/hook/"+webhookID+"/targets/"+targetID+"/delete",
authenticatedCookies( authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername, t, sess, deleteTestUserID, deleteTestUsername,
), ),
+1 -1
View File
@@ -84,7 +84,7 @@ func renderSourceLogsPageWithQuery(
req := httptest.NewRequestWithContext( req := httptest.NewRequestWithContext(
context.Background(), context.Background(),
http.MethodGet, http.MethodGet,
"/source/"+webhookID+"/logs"+query, "/hook/"+webhookID+"/events"+query,
nil, nil,
) )
File diff suppressed because it is too large Load Diff

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