Author SHA1 Message Date
clawbot 075044f67d Empty the add target form on Cancel; encoding failures stay a 500
check / check (push) Waiting to run
The form's reason and values now come from the targetForm component,
loaded from the section's data attributes after a refusal and emptied
by Cancel, which also resets the form. Each type's fields used to be
recreated with the refused values written into the markup, so they
came back after Cancel. The browser test checks this after a refusal.

A target configuration that cannot be encoded is again a logged 500
with the generic error page, on the add and the edit path; only
refusals of submitted values come back on the form.

The README paragraph on the browser test is re-wrapped at 80 columns
and names Cancel at the type step.

Model: opus-5-5
2026-10-02 18:36:08 +00:00
clawbot f9aba94ef9 Targets section: one Add, then a type and Next, then its fields (closes #370)
The webhook page's targets section lists only its targets until Add is
clicked. Add shows a choice of target type with Next; Next shows only
that type's fields, with Save and Cancel. The `database` and `log`
types have no URL field, and the `slack` form gains max retries.

A refused target now shows the webhook page again with the form open on
its type, the values entered and the reason, instead of a bare text
page. Target validation returns that message rather than writing the
response; newTarget validates a whole new target for reuse by the
new-webhook page. The edit page still answers a refusal in plain text.

Model: opus-5-5
2026-10-02 18:36:08 +00:00
clawbot 9526e961b5 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 has a Download button that streams its archive as gzipped JSON, archive-WEBHOOKNAME-TARGETNAME-TIME.json.gz, with names made safe by delivery.ArchiveFileName's function. The export reads one consistent snapshot through one cursor in a read-only transaction, so archive writes carry on, and holds the rename lock only while it reads the stored names and opens the file. It extends its write deadline as it writes, so a large archive downloads for as long as the client reads; a failure after the response has started aborts the connection so the browser marks the download failed. The request limit is now the service's own middleware, which no longer writes a 504 over a response already started.

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

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

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

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

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

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

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

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

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

Model: opus-5-5
2026-10-02 18:16:31 +02:00
clawbot bf3df0312b Clear the environment in the cmd/webhooker tests that build a Config (closes #452)
check / check (push) 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
109 changed files with 5579 additions and 1037 deletions
+1 -1
View File
@@ -33,5 +33,5 @@ jobs:
# report success from cache.
run: git rev-parse HEAD > .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
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -26,7 +26,7 @@ COPY . .
# Dockerfile.lint, including --network=none (see its header for why).
RUN make fmt-check
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
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
+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
# 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 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 ./...
+4 -1
View File
@@ -1,4 +1,4 @@
.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_GOAL := check
@@ -33,6 +33,9 @@ assets:
test:
@script/test
test-browser:
@script/test-browser
lint:
@script/lint
+161 -80
View File
@@ -19,8 +19,8 @@ before deploying one.
### Prerequisites
- Go 1.26.1+ (the version in `go.mod`)
- Docker (for linting, for the test stage of the CI gate, and for
containerized deployment)
- Docker (for `make lint` and so for `make check`, for the browser test in
`make test-browser`, for the CI gate, and for containerized deployment)
golangci-lint is not a prerequisite and must not be installed on the
host: `script/bootstrap` does not install it, and `make lint` runs the
@@ -58,6 +58,7 @@ make fmt # Format code (gofmt + goimports)
make fmt-check # Fail if gofmt would change anything (writes nothing)
make lint # Run golangci-lint in Docker (Dockerfile.lint)
make test # Run tests with race detection
make test-browser # Run the browser test in Docker (Dockerfile.browser)
make check # test + lint + fmt-check (CI gate)
make build # Build binary to bin/webhooker (version-stamped)
make version # Print the version this checkout would stamp
@@ -138,7 +139,7 @@ TTY detection, and security headers are always applied.
| `METRICS_USERNAME` | Basic auth username for `/metrics`. Must be set together with `METRICS_PASSWORD`; one without the other fails startup | `""` |
| `METRICS_PASSWORD` | Basic auth password for `/metrics`. Must be set together with `METRICS_USERNAME`; one without the other fails startup | `""` |
| `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 | `""` |
| `RETENTION_SWEEP_INTERVAL` | How often the retention reaper and archive sweeper run (Go duration, must be positive) | `1h` |
| `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 | `1h` |
| `SESSION_IDLE_TIMEOUT` | Idle session timeout (Go duration) | `24h` |
| `RECEIVER_RATE_LIMIT` | Receiver requests/minute per IP per entrypoint (10x that per IP across the route) | `120` |
| `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. See [Trusted proxies](#trusted-proxies) | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` (RFC 1918) |
@@ -456,6 +457,19 @@ Your proxy must therefore **append** the peer address to
`option forwardfor`, Caddy and AWS ALB by default), and must append a
bare address with no port.
Every log line that names a client carries two addresses: `remoteIP`,
the connecting peer, which behind a proxy is the proxy; and `clientIP`,
the client the rate limiters identify by the rules above, which is the
field to read when tracing who sent what. Those lines are the
`http request` access log line, the rate-limit rejection lines
(`login failure limit exceeded` among them), the
`csrf: token validation failed` warning and the receiver's
`webhook request received` line. `clientIP` is only as trustworthy as
`TRUSTED_PROXIES`: for a request from a peer inside the list, it is
read out of the `X-Forwarded-For` that peer sent, so a peer that does
not belong in the list can make it name any address it likes. For a
request from any other peer, both fields name the peer.
#### Sessions
Sessions are bounded by two independent clocks, and end at whichever
@@ -543,8 +557,8 @@ If it is lost, run `webhooker resetpw admin` on a stopped deployment.
```
It is a banner rather than a log line because that is the only time it
is ever shown: as one `INFO` record it sat among the roughly 45 fx
`PROVIDE`/`RUN`/`HOOK` lines a boot writes, and under `docker run -d`
is ever shown: as one `INFO` record it would sit among the records fx
writes as each start hook runs, and under `docker run -d`
it is one line in a log subject to rotation. The database stores only
its Argon2id hash. There is no second account and no forgot-password
flow, so the banner and the reset command below are the only two ways
@@ -614,7 +628,8 @@ Changing a password you still know needs none of this — use
`DEBUG=true` lowers the log level to `DEBUG`, which turns on every
statement GORM runs, the two by-design lookup misses on the
unauthenticated routes, and the rate limiter's own rejections. It is
unauthenticated routes, the rate limiter's own rejections, and fx's
records of building the dependency graph at startup. It is
meant to be safe to turn on while diagnosing a live service and safe to
paste the output of into a bug report.
@@ -840,9 +855,9 @@ reports.
was given, so on any port other than 443 `$host` makes every form
POST — including login — fail with `403 origin invalid`, with
nothing in the error naming the cause.
5. **Keep the proxy's access log.** webhooker's own access log records
the peer address, which behind a proxy is always the proxy. The
proxy's log is the only record of which client sent what. nginx's
5. **Keep the proxy's access log.** webhooker's own access log names
the client in its `clientIP` field only while `TRUSTED_PROXIES`
covers the proxy; the proxy's log names it regardless. nginx's
default `combined` format already logs `$remote_addr`; do not
replace it with one that drops the client address, and retain those
logs as long as you would want to answer a question about traffic.
@@ -871,9 +886,8 @@ server {
# webhooker's message.
client_max_body_size 1m;
# $remote_addr is the client. webhooker's own log records this
# proxy and nothing else, so this file is the only place the
# client's address is written down.
# $remote_addr is the client. webhooker's own log names it, as
# clientIP, only while TRUSTED_PROXIES covers this proxy.
access_log /var/log/nginx/webhooker.access.log combined;
location / {
@@ -1257,7 +1271,7 @@ What that means for an operator:
This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the
development workflow. Ten of the Makefile's seventeen targets are thin
development workflow. Eleven of the Makefile's eighteen targets are thin
shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and
`version` are inline commands with no script behind them, though `build`,
`run` and `dev` first run `script/assets`, and `build` and `version` both
@@ -1278,6 +1292,8 @@ We provide:
- `script/assets` — extract Alpine.js from its tarball in `3p/` (see
[Third-party browser assets](#third-party-browser-assets))
- `script/test` — run the test suite
- `script/test-browser` — run the browser test in Docker (see
[Third-party browser assets](#third-party-browser-assets))
- `script/lint` — run golangci-lint in Docker (see Linting below)
- `script/fmt` — format all code (writes)
- `script/fmt-check` — check formatting (read-only)
@@ -1298,11 +1314,36 @@ We provide:
## Third-party browser assets
The web UI serves one third-party script, Alpine.js. Its npm package tarball
is committed as `3p/alpinejs-3.14.9.tgz`, byte for byte as the npm registry
publishes it. It is a dependency, not this repo's build output, so
`REPO_POLICIES.md`'s rule against committed build artifacts does not apply.
The directory is `3p/` rather than `vendor/` because Go treats a root
The web UI serves one third-party script, Alpine.js, in its CSP build: the npm
package `@alpinejs/csp`. The pages' Content-Security-Policy forbids eval, which
the standard `alpinejs` build needs to run the expressions written in the
markup. The CSP build runs no expressions, so every Alpine directive in
`templates/` only names a property or method of a component registered in
`static/js/app.js`: `x-data="collapsible"` and `@click="toggle"`, never
`x-data="{ open: false }"` or `@click="open = !open"`.
A browser test in `internal/server` loads the webhook page and the event log
under the real policy and checks that: the add entrypoint form stays hidden
until Add is clicked; for every target type, the targets section's Add shows
only a choice of type with Next and Cancel, Next shows only that type's fields
(no url field for `database` or `log`), Cancel at either step closes the form,
and saving adds the target; a refused target comes back with its form open, the
values entered and the reason, and after Cancel the next Add starts with an
empty form and no reason; the Copy button beside an entrypoint URL reads
"Copied" once clicked; an event expands and collapses, and so do a delivery's
attempts inside it; and at phone width the menu button opens and closes the
mobile menu. It also fails if the browser reports a console warning or error, an
uncaught exception, or anything the policy refused. `make check` and the image
build lint it but do not run it, and `make test` leaves it out (its file is
built only with the `browser` build tag). Run it with `make test-browser` after
changing `templates/` or `static/js/`: that builds `Dockerfile.browser`, which
runs the test in a digest-pinned headless browser image, so the host needs no
browser.
The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for
byte as the npm registry publishes it. It is a dependency, not this repo's build
output, so `REPO_POLICIES.md`'s rule against committed build artifacts does not
apply. The directory is `3p/` rather than `vendor/` because Go treats a root
`vendor/` directory as its module vendor directory.
`script/assets` (`make assets`) extracts the browser build,
@@ -1313,10 +1354,11 @@ nothing downloads Alpine.js. The extracted file is not committed, and
`.dockerignore` keeps any host copy out of the build context.
To move to a new version: download
`https://registry.npmjs.org/alpinejs/-/alpinejs-<version>.tgz`, check it
against the `dist.integrity` hash listed at
`https://registry.npmjs.org/alpinejs/<version>`, replace the tarball in `3p/`
with it, update its file name in `script/assets`, and run `make check`.
`https://registry.npmjs.org/@alpinejs/csp/-/csp-<version>.tgz`, check it against
the `dist.integrity` hash listed at
`https://registry.npmjs.org/@alpinejs/csp/<version>`, replace the tarball in
`3p/` with it as `alpinejs-csp-<version>.tgz`, update its file name in
`script/assets`, and run `make check` and `make test-browser`.
## Rationale
@@ -1992,6 +2034,29 @@ Because each `database` target has its own archive file, a target's
webhook with different expiries keep two archives, each pruned on its
own schedule.
Each `database` target on the webhook page has a **Download** button,
which returns its archive as one gzipped JSON file,
`archive-{webhook_name}-{target_name}-{YYYYMMDDTHHMMSSZ}.json.gz`, the
names made safe as above and the time in UTC. The file holds one
object: `webhook` and `target`, each an `id` and a `name`;
`exported_at`; and `archived_events`, one object per archived row with
every column, keyed by column name. A body that is not valid UTF-8 is
written in base64, with `"body_encoding": "base64"` beside it. An
archive that does not exist yet, or was moved away, downloads with an
empty `archived_events`; the download never creates the file.
The download streams: each row is read and written out compressed
before the next is read, so neither the archive nor the JSON is held in
memory. It reads on a connection of its own, inside one read-only
transaction, so the file holds the archive as it stood when the
download started, and archive writes go on meanwhile, since under WAL a
reader never blocks a writer. While it runs, the `-wal` cannot be
checkpointed past what it reads, so a long download lets the `-wal`
grow. It finds the file by the stored names under the lock that webhook
edits, target edits and target creation hold, and lets go once the file
is open: a rename during the download moves the file without affecting
it.
Deleting a webhook releases its archives: the delivery engine's cached
archive writers are dropped and their file handles closed, so nothing
lingers after the webhook is gone. The archive **files themselves are
@@ -2448,20 +2513,21 @@ trade.
Net: **one `INFO` line per request, of at most 2,560 bytes.** That
ceiling is arithmetic, not an observation: 3 × (512 + 11) for `url`,
`useragent` and `referer`, plus 128 + 11 for `request_id`, plus 32 + 11
for `method`, plus a 336-byte fixed portion (the field names, the
punctuation, both timestamps at their longest, an IPv6 `remoteIP` with
a zone, the status and the latency) — 2,087 bytes, stated at 2,560 so
the figure has headroom. `internal/middleware/accesslog_test.go`
asserts it against 8 KB of client-chosen text in the path, in the
query, and in each of `User-Agent`, `Referer` and `X-Request-Id`,
for `method`, plus a 405-byte fixed portion (the field names, the
punctuation, both timestamps at their longest, `remoteIP` and
`clientIP` each charged as an IPv6 address with a zone, the status and
the latency) — 2,156 bytes, stated at 2,560 so the figure has headroom.
`internal/middleware/accesslog_test.go` asserts it against 8 KB of
client-chosen text in the path, in the query, and in each of
`User-Agent`, `Referer`, `X-Request-Id` and `X-Forwarded-For`,
including cases built from the characters the handlers escape, and
against the widest access log line the service can be made to write: a
5xx that keeps its concrete path while all three header fields are also
at their budget. Every case runs through both handlers
`internal/logger` can select — the JSON one and the text one it installs
on a tty — since the two do not escape alike and the ceiling is quoted
unqualified. Measured over a real connection, the widest access log line
is 1,972 bytes.
against a 5xx that keeps its concrete path while all three header fields
are also at their budget and an `X-Forwarded-For` sent from a trusted
proxy ends in an IPv6 client address at its longest followed by an 8 KB
zone, where `clientIP` must name the address without the zone. Every
case runs through both handlers `internal/logger` can select — the JSON
one and the text one it installs on a tty — since the two do not escape
alike and the ceiling is quoted unqualified.
Multiply that ceiling by the request rate to size log storage. Note
that the rate is not bounded by the limits above on every route:
@@ -2569,11 +2635,10 @@ on all three arms of `Trace`, including the routine one an operator
reaches at `DEBUG`, which is the only level at which a successful
`INSERT` is written at all. One GORM path does not consult the filter —
`(*gorm.DB).Scan`, which records the statement through GORM's own trace
recorder. No production code path calls it; its one caller is
`internal/database/database_test.go:91`, whose `SELECT 1` binds
nothing, and `internal/gormlog/scan_guard_test.go` fails if a non-test
file calls it. `Pluck`, `Row` and `Raw` all run through the normal
callback processor and are filtered.
recorder. No production code path calls it; only tests do, and what a
test binds is fixture data. `internal/gormlog/scan_guard_test.go` fails
if a non-test file calls it. `Pluck`, `Row` and `Raw` all run through
the normal callback processor and are filtered.
See `#### What DEBUG=true exposes` under Configuration.
What that ceiling does **not** cover, stated here so the figure is not
@@ -2599,16 +2664,20 @@ read as more than it is:
that type on a specific webhook, and each line it writes is bounded
per event by the 1 MB receiver body cap. Adding one is a decision to
spend log volume on that webhook's payloads.
- **Two writers that do not go through `internal/logger` at all**, both
on standard error. `fx` prints the dependency graph and the lifecycle
hooks through its default console logger at startup and shutdown —
nothing calls `fx.WithLogger`, and `fx.New` builds that logger over
`os.Stderr`. The Go runtime writes a panic or a fatal error itself; a
panic in a background worker rather than in a request handler is the
case that reaches it, since nothing recovers those. Neither carries a
client-chosen value at a client-chosen length: the five `panic` calls
in this service are invariant guards over constants and over
`crypto/rand`.
- **The Go runtime**, which does not go through `internal/logger`. The
runtime writes an unrecovered panic or a fatal error itself, as plain
text on standard error, and that output cannot be redirected. A panic
in a background worker rather than in a request handler is the case
that reaches it, since nothing recovers those. It carries no
client-chosen value at a client-chosen length: the service's own
`panic` calls are invariant guards over constants and over
`crypto/rand`, apart from the one that hands `http.ErrAbortHandler`
back to `net/http`, described below.
- **A failure before fx's logger is built**, such as an invalid
configuration value. fx's logger takes the configuration, so when
that fails fx's own console logger still prints the failure as plain
text on standard error. Its values come from the operator's
environment, not from a client.
- **`net/http`'s own faults**, which are _not_ a separate writer.
`internal/server/http.go` builds its server with a nil `ErrorLog`, so
`net/http` falls back to the `log` package's default logger — and
@@ -2799,9 +2868,9 @@ remedies are to block the source at the reverse proxy, or to
rate-limit `POST /pages/login` there — the one place a limit can be
applied without reintroducing the lockout, because the proxy sees the
real client address. `TRUSTED_PROXIES` does not stop the saturation.
The flood's source is in the proxy's access log: webhooker's own logs
record the proxy's address, not the client's (see
[Deployment behind a reverse proxy](#deployment-behind-a-reverse-proxy)).
The flood's source is in the `clientIP` field of webhooker's access
log while `TRUSTED_PROXIES` covers the proxy, and in the proxy's own
access log either way (see [Trusted proxies](#trusted-proxies)).
Finer-grained per-webhook rate limits (configured in the web UI and
enforced in the webhook handler) can layer on top of this env-level
@@ -2854,6 +2923,7 @@ returns to the page that was asked for.
| `POST` | `/hook/{id}/targets` | Add target to webhook |
| `GET` | `/hook/{id}/targets/{targetID}/edit` | Edit target form. The one page that renders a target's destination URL and header values in full, rather than masked |
| `POST` | `/hook/{id}/targets/{targetID}/edit` | Edit target submission |
| `GET` | `/hook/{id}/targets/{targetID}/download` | Download a `database` target's archive as one gzipped JSON file. See [Database Architecture](#database-architecture) |
| `POST` | `/hook/{id}/targets/{targetID}/delete` | Delete a target |
| `POST` | `/hook/{id}/targets/{targetID}/toggle` | Enable or disable a target |
@@ -2888,7 +2958,7 @@ imports. The entry point is `cmd/webhooker/main.go`.
```
webhooker/
├── 3p/
│ └── alpinejs-3.14.9.tgz # Alpine.js npm package, extracted by make assets
│ └── alpinejs-csp-3.14.9.tgz # Alpine.js CSP build npm package, extracted by make assets
├── cmd/webhooker/
│ └── main.go # Entry point: subcommand dispatch; no args locks DATA_DIR and wires fx
├── internal/
@@ -2899,7 +2969,8 @@ webhooker/
│ ├── resetpw/
│ │ └── resetpw.go # `webhooker resetpw`: set an account's password, stopped deployments only
│ ├── config/
│ │ └── config.go # Configuration loading from environment variables
│ │ ├── config.go # Configuration loading from environment variables
│ │ └── testing.go # ClearEnvForTest: an empty environment for one test
│ ├── database/
│ │ ├── base_model.go # BaseModel with UUID primary keys
│ │ ├── database.go # GORM connection, migrations, admin seed
@@ -2934,6 +3005,7 @@ webhooker/
│ │ ├── target_slack.go # Slack/Mattermost incoming-webhook target
│ │ ├── target_database.go # Database archive target
│ │ ├── target_database_archive.go # Archive file lifecycle and pruning
│ │ ├── target_database_export.go # Archive download as gzipped JSON
│ │ ├── target_log.go # Log target (stdout)
│ │ ├── target_config_view.go # Masked target config for templates
│ │ ├── archive_sweeper.go # Periodic pruning of idle archives
@@ -2958,7 +3030,7 @@ webhooker/
│ ├── lifecycle/
│ │ └── lifecycle.go # Shared stop-hook waiter, bounded by the stop context
│ ├── logger/
│ │ └── logger.go # slog setup with TTY detection
│ │ └── logger.go # slog setup with TTY detection; fx's event logger
│ ├── metrics/
│ │ └── metrics.go # Delivery Prometheus collectors, labelled by target type
│ ├── middleware/
@@ -2982,14 +3054,15 @@ webhooker/
│ ├── static.go # //go:embed directive
│ ├── css/input.css # Tailwind input, source for tailwind.css (make css)
│ ├── css/tailwind.css # Generated stylesheet the pages load
│ ├── css/style.css # Older hand-written stylesheet, no longer loaded
│ ├── js/app.js # Progressive-enhancement copy-to-clipboard
│ └── js/alpine.min.js # Alpine.js, extracted from 3p/ by make assets, not committed
│ ├── css/style.css # Hand-written, loaded after tailwind.css: btn-small, the pointer cursor for input.css's buttons, the webhook list cards' focus outline
│ ├── js/app.js # Copy-to-clipboard, and the Alpine.js components
│ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed
├── templates/ # Go HTML templates (base, login, sources, etc.)
├── script/ # Scripts to Rule Them All entrypoints
├── Dockerfile # Three stages: lint, test+build, Alpine runtime
├── Dockerfile.lint # Lint-only image built by script/lint
├── Makefile # 10 of 17 targets shim script/; 7 are inline
├── Dockerfile.browser # Browser test image built by script/test-browser
├── Makefile # 11 of 18 targets shim script/; 7 are inline
├── go.mod / go.sum
└── .golangci.yml # Linter configuration
```
@@ -3038,7 +3111,7 @@ Applied to all routes in this order:
(HSTS, X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy,
Permissions-Policy)
3. **Logging** — Structured request logging (method, URL, status,
latency, remote IP, user agent, request ID)
latency, remote IP, client IP, user agent, request ID)
4. **Metrics** — Prometheus HTTP metrics (if `METRICS_USERNAME` and
`METRICS_PASSWORD` are both set)
5. **CORS** — Cross-origin resource sharing headers
@@ -3199,9 +3272,9 @@ each hook. The order, read off the fx stop-hook log:
1. `ArchiveSweeper`
2. `RetentionReaper`
3. `server` — the HTTP drain, bounded separately by
`server.ShutdownTimeout` (**3 seconds**), then a Sentry flush if
`SENTRY_DSN` is set
3. `server` — the HTTP drain, bounded by `server.ShutdownTimeout`
(**3 seconds**) and by what the hooks before it left, then a Sentry
flush if `SENTRY_DSN` is set
4. `delivery.Engine` — waits for its workers, then closes the archive
databases
5. `healthcheck`
@@ -3221,23 +3294,30 @@ exhaust the sequence budget at the instant it finished, and every
later hook — the delivery engine, the healthcheck, the webhook DB
manager and the database close — would be skipped in exactly the
case where the drain mattered. 3 seconds leaves 2 seconds
(`server.TailHookReserve`) for the tail, which is far more than the
microseconds it needs.
(`server.TailHookReserve`) for the tail. The reserve is that
remainder, not a figure sized to the tail, which takes about a
millisecond.
That reserve belongs to the tail hooks, not to the server hook, and
the Sentry flush is what could take it: it runs after the drain
**inside the same hook**, and `sentry.Flush` takes a bare duration
and honours no context, so an unreachable Sentry endpoint would add
its own timeout on top of a full-length drain and consume the whole
sequence budget by itself. It is therefore clamped to whatever is
left on the stop context minus the reserve, and skipped when that
leaves too little to be worth attempting — so a full-length drain
means Sentry events are dropped rather than the database close being
skipped.
the server hook could take it in two ways. The hooks before it may
already have spent part of the budget, so a full 3-second drain
would come out of the reserve; the drain is therefore also bounded
by whatever is left on the stop context minus the reserve. And the
Sentry flush runs after the drain **inside the same hook**, and
`sentry.Flush` takes a bare duration and honours no context, so an
unreachable Sentry endpoint would add its own timeout on top of a
full-length drain and consume the whole sequence budget by itself.
It is clamped the same way, and skipped when that leaves too little
to be worth attempting — so a full-length drain means Sentry events
are dropped rather than the database close being skipped.
This does not make the database close unconditional: a wedged
`ArchiveSweeper` or `RetentionReaper` still runs first and can
consume the whole budget on its own.
This does not make the database close unconditional. A slow
`ArchiveSweeper` or `RetentionReaper` is enough to cut the shutdown
short, not only one that consumes the whole budget: what they spend
comes out of the drain first, so after 2 seconds of theirs a request
still in flight gets 1 second to finish, and after 3 it gets none.
Past 3 seconds they spend the reserve itself, and one that takes the
whole budget skips every hook after it, the database close included.
The value is chosen to sit inside the container stop grace period.
Docker's default `docker stop` grace is 10 seconds and the Dockerfile
@@ -3335,8 +3415,9 @@ linked, which is what lets it run on the Alpine runtime image.
inside the image, so a build that succeeds is a repo that is formatted,
linted, tested and compiled. `script/lint` also uses Docker
(`Dockerfile.lint`, see Linting above), so `make lint` and `make check`
run the same pinned linter version the gate does; only `script/test`
and `script/fmt-check` run on the host.
run the same pinned linter version the gate does; of the steps
`make check` runs, only `script/test` and `script/fmt-check` run on the
host.
#### CI gate honesty
+23 -7
View File
@@ -8,6 +8,7 @@ import (
"time"
"go.uber.org/fx"
"go.uber.org/fx/fxevent"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/datadir"
@@ -37,17 +38,19 @@ import (
// hook that used the whole budget would exhaust it at that instant,
// and fx would skip every hook after the server — the delivery
// engine, the healthcheck, the webhook DB manager and the database
// close. That hook is the 3s HTTP drain plus the Sentry flush that
// follows it in the same hook, so the flush is clamped to the stop
// close. That hook is the HTTP drain plus the Sentry flush that
// follows it in the same hook, and each is clamped to the stop
// context's remaining time less server.TailHookReserve rather than
// running for its own fixed 2s; the reserve is what the tail hooks
// live on, and they are microsecond-scale in normal operation.
// running for its own fixed 3s and 2s; the reserve is what the tail
// hooks live on, and they are microsecond-scale in normal operation.
// TestStopTimeout_LeavesHeadroomForTailHooks pins the arithmetic
// across every drain length.
// across every drain length and every amount of budget the hooks
// before the server may already have spent.
//
// This does not make the database close unconditional: the
// ArchiveSweeper and RetentionReaper hooks run before the server
// and can still consume the whole budget on their own.
// ArchiveSweeper and RetentionReaper hooks run before the server.
// What they spend comes out of the drain first, but past 3s it comes
// out of the reserve, and they can consume the whole budget.
const stopTimeout = 5 * time.Second
// exitUsage is the status for a command line this binary cannot make
@@ -168,6 +171,19 @@ func run(stderr io.Writer) int {
func newApp() *fx.App {
return fx.New(
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(
globals.New,
logger.New,
+129 -9
View File
@@ -2,12 +2,19 @@ package main
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"net"
"os"
"strconv"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/datadir"
"sneak.berlin/go/webhooker/internal/resetpw"
"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
// is set whether or not the graph itself can be constructed here.
func TestNewApp_StopTimeout(t *testing.T) {
config.ClearEnvForTest(t)
t.Setenv("DATA_DIR", t.TempDir())
got := newApp().StopTimeout()
@@ -38,6 +46,100 @@ func TestNewApp_StopTimeout(t *testing.T) {
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
// does. The entry point must refuse before it builds the fx graph —
// nothing may open a database in a DATA_DIR another process holds —
@@ -150,22 +252,40 @@ const tailHeadroom = 2 * time.Second
// can produce, since a shorter drain leaves the flush more room and
// the worst case is not necessarily at either extreme.
//
// Shrinking either budget, or unbounding the flush again, must fail
// here rather than silently recreating a hook that swallows the
// whole sequence.
// Nor does the hook start on a full budget: the ArchiveSweeper and
// RetentionReaper hooks run before it, and whatever they spent is
// gone. The outer sweep walks every amount they can spend. Once they
// have eaten into the headroom themselves, the hook must spend
// nothing of what is left. A drain that starts on the full budget
// must still get all of ShutdownTimeout, so a smaller stopTimeout
// cannot silently shorten every drain.
//
// Shrinking either budget, or unbounding the drain or the flush
// again, must fail here rather than silently recreating a hook that
// swallows the whole sequence.
func TestStopTimeout_LeavesHeadroomForTailHooks(t *testing.T) {
t.Parallel()
require.Less(t, server.ShutdownTimeout, stopTimeout)
require.Equal(
t, server.ShutdownTimeout, server.DrainBudget(stopTimeout),
"a drain that starts on the full stop budget is cut short",
)
const step = 10 * time.Millisecond
for drain := time.Duration(0); drain <= server.ShutdownTimeout; drain += step {
hook := drain + server.SentryFlushBudget(stopTimeout-drain)
for spent := time.Duration(0); spent <= stopTimeout; spent += step {
remaining := stopTimeout - spent
longest := max(server.DrainBudget(remaining), 0)
require.LessOrEqual(
t, hook+tailHeadroom, stopTimeout,
"a %s drain leaves the tail hooks short", drain,
)
for drain := time.Duration(0); drain <= longest; drain += step {
hook := drain + server.SentryFlushBudget(remaining-drain)
require.GreaterOrEqual(
t, remaining-hook, min(remaining, tailHeadroom),
"a %s drain after %s of earlier hooks leaves "+
"the tail hooks short", drain, spent,
)
}
}
}
+12 -7
View File
@@ -4,6 +4,8 @@ go 1.26.1
require (
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/go-chi/chi v1.5.5
@@ -18,7 +20,7 @@ require (
github.com/prometheus/client_model v0.5.0
github.com/slok/go-http-metrics v0.11.0
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
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/sqlite v1.5.4
@@ -29,13 +31,17 @@ require (
require (
github.com/beorn7/perks v1.0.1 // 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/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/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // 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-sqlite3 v1.14.17 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/dig v1.17.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
go.uber.org/zap v1.23.0 // indirect
go.uber.org/dig v1.19.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.26.0 // indirect
golang.org/x/mod v0.17.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/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // 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/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/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/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f h1:8PK9FM4bE0C8GMoWBW5lVsef3U7sPICjDg6JqngyYhk=
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f/go.mod h1:3v4FIp5njIUyPDvqXsxEOxnB34lijG0up98/5kM1KaE=
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
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/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
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/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
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/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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/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/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/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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/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/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/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/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
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/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/dig v1.17.0 h1:5Chju+tUvcC+N7N6EV08BJz41UZuO3BmHcN4A287ZLI=
go.uber.org/dig v1.17.0/go.mod h1:rTxpf7l5I0eBTlE6/9RL+lDybC7WFwY2QH55ZSjy1mU=
go.uber.org/fx v1.20.1 h1:zVwVQGS8zYvhh9Xxcu4w1M6ESyeMzebzj2NbSayZ4Mk=
go.uber.org/fx v1.20.1/go.mod h1:iSYNbHf2y55acNCwCXKx7LbWb5WG1Bnue5RDXz1OREg=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
go.uber.org/zap v1.23.0 h1:OjGQ5KQDEUawVHxNwQgPpiypGHOxo2mNZsOqTak4fFY=
go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY=
go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg=
go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
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/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/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
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.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
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/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
+19 -10
View File
@@ -80,8 +80,7 @@ const (
// 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. The lower
// bound (at least 1) is enforced by envPositiveInt.
// maxPort is the highest valid TCP port number.
maxPort = 65535
// mappedV4Offset is the number of leading bits an IPv4-mapped
@@ -105,7 +104,7 @@ var ErrInvalidEnvironment = errors.New("invalid environment")
var ErrNonPositiveValue = errors.New("value must be positive")
// 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")
// ErrInvalidCIDR is returned when an environment variable holding a
@@ -363,17 +362,27 @@ func envPositiveInt(
// envPort returns the value of the named environment variable parsed
// 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
// 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) {
port, err := envPositiveInt(key, defaultValue)
if err != nil {
return 0, err
v := os.Getenv(key)
if v == "" {
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(
"%w: %s must be at most %d, got %d",
ErrInvalidPort, key, maxPort, port,
"invalid integer for %s: %q: %w", key, v, err,
)
}
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,
)
}
+16 -45
View File
@@ -3,7 +3,6 @@ package config_test
import (
"bytes"
"log/slog"
"os"
"testing"
"time"
@@ -71,14 +70,12 @@ func TestEnvironmentConfig(t *testing.T) {
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.envValue != "" {
t.Setenv(
"WEBHOOKER_ENVIRONMENT", tt.envValue,
)
} else {
require.NoError(t, os.Unsetenv(
"WEBHOOKER_ENVIRONMENT",
))
}
for k, v := range tt.envVars {
@@ -199,14 +196,11 @@ func TestRetentionSweepInterval(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set {
t.Setenv("RETENTION_SWEEP_INTERVAL", tt.value)
} else {
require.NoError(t, os.Unsetenv(
"RETENTION_SWEEP_INTERVAL",
))
}
if tt.expectError {
@@ -341,14 +335,11 @@ func TestSessionIdleTimeout(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set {
t.Setenv("SESSION_IDLE_TIMEOUT", tt.value)
} else {
require.NoError(t, os.Unsetenv(
"SESSION_IDLE_TIMEOUT",
))
}
if tt.expectError {
@@ -397,16 +388,12 @@ func TestDefaultDataDir(t *testing.T) {
t.Run("env="+name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
config.ClearEnvForTest(t)
if 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
app := fxtest.New(
@@ -446,9 +433,9 @@ func TestDataDirHelper(t *testing.T) {
t.Run(name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
if set == "" {
require.NoError(t, os.Unsetenv("DATA_DIR"))
} else {
config.ClearEnvForTest(t)
if set != "" {
t.Setenv("DATA_DIR", set)
}
@@ -511,14 +498,11 @@ func TestReceiverRateLimit(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set {
t.Setenv("RECEIVER_RATE_LIMIT", tt.value)
} else {
require.NoError(t, os.Unsetenv(
"RECEIVER_RATE_LIMIT",
))
}
if tt.expectError {
@@ -630,12 +614,11 @@ func TestTrustedProxies(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set {
t.Setenv("TRUSTED_PROXIES", tt.value)
} else {
require.NoError(t, os.Unsetenv("TRUSTED_PROXIES"))
}
if tt.expectError {
@@ -742,14 +725,11 @@ func TestAllowedEgressCIDRs(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set {
t.Setenv("ALLOWED_EGRESS_CIDRS", tt.value)
} else {
require.NoError(
t, os.Unsetenv("ALLOWED_EGRESS_CIDRS"),
)
}
if tt.expectError {
@@ -817,13 +797,10 @@ func TestEgressAllowlistWarning(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", config.EnvironmentDev)
if tt.allowed == "" {
require.NoError(
t, os.Unsetenv("ALLOWED_EGRESS_CIDRS"),
)
} else {
if tt.allowed != "" {
t.Setenv("ALLOWED_EGRESS_CIDRS", tt.allowed)
}
@@ -956,20 +933,14 @@ func TestMetricsAuthConfig(t *testing.T) {
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.username.set {
t.Setenv("METRICS_USERNAME", tt.username.value)
} else {
require.NoError(
t, os.Unsetenv("METRICS_USERNAME"),
)
}
if tt.password.set {
t.Setenv("METRICS_PASSWORD", tt.password.value)
} else {
require.NoError(
t, os.Unsetenv("METRICS_PASSWORD"),
)
}
if tt.expectError {
+7 -18
View File
@@ -22,17 +22,6 @@ const malformedDotEnv = "PORT 19615\n" +
"this is not = valid ! syntax\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
// directory and returns its path.
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
// have.
//
//nolint:paralleltest // unsetDotEnvKey uses t.Setenv.
//nolint:paralleltest // ClearEnvForTest uses t.Setenv.
func TestLoadDotEnv_MissingFileIsFine(t *testing.T) {
unsetDotEnvKey(t)
config.ClearEnvForTest(t)
absent := filepath.Join(t.TempDir(), config.DotEnvPath)
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
// at all.
//
//nolint:paralleltest // unsetDotEnvKey uses t.Setenv.
//nolint:paralleltest // ClearEnvForTest uses t.Setenv.
func TestLoadDotEnv_AppliesValues(t *testing.T) {
unsetDotEnvKey(t)
config.ClearEnvForTest(t)
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
// 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) {
unsetDotEnvKey(t)
config.ClearEnvForTest(t)
path := writeDotEnv(
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.
func TestLoadDotEnv_ReadsTheWorkingDirectory(t *testing.T) {
unsetDotEnvKey(t)
config.ClearEnvForTest(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(
+78 -91
View File
@@ -1,7 +1,6 @@
package config_test
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
@@ -121,10 +120,10 @@ func TestEnvBool(t *testing.T) {
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)
} else {
require.NoError(t, os.Unsetenv(testEnvKey))
}
got, err := config.EnvBoolForTest(
@@ -145,17 +144,62 @@ func TestEnvBool(t *testing.T) {
}
}
// envIntCase is one row of the envPositiveInt and envPort tables.
type envIntCase struct {
name string
set bool
value string
expectError bool
errIs error
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
tests := []struct {
name string
set bool
value string
expectError bool
errIs error
expected int
}{
runEnvIntCases(t, config.EnvPositiveIntForTest, defaultValue, []envIntCase{
{
name: "unset returns the default integer",
expected: defaultValue,
@@ -192,51 +236,14 @@ func TestEnvPositiveInt(t *testing.T) {
expectError: true,
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) {
const defaultValue = 8080
tests := []struct {
name string
set bool
value string
expectError bool
errIs error
expected int
}{
runEnvIntCases(t, config.EnvPortForTest, defaultValue, []envIntCase{
{
name: "unset returns the default port",
expected: defaultValue,
@@ -264,7 +271,14 @@ func TestEnvPort(t *testing.T) {
set: true,
value: "0",
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",
@@ -273,37 +287,14 @@ func TestEnvPort(t *testing.T) {
expectError: true,
errIs: config.ErrInvalidPort,
},
}
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.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)
})
}
{
name: "too large for an int is rejected",
set: true,
value: "99999999999999999999",
expectError: true,
errIs: config.ErrInvalidPort,
},
})
}
// TestEnvBindAddress covers BIND_ADDRESS parsing.
@@ -319,10 +310,10 @@ func TestEnvBindAddress(t *testing.T) {
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)
} else {
require.NoError(t, os.Unsetenv(testEnvKey))
}
got, err := config.EnvBindAddressForTest(
@@ -485,6 +476,7 @@ func TestNewRejectsBadEnvValues(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
t.Setenv(tt.key, tt.value)
@@ -646,14 +638,9 @@ func sentryEnvValueCases() []badEnvValueCase {
// break the legitimate unset case: absent variables still get their
// documented defaults.
func TestNewUsesDefaultsWhenUnset(t *testing.T) {
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
for _, key := range []string{
envKeyPort, envKeyDebug, envKeyBindAddress, envKeySentryDSN,
} {
require.NoError(t, os.Unsetenv(key))
}
cfg, err := buildConfig(t)
require.NoError(t, err)
require.NotNil(t, cfg)
+2 -3
View File
@@ -1,7 +1,6 @@
package config_test
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
@@ -101,10 +100,10 @@ func TestEnvSentryDSN(t *testing.T) {
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(envKeySentryDSN, tt.value)
} else {
require.NoError(t, os.Unsetenv(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 -2
View File
@@ -15,6 +15,7 @@ type APIKey struct {
Description string `json:"description"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
// Relations
User User `json:"user,omitzero"`
// Relations. No model marshals the record it belongs to:
// User.APIKeys leads back here, and the JSON could loop.
User User `json:"-"`
}
+5 -3
View File
@@ -56,8 +56,10 @@ type Delivery struct {
// the index.
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
// Relations
Event Event `json:"event,omitzero"`
Target Target `json:"target,omitzero"`
// 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"`
}
+3 -2
View File
@@ -23,6 +23,7 @@ type DeliveryResult struct {
Error string `json:"error,omitempty"`
Duration int64 `json:"durationMs"` // Duration in milliseconds
// Relations
Delivery Delivery `json:"delivery,omitzero"`
// Relations. No model marshals the record it belongs to:
// 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"`
Active bool `gorm:"default:true" json:"active"`
// Relations
Webhook Webhook `json:"webhook,omitzero"`
// Relations. No model marshals the record it belongs to:
// Webhook.Entrypoints leads back here, and the JSON could loop.
Webhook Webhook `json:"-"`
}
+4 -3
View File
@@ -44,8 +44,9 @@ type Event struct {
// kept as the record of where the copy came from either way.
ResubmittedFromID *string `gorm:"type:uuid;index" json:"resubmittedFromId,omitempty"`
// Relations
Webhook Webhook `json:"webhook,omitzero"`
Entrypoint Entrypoint `json:"entrypoint,omitzero"`
// Relations. No model marshals the record it belongs to, so
// Webhook and Entrypoint are left out of the JSON.
Webhook Webhook `json:"-"`
Entrypoint Entrypoint `json:"-"`
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"`
MaxQueueSize int `json:"maxQueueSize,omitempty"`
// Relations
Webhook Webhook `json:"webhook,omitzero"`
// Relations. No model marshals the record it belongs to:
// Webhook.Targets leads back here, and the JSON could loop.
Webhook Webhook `json:"-"`
Deliveries []Delivery `json:"deliveries,omitempty"`
}
+3 -2
View File
@@ -66,8 +66,9 @@ type Webhook struct {
// must equal DefaultRetentionDays.
RetentionDays int `gorm:"default:30" json:"retentionDays"`
// Relations
User User `json:"user,omitzero"`
// Relations. No model marshals the record it belongs to:
// User.Webhooks leads back here, and the JSON could loop.
User User `json:"-"`
Entrypoints []Entrypoint `json:"entrypoints,omitempty"`
Targets []Target `json:"targets,omitempty"`
}
+7 -15
View File
@@ -184,16 +184,6 @@ func (r *RetentionReaper) sweep(ctx context.Context) {
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
// been created.
if !r.dbManager.DBExists(wh.ID) {
@@ -212,6 +202,13 @@ func (r *RetentionReaper) reapWebhook(
webhookID string,
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)
if err != nil {
r.log.Error(
@@ -223,11 +220,6 @@ func (r *RetentionReaper) reapWebhook(
return
}
cutoff, ok := retentionCutoff(time.Now(), retentionDays)
if !ok {
return
}
deleted, err := reapExpired(ctx, db, cutoff)
if err != nil {
r.log.Error(
+1 -1
View File
@@ -362,7 +362,7 @@ func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
t,
overflowingRetentionDays,
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(
+11 -4
View File
@@ -102,6 +102,13 @@ func TestWebhookDBManager_TotalsSurviveReopen(t *testing.T) {
// 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,
@@ -138,8 +145,8 @@ func seedExpiredEvents(
)
}
require.NoError(t, db.CreateInBatches(events, 500).Error)
require.NoError(t, db.CreateInBatches(deliveries, 500).Error)
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 {
@@ -148,7 +155,7 @@ func seedExpiredEvents(
}
}
require.NoError(t, db.CreateInBatches(results, 500).Error)
require.NoError(t, db.CreateInBatches(results, 50).Error)
}
// seedBareEvents stores count events created at the given time, with
@@ -172,7 +179,7 @@ func seedBareEvents(
events[i].CreatedAt = createdAt
}
require.NoError(t, db.CreateInBatches(events, 500).Error)
require.NoError(t, db.CreateInBatches(events, 50).Error)
}
// TestRetentionReaper_PrunesMoreThanOneBatch verifies that a prune
+44 -10
View File
@@ -33,6 +33,19 @@ var errInvalidCachedDBType = errors.New(
"invalid cached database type",
)
// ErrEventDBNotRemoved is in DeleteDB's error when the event
// database file itself could not be removed: it is still on disk.
var ErrEventDBNotRemoved = errors.New(
"event database file not removed",
)
// ErrSidecarNotRemoved is in DeleteDB's error when the event
// database file was removed, so its events are gone, but its -wal
// or -shm sidecar could not be.
var ErrSidecarNotRemoved = errors.New(
"event database file removed, but a -wal or -shm sidecar was not",
)
// WebhookDBManager manages per-webhook SQLite database files
// for event storage. Each webhook gets its own dedicated
// database containing Events, Deliveries, DeliveryResults and the
@@ -151,7 +164,10 @@ func (m *WebhookDBManager) DBExists(
}
// DeleteDB closes the connection and deletes the database file
// for a webhook. The file is permanently removed.
// for a webhook, with its -wal and -shm sidecars. The files are
// permanently removed. Each file is tried even when another could
// not be removed, and the error wraps ErrEventDBNotRemoved or
// ErrSidecarNotRemoved to say which was left, naming each file.
func (m *WebhookDBManager) DeleteDB(
webhookID string,
) error {
@@ -170,16 +186,23 @@ func (m *WebhookDBManager) DeleteDB(
}
}
// Delete the main DB file and WAL/SHM files
path := m.dbPath(webhookID)
for _, suffix := range []string{"", "-wal", "-shm"} {
err := os.Remove(path + suffix)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf(
"deleting webhook database file %s%s: %w",
path, suffix, err,
)
}
dbErr := removeFile(path)
sidecarErr := errors.Join(
removeFile(path+"-wal"),
removeFile(path+"-shm"),
)
if dbErr != nil {
return fmt.Errorf(
"%w: %w",
ErrEventDBNotRemoved, errors.Join(dbErr, sidecarErr),
)
}
if sidecarErr != nil {
return fmt.Errorf("%w: %w", ErrSidecarNotRemoved, sidecarErr)
}
m.log.Info(
@@ -190,6 +213,17 @@ func (m *WebhookDBManager) DeleteDB(
return nil
}
// removeFile removes path. A file that is already gone counts as
// removed; the error from any other failure names the file.
func removeFile(path string) error {
err := os.Remove(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
// CloseAll closes all open per-webhook database connections.
// Called during application shutdown.
func (m *WebhookDBManager) CloseAll() error {
+77 -3
View File
@@ -182,17 +182,91 @@ func TestWebhookDBManager_DeleteDB(t *testing.T) {
}
require.NoError(t, db.Create(event).Error)
// Under WAL, an open database that has been written to has both
// sidecars beside it.
dbPath := mgr.DBPath(webhookID)
require.FileExists(t, dbPath+"-wal")
require.FileExists(t, dbPath+"-shm")
// Delete the DB
require.NoError(t, mgr.DeleteDB(webhookID))
// File should no longer exist
assert.False(t, mgr.DBExists(webhookID))
// Verify the file is actually gone from disk
// Verify the files are actually gone from disk
assert.NoFileExists(t, dbPath)
assert.NoFileExists(t, dbPath+"-wal")
assert.NoFileExists(t, dbPath+"-shm")
}
// blockRemoval puts a non-empty directory at path, which os.Remove
// cannot remove whoever runs the test, root included.
func blockRemoval(t *testing.T, path string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Join(path, "keep"), 0o700))
}
// TestWebhookDBManager_DeleteDBKeepsDatabaseFile proves that when the
// event database file cannot be removed, the error says so, and both
// sidecars are still removed.
func TestWebhookDBManager_DeleteDBKeepsDatabaseFile(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()
dbPath := mgr.DBPath(webhookID)
_, err = os.Stat(dbPath)
assert.True(t, os.IsNotExist(err))
blockRemoval(t, dbPath)
require.NoError(t, os.WriteFile(dbPath+"-wal", nil, 0o600))
require.NoError(t, os.WriteFile(dbPath+"-shm", nil, 0o600))
err := mgr.DeleteDB(webhookID)
require.ErrorIs(t, err, database.ErrEventDBNotRemoved)
require.NotErrorIs(t, err, database.ErrSidecarNotRemoved)
assert.Contains(t, err.Error(), dbPath)
assert.NoFileExists(t, dbPath+"-wal")
assert.NoFileExists(t, dbPath+"-shm")
}
// TestWebhookDBManager_DeleteDBKeepsSidecar proves that when the
// event database file is removed but a sidecar is not, the error
// says the database file is gone, and the other sidecar is still
// removed.
func TestWebhookDBManager_DeleteDBKeepsSidecar(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()
dbPath := mgr.DBPath(webhookID)
require.NoError(t, mgr.CreateDB(webhookID))
// Closing removes the sidecars, so the ones below are the only
// ones there.
require.NoError(t, mgr.CloseAll())
blockRemoval(t, dbPath+"-wal")
require.NoError(t, os.WriteFile(dbPath+"-shm", nil, 0o600))
err := mgr.DeleteDB(webhookID)
require.ErrorIs(t, err, database.ErrSidecarNotRemoved)
require.NotErrorIs(t, err, database.ErrEventDBNotRemoved)
assert.Contains(t, err.Error(), dbPath+"-wal")
assert.NoFileExists(t, dbPath)
assert.NoFileExists(t, dbPath+"-shm")
}
func TestWebhookDBManager_LazyCreation(t *testing.T) {
+15 -2
View File
@@ -45,8 +45,13 @@ type ArchiveSweeper struct {
eng *Engine
log *slog.Logger
interval time.Duration
cancel context.CancelFunc
wg sync.WaitGroup
// cancel needs no lock: fx calls the stop hook only after the
// start hook has returned, so stop never reads it while start
// is still setting it.
cancel context.CancelFunc
wg sync.WaitGroup
}
// NewArchiveSweeper creates the archive sweeper and registers
@@ -163,10 +168,18 @@ func (s *ArchiveSweeper) sweep(ctx context.Context) {
var targets []database.Target
err := s.db.DB().
WithContext(ctx).
Model(&database.Target{}).
Where("type = ?", database.TargetTypeDatabase).
Find(&targets).Error
if err != nil {
// The app stopping as a sweep starts cancels the listing.
// Stopping is not a failure, so it must not produce an
// error line.
if ctx.Err() != nil {
return
}
s.log.Error(
"archive sweep: failed to list database targets",
"error", err,
+67 -3
View File
@@ -1,9 +1,11 @@
package delivery_test
import (
"bytes"
"context"
"database/sql"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
@@ -20,6 +22,7 @@ import (
_ "modernc.org/sqlite" // Pure Go SQLite driver.
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/gormlog"
)
const (
@@ -68,7 +71,8 @@ func setupArchiveTest(t *testing.T) *archiveEnv {
t.Cleanup(func() { _ = sqlDB.Close() })
gdb, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
sqlite.Dialector{Conn: sqlDB},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
)
require.NoError(t, err)
@@ -166,7 +170,8 @@ func (env *archiveEnv) seedArchiveRows(
require.NoError(t, err)
gdb, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
sqlite.Dialector{Conn: sqlDB},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
)
require.NoError(t, err)
@@ -225,7 +230,8 @@ func countArchivedRows(path string) (int64, error) {
defer func() { _ = sqlDB.Close() }()
gdb, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
sqlite.Dialector{Conn: sqlDB},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
)
if err != nil {
return 0, err
@@ -681,6 +687,64 @@ func TestArchiveSweep_ClosesHandleOfRegisteredWriter(
)
}
// TestArchiveSweep_ClosesHandleBeforeReopening proves the sweep
// closes the handle it finds open before it reopens the file.
// TestArchiveSweep_LeavesArchiveClosed cannot see this: without the
// close, the reopen replaces the handle without closing it, the
// sweep then closes only the new one, and one connection leaks per
// archive per sweep.
func TestArchiveSweep_ClosesHandleBeforeReopening(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive.db")
w := delivery.NewExportArchiveWriter(
path, archiveTestLogger(), 0,
)
require.NoError(t, w.Open(time.Hour))
before, err := w.DB().DB()
require.NoError(t, err)
require.NoError(t, w.SweepExpired(time.Hour))
assert.Error(
t, before.PingContext(t.Context()),
"the handle open before the sweep must be closed by it",
)
}
// TestArchiveSweep_CancelledSweepLogsNoError proves a sweep whose
// context is already cancelled, as when the app stops just as a
// sweep starts, returns without an error line: stopping is not a
// failure.
func TestArchiveSweep_CancelledSweepLogsNoError(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
var errorLines bytes.Buffer
sweeper := delivery.NewTestArchiveSweeper(
env.mainDB, env.eng,
slog.New(slog.NewTextHandler(
&errorLines,
&slog.HandlerOptions{Level: slog.LevelError},
)),
)
ctx, cancel := context.WithCancel(context.Background())
cancel()
sweeper.ExportSweep(ctx)
assert.Empty(
t, errorLines.String(),
"a cancelled sweep must not log at error level",
)
}
// TestArchiveSweep_NeverExpiryUntouched proves the sweep is a
// no-op for the default retention policy, so archives with no
// expiry (or the literal "never") behave exactly as before.
+45 -3
View File
@@ -23,6 +23,7 @@ import (
_ "modernc.org/sqlite"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/gormlog"
)
// iSetup holds common integration test dependencies.
@@ -80,7 +81,8 @@ func iMainDB(t *testing.T) *gorm.DB {
t.Cleanup(func() { _ = sqlDB.Close() })
db, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
sqlite.Dialector{Conn: sqlDB},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
)
require.NoError(t, err)
@@ -355,9 +357,14 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) {
s := newISetup(t)
var receivedBody string
ts := httptest.NewServer(
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)
},
),
@@ -397,6 +404,8 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) {
context.TODO(), &task,
)
assert.Equal(t, event.Body, receivedBody)
iAssertStatus(t, s.WebhookDB, d.ID,
database.DeliveryStatusDelivered,
)
@@ -443,9 +452,14 @@ func TestProcessRetryTask_LargeBody_FetchFromDB(
s := newISetup(t)
var receivedBody string
ts := httptest.NewServer(
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)
},
),
@@ -482,6 +496,8 @@ func TestProcessRetryTask_LargeBody_FetchFromDB(
context.TODO(), &task,
)
assert.Equal(t, largeBody, receivedBody)
iAssertStatus(t, s.WebhookDB, d.ID,
database.DeliveryStatusDelivered,
)
@@ -1411,6 +1427,32 @@ func TestDeliverHTTP_InvalidConfig(t *testing.T) {
)
}
// TestDeliverHTTP_InvalidConfigUnrecordedStaysPending: a delivery is
// failed for an invalid config only once the reason is recorded.
// Unrecorded, it stays pending, where the sweep finds it again.
func TestDeliverHTTP_InvalidConfigUnrecordedStaysPending(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e := testEngine(t, 1)
event, del := iSeedEventAndDelivery(
t, db, `{"config":"invalid"}`, "",
)
task, d := iHTTPTaskAndDelivery(
event, del, "bad-config", `not-json`, 0, 1,
)
require.NoError(t, db.Exec("drop table delivery_results").Error)
e.ExportDeliverHTTP(context.TODO(), db, d, task)
iAssertStatus(t, db, del.ID,
database.DeliveryStatusPending,
)
}
// --- Notify batching ---
func TestNotify_MultipleTasks(t *testing.T) {
+74 -1
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
@@ -25,6 +26,7 @@ import (
_ "modernc.org/sqlite"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/gormlog"
"sneak.berlin/go/webhooker/internal/metrics"
)
@@ -49,7 +51,8 @@ func testWebhookDB(t *testing.T) *gorm.DB {
t.Cleanup(func() { _ = sqlDB.Close() })
db, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
sqlite.Dialector{Conn: sqlDB},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
)
require.NoError(t, err)
@@ -1056,6 +1059,21 @@ func TestParseHTTPConfig_MissingURL(t *testing.T) {
)
}
func TestParseHTTPConfig_Undecodable(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
_, err := e.ExportParseHTTPConfig(
`{"url":"https://example.com/hook","timeout":"soon"}`,
)
assert.Error(t, err,
"config that does not decode should return error, "+
"even when the part that did names a URL",
)
}
func TestScheduleRetry_SendsToRetryChannel(
t *testing.T,
) {
@@ -1241,6 +1259,33 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
)
}
// A response that ends before the length it announced is an error, not
// a short body.
func TestDoHTTPRequest_CutShortResponseIsAnError(t *testing.T) {
t.Parallel()
ts := httptest.NewServer(
http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", "100")
_, _ = w.Write([]byte("cut short"))
},
),
)
defer ts.Close()
e := testEngine(t, 1)
_, body, _, err := e.ExportDoHTTPRequest(
context.TODO(),
&delivery.HTTPTargetConfig{URL: ts.URL},
&database.Event{},
)
require.ErrorIs(t, err, io.ErrUnexpectedEOF)
assert.Empty(t, body)
}
// The event's stored inbound headers carry the same Content-Type the
// receiver saved as the event's ContentType, so a delivery could send
// it twice. It must go out exactly once, with a Content-Type configured
@@ -1317,6 +1362,34 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) {
}
}
// Stored inbound headers that do not decode forward nothing, not the
// part of them that happened to decode.
func TestApplyRequestHeaders_UndecodableInboundForwardsNothing(
t *testing.T,
) {
t.Parallel()
req, err := http.NewRequestWithContext(
context.Background(),
http.MethodPost,
"https://target.example.com/hook",
http.NoBody,
)
require.NoError(t, err)
names := delivery.ExportApplyRequestHeaders(
req,
&database.Event{
Headers: `{"X-Custom":["value1"],"X-Broken":"not a list"}`,
},
&delivery.HTTPTargetConfig{},
"webhooker/dev",
)
assert.Empty(t, names)
assert.Empty(t, req.Header.Get("X-Custom"))
}
func TestProcessDelivery_RoutesToCorrectHandler(
t *testing.T,
) {
@@ -376,3 +376,97 @@ func TestFailedResultWriteLeavesDeliveryRecoverable(
database.DeliveryStatusPending,
)
}
// TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable is the same
// rule for a target with retries: whatever the receiver answered, the
// delivery stays pending and no retry is scheduled. The circuit breaker
// still learns the answer, because it describes the target's health,
// not the database's.
func TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable(
t *testing.T,
) {
t.Parallel()
// The "send succeeded" case starts with the breaker tripped open,
// so the delivery goes out as its probe and only a recorded
// success closes it again.
tests := []struct {
name string
answer int
tripped bool
wantBreaker delivery.CircuitState
}{
{"send succeeded", http.StatusOK, true, delivery.CircuitClosed},
{"send failed", http.StatusBadGateway, false, delivery.CircuitOpen},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s := newISetup(t)
targetID := uuid.New().String()
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(tc.answer)
},
))
defer ts.Close()
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"unwritable":true}`,
)
d := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
require.NoError(
t,
s.WebhookDB.Exec("drop table delivery_results").Error,
)
// A single failure opens this breaker, and with no
// cooldown an open breaker lets the next delivery
// through as a probe.
cb := delivery.NewTestCircuitBreaker(1, 0)
if tc.tripped {
cb.RecordFailure()
}
s.Engine.ExportSetCircuitBreaker(targetID, cb)
full := &database.Delivery{
EventID: event.ID,
TargetID: targetID,
Status: database.DeliveryStatusPending,
Event: event,
Target: database.Target{
Name: "unwritable",
Type: database.TargetTypeHTTP,
Config: iHTTPConfig(ts.URL),
MaxRetries: 3,
},
}
full.ID = d.ID
sched := &recordingScheduler{}
s.Engine.ExportDeliverHTTPWithScheduler(
context.Background(), s.WebhookDB, full,
&delivery.Task{
DeliveryID: d.ID,
TargetID: targetID,
AttemptNum: 1,
},
sched,
)
iAssertStatus(t, s.WebhookDB, d.ID, database.DeliveryStatusPending)
assert.Empty(t, sched.delays, "no retry may be scheduled")
assert.Equal(t, tc.wantBreaker, cb.State())
})
}
}
+6 -10
View File
@@ -3,7 +3,6 @@ package delivery
import (
"context"
"fmt"
"path/filepath"
"strings"
"sync"
"time"
@@ -277,10 +276,9 @@ func (t *databaseTarget) releaseSweepWriter(
}
// newWriter builds the writer for a database target's archive. The
// file lives beside the webhook's event database in the data
// directory and is named for the webhook and the target as the main
// database has them now; from then on only rename changes the name
// the writer uses. It does not touch the archive file.
// file is the one ArchivePath gives for the webhook and the target as
// the main database names them now; from then on only rename changes
// the name the writer uses. It does not touch the archive file.
func (t *databaseTarget) newWriter(
targetID string,
) (*archiveWriter, error) {
@@ -299,12 +297,10 @@ func (t *databaseTarget) newWriter(
)
}
dir := filepath.Dir(t.eng.dbManager.DBPath(target.WebhookID))
name := ArchiveFileName(
target.Webhook.Name, target.Name, target.ID,
w := newArchiveWriter(
ArchivePath(t.eng.dbManager, &target.Webhook, &target),
t.eng.log,
)
w := newArchiveWriter(filepath.Join(dir, name), t.eng.log)
w.webhookID = target.WebhookID
return w, nil
+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,412 @@
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
}
// exportHeapGrowth exports an archive of rows random bodies, each
// bodySize bytes of base64, and returns how far the heap rose above
// where it stood when the export began, at its highest.
func exportHeapGrowth(t *testing.T, rows, bodySize int) uint64 {
t.Helper()
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()) }()
runtime.GC()
var start runtime.MemStats
runtime.ReadMemStats(&start)
// Through a buffer, the heap is read once per 8 KiB of output
// rather than at each of gzip's small writes, which takes far
// longer.
peak := &heapPeak{max: start.HeapAlloc}
buffered := bufio.NewWriterSize(peak, 8<<10)
require.NoError(t, writeExportTo(t, export, buffered))
require.NoError(t, buffered.Flush())
return peak.max - start.HeapAlloc
}
// TestArchiveExport_Streams proves an export holds neither the archive
// nor its output in memory whole: exporting 384 KiB more of archive
// raises the heap's peak by less than half of that. The export's own
// memory, mostly gzip's compressor, is the same for both archives, so
// it cancels out. 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 raise the peak by at least three quarters of the difference.
//
// The smaller archive has two rows so that its export, too, writes
// out more than the 8 KiB buffer in exportHeapGrowth before it ends:
// the heap must be read while the export's own memory is held.
//
//nolint:paralleltest // It measures the heap, which tests share.
func TestArchiveExport_Streams(t *testing.T) {
const (
bodySize = 16 << 10
smallRows = 2
largeRows = smallRows + 24
limit = (largeRows - smallRows) * bodySize / 2
)
small := exportHeapGrowth(t, smallRows, bodySize)
large := exportHeapGrowth(t, largeRows, bodySize)
assert.Less(t, large, small+limit,
"the heap rose by %d for %d rows and by %d for %d rows",
small, smallRows, large, largeRows,
)
}
// 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),
),
)
}
+6 -4
View File
@@ -17,6 +17,7 @@ import (
_ "modernc.org/sqlite" // Pure Go SQLite driver.
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/gormlog"
)
func archiveTestLogger() *slog.Logger {
@@ -42,7 +43,8 @@ func openArchiveDBForRead(
t.Cleanup(func() { _ = sqlDB.Close() })
gdb, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
sqlite.Dialector{Conn: sqlDB},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
)
require.NoError(t, err)
@@ -191,9 +193,9 @@ func TestArchiveWriter_ReopenDebounce(t *testing.T) {
path, archiveTestLogger(), debounce,
)
// The writer reads the time from this clock, which only the
// test moves, so how long the host takes between writes
// cannot change the result.
// 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 })
+21
View File
@@ -179,6 +179,27 @@ func TestDoHTTPRequest_TransportErrorMasksURL(t *testing.T) {
)
}
// TestDoHTTPRequest_UnparsableURLIsMasked is the same for an HTTP
// target URL that no request can be built from.
func TestDoHTTPRequest_UnparsableURLIsMasked(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
statusCode, _, _, reqErr := e.ExportDoHTTPRequest(
context.TODO(),
&delivery.HTTPTargetConfig{
URL: "https://hooks.example.com" + maskSecretPath + "\n",
},
&database.Event{},
)
require.Error(t, reqErr)
assert.Zero(t, statusCode)
assertNoCredential(t, reqErr.Error())
assert.Contains(t, reqErr.Error(), "invalid control character")
}
// TestValidateTargetURL_UnparsableURLIsMasked proves the SSRF
// validator's error does not carry the submitted URL, which
// the handler both logs and shows.
+3 -2
View File
@@ -117,8 +117,8 @@ func readFirstBootSecrets(
}
// bootAtDebug starts and stops the real application graph against
// dataDir with DEBUG=true, and returns everything it wrote to standard
// output.
// dataDir with DEBUG=true and nothing else set, and returns everything
// it wrote to standard output.
//
// config.New reads DEBUG from the environment exactly as the binary
// does, internal/logger builds the handler it builds in production,
@@ -128,6 +128,7 @@ func readFirstBootSecrets(
func bootAtDebug(t *testing.T, dataDir string) string {
t.Helper()
config.ClearEnvForTest(t)
t.Setenv("DEBUG", "true")
t.Setenv("DATA_DIR", dataDir)
+3 -3
View File
@@ -111,9 +111,9 @@ func (l *Logger) LogMode(gormlogger.LogLevel) gormlogger.Interface {
//
// One GORM path does not consult this: (*gorm.DB).Scan records the
// statement through gorm's own traceRecorder, which does not implement
// this interface. No production code path calls it; its one caller is
// internal/database/database_test.go:91, whose SELECT 1 binds nothing.
// scan_guard_test.go fails if a non-test file calls it.
// this interface. No production code path calls it; only tests do, and
// what a test binds is fixture data. scan_guard_test.go fails if a
// non-test file calls it.
// (*gorm.DB).Pluck, Row and Raw all run through the normal callback
// processor and are filtered.
func (l *Logger) ParamsFilter(
+99 -40
View File
@@ -14,18 +14,16 @@ import (
"github.com/stretchr/testify/require"
)
// minNonTestFiles guards the walk below against passing because it
// found nothing to look at. The tree held 60 non-test .go files when
// this was written.
const minNonTestFiles = 40
// isRowProducer reports whether name is a method that returns a
// database/sql row handle. GORM's Row and Rows return *sql.Row and
// *sql.Rows, so Scan on the result of one of them is database/sql's
// Scan and never (*gorm.DB).Scan.
// isRowProducer reports whether name is GORM's Row or database/sql's
// QueryRow or QueryRowContext, which return a *sql.Row whose Scan is
// database/sql's and not (*gorm.DB).Scan. GORM's Rows is not listed:
// it also returns an error, so Scan is never called on its result
// directly. It matches the method name only and resolves no types, so
// a repo-local method with one of these names that returns *gorm.DB
// gets past it: Scan on that method's result is not reported.
func isRowProducer(name string) bool {
switch name {
case "Row", "Rows", "QueryRow", "QueryRowContext":
case "Row", "QueryRow", "QueryRowContext":
return true
default:
return false
@@ -50,9 +48,14 @@ func receiverIsRowHandle(x ast.Expr) bool {
}
// unguardedScans returns the position of every Scan call in file whose
// receiver is not a row handle. It fails closed: a receiver it cannot
// resolve syntactically — a local variable, a struct field — is
// reported rather than assumed safe.
// receiver is not a call to a row producer. It fails closed: any other
// receiver — a local variable, a struct field, a call to any other
// method — is reported rather than assumed safe.
//
// It sees only calls written x.Scan(...). A method value, f := db.Scan
// followed by f(&v), is out of scope: Scan is never the called
// expression there, and nobody writes a query that way by accident,
// which is the mistake this check exists to catch.
func unguardedScans(
fset *token.FileSet, file *ast.File,
) []token.Position {
@@ -111,15 +114,15 @@ func skipDir(name string) bool {
}
}
// walkNonTestGo parses every non-test .go file under root and returns
// how many it parsed along with every unguarded Scan it found.
func walkNonTestGo(t *testing.T, root string) (int, []string) {
// walkNonTestGo parses every non-test .go file under root. It returns
// the directories, relative to root, it parsed a file in, along with
// every unguarded Scan it found.
func walkNonTestGo(t *testing.T, root string) (map[string]bool, []string) {
t.Helper()
var (
parsed int
hits []string
)
walked := map[string]bool{}
var hits []string
fset := token.NewFileSet()
@@ -147,7 +150,12 @@ func walkNonTestGo(t *testing.T, root string) (int, []string) {
return err
}
parsed++
dir, err := filepath.Rel(root, filepath.Dir(path))
if err != nil {
return err
}
walked[dir] = true
for _, pos := range unguardedScans(fset, file) {
hits = append(hits, relPosition(root, pos))
@@ -157,7 +165,7 @@ func walkNonTestGo(t *testing.T, root string) (int, []string) {
},
))
return parsed, hits
return walked, hits
}
// isNonTestGo reports whether a file name is Go source this check
@@ -189,19 +197,39 @@ func relPosition(root string, pos token.Position) string {
// logged with its values interpolated. The package comment states the
// limit; this fails when someone adds a call site anyway.
//
// The current tree has one caller, internal/database/database_test.go,
// which this check does not govern: it is test-only and its SELECT 1
// binds nothing.
// Test files are not governed: what a test binds is fixture data.
func TestGormScanIsNeverCalledOutsideTests(t *testing.T) {
t.Parallel()
parsed, offenders := walkNonTestGo(t, moduleRoot(t))
root := moduleRoot(t)
walked, offenders := walkNonTestGo(t, root)
// The module's packages are static, templates, and every directory
// directly under cmd and internal. Each holds non-test code, so one
// the walk parsed nothing in was skipped, and a Scan there would
// pass unseen.
packages := []string{"static", "templates"}
for _, parent := range []string{"cmd", "internal"} {
entries, err := os.ReadDir(filepath.Join(root, parent))
require.NoError(t, err)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
packages = append(packages, filepath.Join(parent, entry.Name()))
}
}
for _, dir := range packages {
require.True(
t, walked[dir],
"the walk parsed no non-test .go file in %s", dir,
)
}
require.GreaterOrEqual(
t, parsed, minNonTestFiles,
"parsed %d non-test .go files, so this check found "+
"nothing to look at", parsed,
)
require.Empty(
t, offenders,
"Scan called on a receiver this check cannot show is a "+
@@ -222,18 +250,51 @@ type scanGuardCase struct {
want int
}
// scanGuardCases covers each receiver form unguardedScans names, plus
// each row producer isRowProducer lets through. Each body is valid Go
// inside plantedFile.
func scanGuardCases() []scanGuardCase {
return []scanGuardCase{
{"gorm chain", `db.DB().Raw("SELECT 1").Scan(&v)`, 1},
{"gorm receiver", `gdb.Scan(&v)`, 1},
{"gorm via variable", "q := gdb.Raw(\"x\")\nq.Scan(&v)", 1},
{"gorm model chain", `gdb.Model(&x).Scan(&v)`, 1},
{"sql row", `gdb.Raw("SELECT 1").Row().Scan(&v)`, 0},
{"sql rows", `gdb.Raw("SELECT 1").Rows().Scan(&v)`, 0},
{"local variable", "q := gdb.Raw(\"SELECT 1\")\n\tq.Scan(&v)", 1},
{"struct field", `s.db.Scan(&v)`, 1},
{"gorm chain", `gdb.Raw("SELECT 1").Scan(&v)`, 1},
{
"sql rows in a variable",
"rows, _ := gdb.Raw(\"SELECT 1\").Rows()\n\trows.Scan(&v)",
1,
},
{"gorm Row", `gdb.Raw("SELECT 1").Row().Scan(&v)`, 0},
{"sql QueryRow", `sqlDB.QueryRow("SELECT 1").Scan(&v)`, 0},
{
"sql QueryRowContext",
`sqlDB.QueryRowContext(ctx, "SELECT 1").Scan(&v)`,
0,
},
{"unrelated call", `gdb.Find(&v)`, 0},
}
}
// plantedFile wraps one case body in a function that declares every
// name the bodies use, so each body is the Go it stands for. The result
// is parsed, never compiled.
const plantedFile = `package p
import (
"context"
"database/sql"
"gorm.io/gorm"
)
type store struct{ db *gorm.DB }
func f(ctx context.Context, gdb *gorm.DB, sqlDB *sql.DB, s store) {
var v int
%s
}
`
// TestScanGuard_ReportsPlantedCalls proves the check fires. Without it
// a detector that matched nothing would satisfy the walk above no
// matter what the tree contained.
@@ -245,9 +306,7 @@ func TestScanGuard_ReportsPlantedCalls(t *testing.T) {
t.Parallel()
fset := token.NewFileSet()
src := fmt.Sprintf(
"package p\n\nfunc f() {\n\t%s\n}\n", tc.body,
)
src := fmt.Sprintf(plantedFile, tc.body)
file, err := parser.ParseFile(
fset, tc.name+".go", src, 0,
+1 -2
View File
@@ -139,8 +139,7 @@ func (h *Handlers) renderLoginError(
),
}
w.WriteHeader(status)
h.renderTemplate(w, r, "login.html", data)
h.renderTemplateStatus(w, r, "login.html", data, status)
}
// authenticateUser looks up and verifies a user's credentials.
+55
View File
@@ -3,6 +3,7 @@ package handlers_test
import (
"context"
"fmt"
"html/template"
"net/http"
"net/http/httptest"
"net/url"
@@ -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:
// the success path they assert on really does authenticate.
func TestLogin_SuccessCreatesSession(t *testing.T) {
+6 -4
View File
@@ -15,10 +15,12 @@ import (
// eventBodyQuery reads one event's stored body as bytes. The cast
// to blob is what makes the driver hand back the stored bytes
// rather than a string conversion, so Content-Length taken from
// the result matches what goes on the wire. The soft-delete
// predicate is spelled out because Raw bypasses GORM's default
// scope, and it is what stops a reaped event still being
// downloadable.
// the result matches what goes on the wire. The retention reaper
// deletes event rows outright, so a reaped event is simply gone
// and the query finds no row. The deleted_at predicate repeats
// the soft-delete scope GORM adds to its own queries, which Raw
// bypasses; nothing soft-deletes an event, so today it excludes
// nothing.
const eventBodyQuery = "SELECT cast(body as blob) " +
"FROM events WHERE id = ? AND webhook_id = ? AND deleted_at IS NULL"
+5 -4
View File
@@ -405,10 +405,11 @@ func TestHandleEventBodyDownload_UnknownEvent404s(t *testing.T) {
// route. The body is read in one query before any header is
// written, so a reaped event cannot produce a partial download:
// it is a clean 404 with no Content-Length and no
// Content-Disposition. Both removals the codebase performs are
// covered — the reaper hard-deletes, and a soft-deleted row is
// excluded by the query's own deleted_at predicate rather than
// by GORM's default scope, which Raw bypasses.
// Content-Disposition. The reaper deletes event rows outright,
// which is the "hard deleted" case. The "soft deleted" case
// covers a row no code produces today: it only pins the query's
// own deleted_at predicate, the soft-delete condition Raw would
// otherwise skip.
func TestHandleEventBodyDownload_ReapedEvent404s(t *testing.T) {
t.Parallel()
+3 -2
View File
@@ -145,8 +145,9 @@ func (h *Handlers) resubmitEvent(
// per-webhook database files — a sibling webhook's event is not in the
// database being queried at all — and is there so the scoping survives
// any future change that puts more than one webhook's events in one
// file. Going through Model applies GORM's soft-delete scope, which is
// what stops a reaped event being resubmitted.
// file. A reaped event is not found because the retention reaper
// deletes its row outright rather than marking it deleted; see
// deleteEvents in internal/database/retention.go.
func loadResubmitSource(
webhookDB *gorm.DB,
webhookID, eventID string,
+18 -12
View File
@@ -36,6 +36,15 @@ const MaxRenderedAttemptsForTest = maxRenderedAttempts
// the handlers enforce rather than a number copied beside it.
const MaxTargetRetriesForTest = maxTargetRetries
// EventDBLeftMsgForTest and SidecarLeftMsgForTest expose the two
// messages the webhook delete handler logs when a file of the event
// database is left on disk, so a test checking that one is absent
// checks for the handler's own wording.
const (
EventDBLeftMsgForTest = eventDBLeftMsg
SidecarLeftMsgForTest = sidecarLeftMsg
)
// PageOrFirstForTest exposes pageOrFirst for use in the handlers_test
// package.
func PageOrFirstForTest(s string) int {
@@ -128,22 +137,20 @@ func (s *Handlers) RenderTemplateForTest(
// BuildSlackTargetConfigForTest exposes
// buildSlackTargetConfig for use in the handlers_test package.
func (s *Handlers) BuildSlackTargetConfigForTest(
w http.ResponseWriter,
r *http.Request,
ctx context.Context,
targetURL string,
) (string, error) {
return s.buildSlackTargetConfig(w, r, targetURL)
) (string, string, error) {
return s.buildSlackTargetConfig(ctx, targetURL)
}
// BuildHTTPTargetConfigForTest exposes buildHTTPTargetConfig
// for use in the handlers_test package, taking the form fields
// an HTTP target's configuration is built from.
func (s *Handlers) BuildHTTPTargetConfigForTest(
w http.ResponseWriter,
r *http.Request,
ctx context.Context,
targetURL, headers, timeout string,
) (string, error) {
return s.buildHTTPTargetConfig(w, r, targetFormInput{
) (string, string, error) {
return s.buildHTTPTargetConfig(ctx, targetFormInput{
URL: targetURL,
Headers: headers,
Timeout: timeout,
@@ -153,9 +160,8 @@ func (s *Handlers) BuildHTTPTargetConfigForTest(
// BuildDatabaseTargetConfigForTest exposes
// buildDatabaseTargetConfig for use in the handlers_test
// package.
func (s *Handlers) BuildDatabaseTargetConfigForTest(
w http.ResponseWriter,
func BuildDatabaseTargetConfigForTest(
expiry string,
) (string, error) {
return s.buildDatabaseTargetConfig(w, newRequestForTest(), expiry)
) (string, string, error) {
return buildDatabaseTargetConfig(expiry)
}
+30 -11
View File
@@ -97,7 +97,8 @@ type Handlers struct {
// 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.
// 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
@@ -309,12 +310,26 @@ func (s *Handlers) getUserInfo(
}
// renderTemplate renders a pre-parsed template with common
// data
// data and answers 200.
func (s *Handlers) renderTemplate(
w http.ResponseWriter,
r *http.Request,
pageTemplate string,
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]
if !ok {
@@ -327,7 +342,9 @@ func (s *Handlers) renderTemplate(
return
}
s.executeTemplate(w, r, tmpl, s.pageData(r, data, noticeFor(r)))
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
@@ -363,19 +380,20 @@ func (s *Handlers) pageData(
}
}
// executeTemplate renders the template into a buffer and writes to
// the response only once rendering has fully succeeded. Executing
// straight into the ResponseWriter commits a partial body and a 200
// status before a mid-render error can be reported, leaving no way
// to serve a 500. Buffering makes a page's rendered size resident
// memory per concurrent viewer, so every page owes it a bound: the
// event log caps each stored body at maxRenderedBodyBytes for exactly
// this reason.
// executeTemplate renders the template into a buffer and writes status
// and the page to the response only once rendering has fully
// succeeded. Executing straight into the ResponseWriter commits a
// partial body and the status before a mid-render error can be
// reported, leaving no way to serve a 500. Buffering makes a page's
// rendered size resident memory per concurrent viewer, so every page
// owes it a bound: the event log caps each stored body at
// maxRenderedBodyBytes for exactly this reason.
func (s *Handlers) executeTemplate(
w http.ResponseWriter,
r *http.Request,
tmpl *template.Template,
data any,
status int,
) {
var buf bytes.Buffer
@@ -390,6 +408,7 @@ func (s *Handlers) executeTemplate(
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, err = buf.WriteTo(w)
if err != nil {
+19 -42
View File
@@ -314,16 +314,12 @@ func TestBuildSlackTargetConfig_AcceptsPublicURL(t *testing.T) {
t.Cleanup(app.RequireStop)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost, "/", nil)
w := httptest.NewRecorder()
cfg, err := h.BuildSlackTargetConfigForTest(
w, req, "http://93.184.216.34/services/T00/B00/xxx",
cfg, errMsg, err := h.BuildSlackTargetConfigForTest(
t.Context(), "http://93.184.216.34/services/T00/B00/xxx",
)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, w.Code)
assert.Empty(t, errMsg)
assert.Contains(t, cfg, "webhookUrl")
}
@@ -337,17 +333,13 @@ func TestBuildSlackTargetConfig_RejectsReservedURL(t *testing.T) {
t.Cleanup(app.RequireStop)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost, "/", nil)
w := httptest.NewRecorder()
cfg, err := h.BuildSlackTargetConfigForTest(
w, req, "http://169.254.169.254/latest/meta-data/",
cfg, errMsg, err := h.BuildSlackTargetConfigForTest(
t.Context(), "http://169.254.169.254/latest/meta-data/",
)
require.Error(t, err)
require.NoError(t, err)
assert.Contains(t, errMsg, "Invalid target URL")
assert.Empty(t, cfg)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestRenderTemplate(t *testing.T) {
@@ -444,29 +436,22 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
// Empty expiry: the keep-forever default, empty config.
w := httptest.NewRecorder()
cfg, err := h.BuildDatabaseTargetConfigForTest(w, "")
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("")
require.NoError(t, err)
assert.Empty(t, errMsg)
assert.Empty(t, cfg)
// Explicit never is stored as config.
w = httptest.NewRecorder()
cfg, err = h.BuildDatabaseTargetConfigForTest(w, "never")
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("never")
require.NoError(t, err)
assert.Empty(t, errMsg)
assert.JSONEq(t, `{"expiry":"never"}`, cfg)
// A positive duration is stored as config.
w = httptest.NewRecorder()
cfg, err = h.BuildDatabaseTargetConfigForTest(w, "720h")
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("720h")
require.NoError(t, err)
assert.Empty(t, errMsg)
assert.JSONEq(t, `{"expiry":"720h"}`, cfg)
}
@@ -475,22 +460,14 @@ func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
) {
t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
for _, bad := range []string{"nonsense", "7d", "-5h"} {
w := httptest.NewRecorder()
cfg, err := h.BuildDatabaseTargetConfigForTest(w, bad)
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest(bad)
require.Error(t, err, "expiry %q", bad)
assert.Empty(t, cfg)
assert.Equal(
t, http.StatusBadRequest, w.Code,
"expiry %q should be rejected with 400", bad,
require.NoError(t, err)
assert.Contains(
t, errMsg, "Invalid archive expiry",
"expiry %q should be refused", bad,
)
assert.Empty(t, cfg)
}
}
+2 -1
View File
@@ -77,7 +77,8 @@ func settingRows(cfg *config.Config) []settingRow {
{
"RETENTION_SWEEP_INTERVAL",
"How often the retention reaper and archive sweeper run " +
"(Go duration, must be positive)",
"(Go duration, must be positive). A value that does " +
"not parse, or is zero or negative, fails startup",
cfg.RetentionSweepInterval.String(),
},
{
+117
View File
@@ -1,8 +1,10 @@
package handlers_test
import (
"bytes"
"context"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"os"
@@ -466,6 +468,121 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
)
}
// TestHandleSourceDelete_LeftoverSidecar proves that when the event
// database file is removed but a sidecar beside it is not, the
// operator is told the events are gone, never that the event
// database file is still there.
func TestHandleSourceDelete_LeftoverSidecar(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
mgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &mgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
logs := new(bytes.Buffer)
h.SetLogForTest(slog.New(slog.NewTextHandler(logs, nil)))
wh := seedWebhook(t, db)
require.NoError(t, mgr.CreateDB(wh.ID))
// Closing removes the sidecars, so the -wal below is the only
// one there.
require.NoError(t, mgr.CloseAll())
// A non-empty directory in the -wal file's place, which
// os.Remove cannot remove whoever runs the test.
eventDBPath := mgr.DBPath(wh.ID)
require.NoError(t, os.MkdirAll(
filepath.Join(eventDBPath+"-wal", "keep"), 0o700,
))
cookies := authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
)
req := postRequest(
"/hook/"+wh.ID+"/delete",
cookies,
map[string]string{paramSourceID: wh.ID},
)
w := httptest.NewRecorder()
h.HandleSourceDelete().ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.NoFileExists(t, eventDBPath)
assert.Contains(t, logs.String(), "its events are gone")
assert.Contains(t, logs.String(), eventDBPath+"-wal")
assert.NotContains(
t, logs.String(), handlers.EventDBLeftMsgForTest,
"the events are gone, so the operator must not be told "+
"the event database file survived",
)
}
// TestHandleSourceDelete_LeftoverDatabaseFile proves that when the
// event database file itself cannot be removed, the operator is told
// it is still on disk, never that its events are gone.
func TestHandleSourceDelete_LeftoverDatabaseFile(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
mgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &mgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
logs := new(bytes.Buffer)
h.SetLogForTest(slog.New(slog.NewTextHandler(logs, nil)))
wh := seedWebhook(t, db)
// A non-empty directory in the database file's place, which
// os.Remove cannot remove whoever runs the test.
eventDBPath := mgr.DBPath(wh.ID)
require.NoError(t, os.MkdirAll(
filepath.Join(eventDBPath, "keep"), 0o700,
))
cookies := authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
)
req := postRequest(
"/hook/"+wh.ID+"/delete",
cookies,
map[string]string{paramSourceID: wh.ID},
)
w := httptest.NewRecorder()
h.HandleSourceDelete().ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(
t, logs.String(), "event database file is still on disk",
)
assert.Contains(t, logs.String(), eventDBPath)
assert.NotContains(
t, logs.String(), handlers.SidecarLeftMsgForTest,
"the database file is still on disk, so the operator must "+
"not be told its events are gone",
)
}
// TestHandleTargetDelete_EvictsThatTarget proves that deleting a
// database target releases that target's archive writer and no
// other: the webhook's other database target keeps its own.
+198 -220
View File
@@ -1,6 +1,7 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
@@ -38,74 +39,51 @@ type WebhookListItem struct {
EventsUnreadable bool
}
// errMissingURL signals that a required URL was not provided.
var errMissingURL = errors.New("missing URL")
// errInvalidRetention signals a retention_days form value that is not
// a non-negative whole number.
var errInvalidRetention = errors.New("invalid retention days")
// errRetentionTooLarge signals a retention_days form value that is a
// whole number but larger than the reaper's cutoff arithmetic can
// represent. It is distinguished from errInvalidRetention so the form
// can tell the user the actual ceiling instead of implying their input
// was not a number.
var errRetentionTooLarge = errors.New("retention days out of range")
// retentionErrorMessage returns the message the create and edit forms
// show the user for a rejected retention_days value. Any error other
// than errRetentionTooLarge falls back to the generic wording, so an
// unrecognised parse failure still produces a sensible 400 rather than
// an empty alert.
func retentionErrorMessage(err error) string {
if errors.Is(err, errRetentionTooLarge) {
return "Retention must be at most " +
strconv.Itoa(database.MaxFiniteRetentionDays) +
" days, or 0 to retain events forever."
}
return "Retention must be a whole number of days, or 0 to " +
"retain events forever."
}
// parseRetentionDays interprets a retention_days form value.
// parseRetentionDays interprets a retention_days form value. It
// returns the number of days, or, for a value it refuses, the message
// the create and edit forms show; the message is empty when the value
// is accepted.
//
// An empty value yields fallback, which lets the create path apply the
// default and the edit path leave the stored value unchanged. A value
// of 0 is returned as 0 and is rewritten to the retain-forever
// sentinel by database.Webhook's BeforeSave hook. Anything unparseable
// or negative is an error rather than a silently substituted default.
// or negative is refused rather than silently given a default.
//
// The upper bound is not cosmetic. The reaper computes its cutoff as a
// time.Duration, an int64 nanosecond count, so a day count above
// database.MaxFiniteRetentionDays overflows, puts the cutoff in the
// future, and deletes every event the webhook has. A finite value
// above that ceiling is therefore a 400.
// above that ceiling is therefore refused, and the message names the
// ceiling rather than implying the input was not a number.
//
// A value at or above the retain-forever sentinel is not out of range:
// it is what the edit form pre-fills for a retain-forever webhook, so
// submitting the form back unchanged has to keep meaning "forever"
// rather than being rejected.
func parseRetentionDays(raw string, fallback int) (int, error) {
func parseRetentionDays(raw string, fallback int) (int, string) {
raw = strings.TrimSpace(raw)
if raw == "" {
return fallback, nil
return fallback, ""
}
v, err := strconv.Atoi(raw)
if err != nil || v < 0 {
return 0, errInvalidRetention
return 0, "Retention must be a whole number of days, or 0 to " +
"retain events forever."
}
if v >= database.RetentionForeverDays {
return database.RetentionForeverDays, nil
return database.RetentionForeverDays, ""
}
if v > database.MaxFiniteRetentionDays {
return 0, errRetentionTooLarge
return 0, "Retention must be at most " +
strconv.Itoa(database.MaxFiniteRetentionDays) +
" days, or 0 to retain events forever."
}
return v, nil
return v, ""
}
// DeliveryView is the display-safe projection of a delivery
@@ -350,28 +328,25 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
retentionStr := r.PostFormValue("retention_days")
if name == "" {
w.WriteHeader(http.StatusBadRequest)
h.renderTemplate(
h.renderTemplateStatus(
w, r, "sources_new.html",
newSourceFormData(
"Name is required", name, description,
),
http.StatusBadRequest,
)
return
}
retentionDays, retErr := parseRetentionDays(
retentionDays, errMsg := parseRetentionDays(
retentionStr, database.DefaultRetentionDays,
)
if retErr != nil {
w.WriteHeader(http.StatusBadRequest)
h.renderTemplate(
if errMsg != "" {
h.renderTemplateStatus(
w, r, "sources_new.html",
newSourceFormData(
retentionErrorMessage(retErr),
name, description,
),
newSourceFormData(errMsg, name, description),
http.StatusBadRequest,
)
return
@@ -483,15 +458,20 @@ func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
return
}
h.renderSourceDetail(w, r, webhook)
h.renderSourceDetail(w, r, webhook, targetFormInput{}, "")
}
}
// renderSourceDetail loads and renders a source detail page.
// renderSourceDetail loads and renders a source detail page. With a
// targetErr, it is the page shown again for a refused add target
// form: it answers 400, and the form opens on targetForm's type with
// its values and the message.
func (h *Handlers) renderSourceDetail(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
targetForm targetFormInput,
targetErr string,
) {
var entrypoints []database.Entrypoint
@@ -548,9 +528,16 @@ func (h *Handlers) renderSourceDetail(
"Events": events,
"BaseURL": baseURL,
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
"TargetForm": targetForm,
"TargetError": targetErr,
}
h.renderTemplate(w, r, "source_detail.html", data)
status := http.StatusOK
if targetErr != "" {
status = http.StatusBadRequest
}
h.renderTemplateStatus(w, r, "source_detail.html", data, status)
}
// HandleSourceEdit shows the form to edit a webhook.
@@ -644,8 +631,7 @@ func (h *Handlers) applyWebhookEdit(
tmplKeyError: "Name is required",
}
w.WriteHeader(http.StatusBadRequest)
h.renderTemplate(w, r, "source_edit.html", data)
h.renderTemplateStatus(w, r, "source_edit.html", data, http.StatusBadRequest)
return
}
@@ -656,17 +642,16 @@ func (h *Handlers) applyWebhookEdit(
// An empty field falls back to the stored value, so submitting the
// form without touching retention leaves the policy alone.
retentionDays, retErr := parseRetentionDays(
retentionDays, errMsg := parseRetentionDays(
r.PostFormValue("retention_days"), webhook.RetentionDays,
)
if retErr != nil {
if errMsg != "" {
data := map[string]any{
tmplKeyWebhook: webhook,
tmplKeyError: retentionErrorMessage(retErr),
tmplKeyError: errMsg,
}
w.WriteHeader(http.StatusBadRequest)
h.renderTemplate(w, r, "source_edit.html", data)
h.renderTemplateStatus(w, r, "source_edit.html", data, http.StatusBadRequest)
return
}
@@ -703,8 +688,7 @@ func (h *Handlers) applyWebhookEdit(
"it, then save again.",
}
w.WriteHeader(http.StatusConflict)
h.renderTemplate(w, r, "source_edit.html", data)
h.renderTemplateStatus(w, r, "source_edit.html", data, http.StatusConflict)
return
}
@@ -749,6 +733,17 @@ func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
}
}
// The messages deleteWebhookResources logs when a file of the event
// database cannot be removed: the database file itself, or only a
// sidecar once the database file is gone.
const (
eventDBLeftMsg = "webhook deleted, but its event database file is " +
"still on disk; remove it by hand"
sidecarLeftMsg = "webhook deleted and its events are gone, but a " +
"-wal or -shm sidecar of its event database is " +
"still on disk; remove it by hand"
)
// deleteWebhookResources soft-deletes config and hard-deletes
// the per-webhook event database.
func (h *Handlers) deleteWebhookResources(
@@ -788,13 +783,18 @@ func (h *Handlers) deleteWebhookResources(
err = h.dbMgr.DeleteDB(webhook.ID)
if err != nil {
// The configuration is committed, so the webhook is gone,
// but its event database file is still on disk with
// but a file of its event database is still on disk with
// nothing referencing it. Report the failure rather than
// redirecting as though everything succeeded: the file
// needs removing by hand, and the logged error names it.
h.serverError(
w, r, "failed to delete webhook event database", err,
)
// When only a sidecar is left, the events are already
// gone, and the message must not suggest they survive.
msg := eventDBLeftMsg
if errors.Is(err, database.ErrSidecarNotRemoved) {
msg = sidecarLeftMsg
}
h.serverError(w, r, msg, err)
return
}
@@ -1447,64 +1447,27 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
}
}
// processTargetCreate validates and creates a new target.
// processTargetCreate validates and creates a new target. A refused
// submission shows the webhook page again, with the add target form
// open on the chosen type, the values entered, and the reason.
func (h *Handlers) processTargetCreate(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
) {
// The body size cap is enforced by the MaxBodySize middleware,
// which runs before CSRF parses the form.
//
// Every field here is read with PostFormValue, not FormValue.
// FormValue falls back to the query string, which would let
// `POST /hook/{id}/targets?url=https://hooks.slack.com/...`
// configure a target from a value the request line carries — and
// the request line, unlike the body, is what logs, proxies,
// Referer headers and error trackers record.
name := r.PostFormValue("name")
targetType := database.TargetType(r.PostFormValue("type"))
in := targetFormInputFrom(r)
if name == "" {
http.Error(
w, "Name is required", http.StatusBadRequest,
)
return
}
if !isValidTargetType(targetType) {
http.Error(
w, "Invalid target type",
http.StatusBadRequest,
)
return
}
configJSON, err := h.buildTargetConfig(
w, r, targetType, targetFormInputFrom(r),
)
target, errMsg, err := h.newTarget(r.Context(), webhook.ID, in)
if err != nil {
h.serverError(w, r, "failed to encode target config", err)
return
}
// A new target has no stored retry count, so an absent field
// takes the fire-and-forget default. A field the operator filled
// in with something invalid is rejected rather than becoming
// that default.
maxRetries, ok := targetMaxRetries(w, r, 0)
if !ok {
return
}
if errMsg != "" {
h.renderSourceDetail(w, r, webhook, in, errMsg)
target := &database.Target{
WebhookID: webhook.ID,
Name: name,
Type: targetType,
Active: true,
Config: configJSON,
MaxRetries: maxRetries,
return
}
err = h.db.DB().Create(target).Error
@@ -1520,6 +1483,49 @@ func (h *Handlers) processTargetCreate(
)
}
// newTarget validates a new target for a webhook and returns the row
// to create, or, when it refuses the target, the message the form
// shows. An error is the server's fault, not a refusal: the accepted
// configuration could not be encoded. Every form that creates a
// target goes through here, so they all accept and refuse the same
// things.
func (h *Handlers) newTarget(
ctx context.Context,
webhookID string,
in targetFormInput,
) (*database.Target, string, error) {
if in.Name == "" {
return nil, "Name is required", nil
}
if !isValidTargetType(in.Type) {
return nil, "Invalid target type", nil
}
configJSON, errMsg, err := h.buildTargetConfig(ctx, in.Type, in)
if err != nil || errMsg != "" {
return nil, errMsg, err
}
// A new target has no stored retry count, so an absent field
// takes the fire-and-forget default. A field the operator filled
// in with something invalid is refused rather than becoming
// that default.
maxRetries, err := parseMaxRetries(in.MaxRetries, 0)
if err != nil {
return nil, "Invalid max retries: " + retriesErrorMessage(err), nil
}
return &database.Target{
WebhookID: webhookID,
Name: in.Name,
Type: in.Type,
Active: true,
Config: configJSON,
MaxRetries: maxRetries,
}, "", nil
}
// isValidTargetType checks whether the target type is supported.
func isValidTargetType(tt database.TargetType) bool {
switch tt {
@@ -1551,11 +1557,16 @@ func pageOrFirst(s string) int {
return v
}
// targetFormInput carries the raw form values describing a target's
// configuration. Both the create and the edit path fill one and hand
// it to buildTargetConfig, so neither can come to validate a
// destination differently from the other.
// targetFormInput carries the raw values of a target form. Both the
// create and the edit path fill one and hand it to buildTargetConfig,
// so neither can come to validate a destination differently from the
// other. A refused add target form is shown again from it.
type targetFormInput struct {
// Name is the target's name.
Name string
// Type is the type chosen on the add target form. The edit form
// has none: a target's stored type decides.
Type database.TargetType
// URL is the destination for an HTTP target and the webhook URL
// for a Slack target.
URL string
@@ -1564,13 +1575,15 @@ type targetFormInput struct {
Headers string
// Timeout is an HTTP target's per-request timeout in seconds.
Timeout string
// MaxRetries is an HTTP or Slack target's max_retries.
MaxRetries string
// Expiry is a database (archive) target's row expiry.
Expiry string
}
// targetFormInputFrom reads the configuration fields from a request
// body. The body size cap is enforced by the MaxBodySize middleware,
// which runs before CSRF parses the form.
// targetFormInputFrom reads a target form from a request body. The
// body size cap is enforced by the MaxBodySize middleware, which runs
// before CSRF parses the form.
//
// Every field is read with PostFormValue, not FormValue. FormValue
// falls back to the query string, which would let
@@ -1582,38 +1595,38 @@ type targetFormInput struct {
// tokens.
func targetFormInputFrom(r *http.Request) targetFormInput {
return targetFormInput{
URL: r.PostFormValue("url"),
Headers: r.PostFormValue("headers"),
Timeout: r.PostFormValue("timeout"),
Expiry: r.PostFormValue("expiry"),
Name: r.PostFormValue("name"),
Type: database.TargetType(r.PostFormValue("type")),
URL: r.PostFormValue("url"),
Headers: r.PostFormValue("headers"),
Timeout: r.PostFormValue("timeout"),
MaxRetries: r.PostFormValue("max_retries"),
Expiry: r.PostFormValue("expiry"),
}
}
// buildTargetConfig builds the JSON config string for a target from
// the submitted form values, writing its own 4xx response on
// rejection. Which fields of in apply depends on the target type.
// the submitted form values, or returns the message the form shows
// for a value it refuses. An error is the server's fault, not a
// refusal: the accepted configuration could not be encoded. Which
// fields of in apply depends on the target type; a type without a URL
// ignores any URL submitted.
func (h *Handlers) buildTargetConfig(
w http.ResponseWriter,
r *http.Request,
ctx context.Context,
targetType database.TargetType,
in targetFormInput,
) (string, error) {
) (string, string, error) {
switch targetType {
case database.TargetTypeHTTP:
return h.buildHTTPTargetConfig(w, r, in)
return h.buildHTTPTargetConfig(ctx, in)
case database.TargetTypeSlack:
return h.buildSlackTargetConfig(w, r, in.URL)
return h.buildSlackTargetConfig(ctx, in.URL)
case database.TargetTypeDatabase:
return h.buildDatabaseTargetConfig(w, r, in.Expiry)
return buildDatabaseTargetConfig(in.Expiry)
case database.TargetTypeLog:
return "", nil
return "", "", nil
default:
http.Error(
w, "Invalid target type",
http.StatusBadRequest,
)
return "", errMissingURL
return "", "Invalid target type", nil
}
}
@@ -1621,92 +1634,73 @@ func (h *Handlers) buildTargetConfig(
// SSRF-validated destination plus the optional headers and timeout
// the delivery path honours.
func (h *Handlers) buildHTTPTargetConfig(
w http.ResponseWriter,
r *http.Request,
ctx context.Context,
in targetFormInput,
) (string, error) {
err := h.validateTargetURL(
w, r, in.URL, "URL is required for HTTP targets",
) (string, string, error) {
errMsg := h.validateTargetURL(
ctx, in.URL, "URL is required for HTTP targets",
)
if err != nil {
return "", err
if errMsg != "" {
return "", errMsg, nil
}
headers, err := delivery.ParseTargetHeaders(in.Headers)
if err != nil {
http.Error(
w,
"Invalid headers: "+err.Error(),
http.StatusBadRequest,
)
return "", err
return "", fmt.Sprintf("Invalid headers: %v", err), nil
}
timeout, err := delivery.ParseTargetTimeout(in.Timeout)
if err != nil {
http.Error(
w,
"Invalid timeout: "+err.Error(),
http.StatusBadRequest,
)
return "", err
return "", fmt.Sprintf("Invalid timeout: %v", err), nil
}
return h.marshalTargetConfig(w, r, delivery.HTTPTargetConfig{
configJSON, err := marshalTargetConfig(delivery.HTTPTargetConfig{
URL: in.URL,
Headers: headers,
Timeout: timeout,
})
return configJSON, "", err
}
// buildSlackTargetConfig builds config JSON for a Slack target,
// whose whole configuration is one SSRF-validated webhook URL.
func (h *Handlers) buildSlackTargetConfig(
w http.ResponseWriter,
r *http.Request,
ctx context.Context,
targetURL string,
) (string, error) {
err := h.validateTargetURL(
w, r, targetURL,
) (string, string, error) {
errMsg := h.validateTargetURL(
ctx, targetURL,
"Webhook URL is required for Slack targets",
)
if err != nil {
return "", err
if errMsg != "" {
return "", errMsg, nil
}
return h.marshalTargetConfig(w, r, delivery.SlackTargetConfig{
configJSON, err := marshalTargetConfig(delivery.SlackTargetConfig{
WebhookURL: targetURL,
})
return configJSON, "", err
}
// validateTargetURL rejects an empty or SSRF-blocked destination,
// writing the 400 itself. missingMsg is the error shown when no URL
// is given.
// validateTargetURL refuses an empty or SSRF-blocked destination,
// returning the message the form shows, or "" when the destination
// is accepted. missingMsg is the message for no URL at all.
//
// It is the single point at which a user-supplied destination enters
// the SSRF guard, on create and on edit alike. An edit path that
// reached storage without passing through here would reopen the hole
// the guard closes.
func (h *Handlers) validateTargetURL(
w http.ResponseWriter,
r *http.Request,
ctx context.Context,
targetURL, missingMsg string,
) error {
) string {
if targetURL == "" {
http.Error(
w,
missingMsg,
http.StatusBadRequest,
)
return errMissingURL
return missingMsg
}
err := h.ssrf.ValidateTargetURL(
r.Context(), targetURL,
)
err := h.ssrf.ValidateTargetURL(ctx, targetURL)
if err != nil {
// The submitted URL can be a credential (a Slack
// incoming webhook URL is a bearer token), so the log
@@ -1732,25 +1726,16 @@ func (h *Handlers) validateTargetURL(
"egress to your own network\" in the README)."
}
http.Error(w, msg, http.StatusBadRequest)
return err
return msg
}
return nil
return ""
}
// marshalTargetConfig serialises a target configuration for storage,
// writing a 500 itself if it cannot.
func (h *Handlers) marshalTargetConfig(
w http.ResponseWriter,
r *http.Request,
cfg any,
) (string, error) {
// marshalTargetConfig serialises a target configuration for storage.
func marshalTargetConfig(cfg any) (string, error) {
configBytes, err := json.Marshal(cfg)
if err != nil {
h.serverError(w, r, "failed to encode target config", err)
return "", err
}
@@ -1758,35 +1743,24 @@ func (h *Handlers) marshalTargetConfig(
}
// buildDatabaseTargetConfig builds config JSON for a database
// (archive) target. The optional expiry (a form value read by
// the caller, which bounds the request body) is validated here,
// at creation time, so an unparseable value is rejected with a
// 400 instead of failing every subsequent delivery. An empty
// expiry yields an empty config (the keep-forever default).
func (h *Handlers) buildDatabaseTargetConfig(
w http.ResponseWriter,
r *http.Request,
expiry string,
) (string, error) {
// (archive) target. The optional expiry is validated here, at
// creation time, so an unparseable value is refused instead of
// failing every subsequent delivery. An empty expiry yields an
// empty config (the keep-forever default).
func buildDatabaseTargetConfig(expiry string) (string, string, error) {
expiry = strings.TrimSpace(expiry)
if expiry == "" {
return "", nil
return "", "", nil
}
err := delivery.ValidateArchiveExpiry(expiry)
if err != nil {
http.Error(
w,
"Invalid archive expiry: "+err.Error(),
http.StatusBadRequest,
)
return "", err
return "", fmt.Sprintf("Invalid archive expiry: %v", err), nil
}
return h.marshalTargetConfig(
w, r, map[string]any{"expiry": expiry},
)
configJSON, err := marshalTargetConfig(map[string]any{"expiry": expiry})
return configJSON, "", err
}
// HandleEntrypointDelete handles deleting an entrypoint.
@@ -1911,9 +1885,13 @@ func (h *Handlers) HandleTargetToggle() http.HandlerFunc {
return false, err
}
tgt.Active = !tgt.Active
// Only the active column: saving the whole row would
// write back the name and settings read above over an
// edit saved since.
active := !tgt.Active
return tgt.Active, h.db.DB().Save(&tgt).Error
return active, h.db.DB().Model(&tgt).
Update("active", active).Error
},
"failed to toggle target",
targetActivated, targetDeactivated,
+35 -18
View File
@@ -191,6 +191,7 @@ func storedRetentionDays(
type sourceTestEnv struct {
handlers *handlers.Handlers
db *database.Database
dbMgr *database.WebhookDBManager
archives *recordingArchives
cookies []*http.Cookie
}
@@ -204,9 +205,11 @@ func setupSourceTest(t *testing.T) *sourceTestEnv {
var db *database.Database
var dbMgr *database.WebhookDBManager
var archives *recordingArchives
app := newTestApp(t, &h, &sess, &db, &archives)
app := newTestApp(t, &h, &sess, &db, &dbMgr, &archives)
app.RequireStart()
t.Cleanup(app.RequireStop)
@@ -214,6 +217,7 @@ func setupSourceTest(t *testing.T) *sourceTestEnv {
return &sourceTestEnv{
handlers: h,
db: db,
dbMgr: dbMgr,
archives: archives,
cookies: authenticatedCookies(
t, sess, sourceTestUserID, "sourceuser",
@@ -368,31 +372,42 @@ func TestHandleSourceCreateSubmit_OverflowingRetentionIsRejected(
// boundary between "too large to represent" and "retain forever": the
// sentinel is above MaxFiniteRetentionDays, but it is the value the
// edit form pre-fills, so it must be accepted rather than rejected as
// out of range.
// out of range. A value above the sentinel is stored as the sentinel.
func TestHandleSourceCreateSubmit_SentinelIsAcceptedAsForever(
t *testing.T,
) {
t.Parallel()
env := setupSourceTest(t)
sentinel := strconv.Itoa(database.RetentionForeverDays)
w := submitCreate(t, env.handlers, env.cookies, "forever", &sentinel)
require.Equal(t, http.StatusSeeOther, w.Code)
wh := onlyWebhook(t, env.db)
assert.Equal(
t,
for _, days := range []int{
database.RetentionForeverDays,
storedRetentionDays(t, env.db, wh.ID),
)
database.RetentionForeverDays + 1,
} {
raw := strconv.Itoa(days)
t.Run(raw, func(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
w := submitCreate(t, env.handlers, env.cookies, "forever", &raw)
require.Equal(t, http.StatusSeeOther, w.Code)
wh := onlyWebhook(t, env.db)
assert.Equal(
t,
database.RetentionForeverDays,
storedRetentionDays(t, env.db, wh.ID),
)
})
}
}
// TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput checks that a
// validation failure hands the user's typing back, matching what the
// edit form already does. Losing a long description to a mistyped
// retention value is the kind of thing that makes people give up on a
// form.
// form. Both values carry HTML-special characters, which must come
// back escaped rather than as markup.
func TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput(
t *testing.T,
) {
@@ -401,8 +416,8 @@ func TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput(
env := setupSourceTest(t)
const (
name = "kept-name"
description = "a description worth not losing"
name = `kept"><b>name`
description = `a </textarea> worth not losing`
)
form := url.Values{}
@@ -419,8 +434,10 @@ func TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput(
body := w.Body.String()
assert.Contains(t, body, `value="`+name+`"`)
assert.Contains(t, body, description)
assert.Contains(t, body, `value="kept&#34;&gt;&lt;b&gt;name"`)
assert.Contains(t, body, `a &lt;/textarea&gt; worth not losing`)
assert.NotContains(t, body, name)
assert.NotContains(t, body, description)
}
// submitEdit posts the webhook edit form for the given webhook.
+154
View File
@@ -0,0 +1,154 @@
package handlers_test
import (
"html"
"net/http"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
)
// TestHandleTargetCreate_EveryType adds a target of each type. Each
// submission carries a url: only the http and slack types store one.
func TestHandleTargetCreate_EveryType(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
// fields is the rest of each submission, as a query string.
cases := []struct {
targetType database.TargetType
fields string
wantConfig string
wantRetries int
}{
{
database.TargetTypeHTTP, "timeout=12&max_retries=3",
`{"url":"` + editOriginalURL + `","timeout":12}`, 3,
},
{
database.TargetTypeSlack, "max_retries=4",
`{"webhookUrl":"` + editOriginalURL + `"}`, 4,
},
{
database.TargetTypeDatabase, "expiry=720h",
`{"expiry":"720h"}`, 0,
},
{database.TargetTypeLog, "", "", 0},
}
for _, tc := range cases {
t.Run(string(tc.targetType), func(t *testing.T) {
t.Parallel()
webhook := seedWebhookWithRetention(t, env.db, 30)
form, err := url.ParseQuery(tc.fields)
require.NoError(t, err)
form.Set("name", "every-type")
form.Set("type", string(tc.targetType))
form.Set("url", editOriginalURL)
w := serveTarget(
env, http.MethodPost,
"/hook/"+webhook.ID+"/targets", form,
)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.Equal(t, tc.targetType, targets[0].Type)
assert.Equal(t, tc.wantRetries, targets[0].MaxRetries)
if tc.wantConfig == "" {
assert.Empty(t, targets[0].Config)
} else {
assert.JSONEq(t, tc.wantConfig, targets[0].Config)
}
})
}
}
// TestHandleTargetCreate_RefusedFormComesBack refuses a target of each
// type and checks that the webhook page comes back with the add target
// form open on that type, the values entered, and the reason.
func TestHandleTargetCreate_RefusedFormComesBack(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
// fields is what the operator typed, as a query string.
cases := []struct {
targetType database.TargetType
fields string
reason string
}{
{
database.TargetTypeHTTP,
"name=private&url=" + editBlockedURL +
"&timeout=12&max_retries=3",
"Invalid target URL",
},
{
database.TargetTypeSlack, "name=no-url&max_retries=4",
"Webhook URL is required for Slack targets",
},
{
database.TargetTypeDatabase, "name=archive&expiry=7d",
"Invalid archive expiry",
},
{database.TargetTypeLog, "name=", "Name is required"},
}
for _, tc := range cases {
t.Run(string(tc.targetType), func(t *testing.T) {
t.Parallel()
webhook := seedWebhookWithRetention(t, env.db, 30)
typed, err := url.ParseQuery(tc.fields)
require.NoError(t, err)
form := url.Values{}
form.Set("type", string(tc.targetType))
for field := range typed {
form.Set(field, typed.Get(field))
}
w := serveTarget(
env, http.MethodPost,
"/hook/"+webhook.ID+"/targets", form,
)
assert.Equal(t, http.StatusBadRequest, w.Code)
page := w.Body.String()
assert.Contains(
t, page, `data-type="`+string(tc.targetType)+`"`,
)
assert.Contains(t, page, html.EscapeString(tc.reason))
// Each value comes back in a data attribute of the targets
// section named after its field (max_retries as
// data-max-retries), except url, which comes back in
// data-destination; templates/source_detail.html says why.
for field := range typed {
attr := "data-" + strings.ReplaceAll(field, "_", "-")
if field == "url" {
attr = "data-destination"
}
assert.Contains(
t, page, attr+`="`+
html.EscapeString(typed.Get(field))+`"`,
)
}
assert.Empty(t, targetsForWebhook(t, env.db, webhook.ID))
})
}
}
+121
View File
@@ -0,0 +1,121 @@
package handlers
import (
"context"
"errors"
"net/http"
"time"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
// downloadWriteTimeout is how long one write of a download may wait
// for a client that has stopped reading.
const downloadWriteTimeout = 60 * time.Second
// HandleTargetDownload serves a database target's archive as one
// gzipped JSON file, named for the webhook, the target and the time;
// see delivery.ArchiveExport.WriteGzipJSON for what it holds. Other
// target types have no archive and are a 404.
//
// A download runs for as long as the client keeps reading: it reads
// under a context the request limit does not cancel, and gives each
// write its own deadline in place of the server's write timeout. It
// stops when a write fails.
func (h *Handlers) HandleTargetDownload() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithoutCancel(r.Context())
webhook, target, export, ok := h.openTargetArchive(ctx, w, r)
if !ok {
return
}
defer func() { _ = export.Close() }()
now := time.Now()
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set(
"Content-Disposition",
`attachment; filename="`+delivery.ArchiveExportFileName(
webhook.Name, target.Name, now,
)+`"`,
)
err := export.WriteGzipJSON(
ctx,
downloadWriter{w: w, rc: http.NewResponseController(w)},
&webhook, target, now,
)
if err != nil {
h.log.Error(
"failed to export archive",
"target_id", target.ID,
"error", err,
)
// The 200 has gone out. Aborting the connection is what
// tells the client the file is incomplete.
panic(http.ErrAbortHandler)
}
}
}
// downloadWriter writes a download to the client, giving each write
// downloadWriteTimeout to finish.
type downloadWriter struct {
w http.ResponseWriter
rc *http.ResponseController
}
func (d downloadWriter) Write(b []byte) (int, error) {
// A writer that has no write deadline, such as a test's recorder,
// answers http.ErrNotSupported and needs none extended.
err := d.rc.SetWriteDeadline(time.Now().Add(downloadWriteTimeout))
if err != nil && !errors.Is(err, http.ErrNotSupported) {
return 0, err
}
return d.w.Write(b)
}
// openTargetArchive opens the archive of the request's database target
// for export, with its reads under ctx. It reports false once it has
// written the response.
//
// It holds renameMu, which every archive rename runs under, while it
// reads the stored names and opens the file, so the file it opens is
// the one those names give. It lets go before the export is streamed:
// once the file is open, a rename does not affect the export.
func (h *Handlers) openTargetArchive(
ctx context.Context,
w http.ResponseWriter,
r *http.Request,
) (database.Webhook, *database.Target, *delivery.ArchiveExport, bool) {
h.renameMu.Lock()
defer h.renameMu.Unlock()
webhook, target, ok := h.ownedTarget(w, r)
if !ok {
return database.Webhook{}, nil, nil, false
}
if target.Type != database.TargetTypeDatabase {
h.renderError(w, r, http.StatusNotFound)
return database.Webhook{}, nil, nil, false
}
export, err := delivery.OpenArchiveExport(
ctx, delivery.ArchivePath(h.dbMgr, &webhook, target), h.log,
)
if err != nil {
h.serverError(w, r, "failed to open archive for export", err)
return database.Webhook{}, nil, nil, false
}
return webhook, target, export, true
}
+379
View File
@@ -0,0 +1,379 @@
package handlers_test
import (
"bytes"
"compress/gzip"
"context"
"crypto/rand"
"encoding/json"
"errors"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/middleware"
)
// errClientGone is the write failure of a client that has gone away.
var errClientGone = errors.New("client gone")
// downloadPath is the archive download route of a target.
func downloadPath(webhookID, targetID string) string {
return "/hook/" + webhookID + "/targets/" + targetID + "/download"
}
// renameTarget submits the edit form renaming a target to Renamed.
func renameTarget(
env *sourceTestEnv, webhookID, targetID string,
) *httptest.ResponseRecorder {
form := url.Values{}
form.Set("name", "Renamed")
return submitTargetEdit(env, webhookID, targetID, form)
}
// TestHandleTargetDownload proves a database target's archive
// downloads as a gzipped JSON attachment named for the webhook, the
// target and the time, here with no archive file yet, so with no
// rows; and that a target of another type has no download.
func TestHandleTargetDownload(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7)
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
logTarget := seedTarget(t, env.db, wh.ID, database.TargetTypeLog)
w := serveTarget(
env, http.MethodGet, downloadPath(wh.ID, archive.ID), nil,
)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
assert.Equal(t, "application/gzip", w.Header().Get("Content-Type"))
assert.Regexp(t,
`^attachment; filename="archive-seeded-t-database-`+
`\d{8}T\d{6}Z\.json\.gz"$`,
w.Header().Get("Content-Disposition"),
)
zr, err := gzip.NewReader(w.Body)
require.NoError(t, err)
var got map[string]json.RawMessage
require.NoError(t, json.NewDecoder(zr).Decode(&got))
assert.JSONEq(t,
`{"id":"`+archive.ID+`","name":"t-database"}`,
string(got["target"]),
)
assert.JSONEq(t, `[]`, string(got["archived_events"]))
w = serveTarget(
env, http.MethodGet, downloadPath(wh.ID, logTarget.ID), nil,
)
assert.Equal(t, http.StatusNotFound, w.Code)
}
// TestHandleTargetDownload_WaitsForRename proves a download reads the
// target's names and opens its archive under the lock a rename holds:
// started while an edit is renaming the archive, it waits, and is
// named for the target's new name.
func TestHandleTargetDownload_WaitsForRename(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7)
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
renaming, release := env.archives.BlockNextRename()
edited := make(chan *httptest.ResponseRecorder, 1)
go func() {
edited <- renameTarget(env, wh.ID, archive.ID)
}()
<-renaming
downloaded := make(chan *httptest.ResponseRecorder, 1)
go func() {
downloaded <- serveTarget(
env, http.MethodGet, downloadPath(wh.ID, archive.ID), nil,
)
}()
select {
case <-downloaded:
release()
t.Fatal("the download did not wait for the rename")
case <-time.After(100 * time.Millisecond):
}
release()
require.Equal(t, http.StatusSeeOther, (<-edited).Code)
w := <-downloaded
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t,
w.Header().Get("Content-Disposition"), "archive-seeded-renamed-",
)
}
// stalledWriter is a response writer whose first write waits until
// resume is closed, closing writing when it starts to wait.
type stalledWriter struct {
*httptest.ResponseRecorder
once sync.Once
writing chan struct{}
resume chan struct{}
}
func (s *stalledWriter) Write(b []byte) (int, error) {
s.once.Do(func() {
close(s.writing)
<-s.resume
})
return s.ResponseRecorder.Write(b)
}
// TestHandleTargetDownload_StreamsWithoutTheLock proves a download
// lets go of the rename lock once its archive is open: while the
// download is stalled writing, an edit can still rename the target.
func TestHandleTargetDownload_StreamsWithoutTheLock(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7)
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
req := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, downloadPath(wh.ID, archive.ID), nil,
)
for _, c := range env.cookies {
req.AddCookie(c)
}
sw := &stalledWriter{
ResponseRecorder: httptest.NewRecorder(),
writing: make(chan struct{}),
resume: make(chan struct{}),
}
downloaded := make(chan struct{})
go func() {
targetRouter(env).ServeHTTP(sw, req)
close(downloaded)
}()
<-sw.writing
edited := make(chan *httptest.ResponseRecorder, 1)
go func() {
edited <- renameTarget(env, wh.ID, archive.ID)
}()
select {
case w := <-edited:
assert.Equal(t, http.StatusSeeOther, w.Code)
case <-time.After(10 * time.Second):
t.Error("the rename waited for the download")
}
close(sw.resume)
<-downloaded
assert.Equal(t, http.StatusOK, sw.Code)
}
// seedArchive writes rows to the archive file at path, each with a
// body of bodySize random bytes, which do not compress. Its table has
// only the columns the test fills; an export writes the others empty.
func seedArchive(t *testing.T, path string, rows, bodySize int) {
t.Helper()
db, err := database.OpenSQLite(path, database.SQLiteModeCreate)
require.NoError(t, err)
defer func() { require.NoError(t, db.Close()) }()
_, err = db.ExecContext(t.Context(),
"CREATE TABLE archived_events (id INTEGER PRIMARY KEY, body TEXT)",
)
require.NoError(t, err)
body := make([]byte, bodySize)
for range rows {
_, _ = rand.Read(body)
_, err = db.ExecContext(t.Context(),
"INSERT INTO archived_events (body) VALUES (?)", string(body),
)
require.NoError(t, err)
}
}
// limitedServer serves the target routes as the server does, behind the
// access log, whose lines it returns, and the request limit, here
// limit, which is also its write timeout. Each connection's send buffer
// is a few KiB, so a larger response is still being written while its
// client is not reading.
func limitedServer(
t *testing.T, env *sourceTestEnv, limit time.Duration,
) (*httptest.Server, *bytes.Buffer) {
t.Helper()
const sendBuffer = 4 << 10
logBuf := new(bytes.Buffer)
mw := middleware.NewForTest(
slog.New(slog.NewJSONHandler(logBuf, nil)),
&config.Config{Environment: config.EnvironmentDev},
nil,
)
srv := httptest.NewUnstartedServer(
mw.Logging()(mw.Timeout(limit)(targetRouter(env))),
)
srv.Config.WriteTimeout = limit
srv.Config.ConnContext = func(
ctx context.Context, c net.Conn,
) context.Context {
tcp, ok := c.(*net.TCPConn)
if assert.True(t, ok) {
assert.NoError(t, tcp.SetWriteBuffer(sendBuffer))
}
return ctx
}
srv.Start()
t.Cleanup(srv.Close)
return srv, logBuf
}
// TestHandleTargetDownload_OutlastsTheRequestLimit proves a download
// runs for as long as the client keeps reading, and is logged as the
// 200 it was. Behind a request limit and a server write timeout of a
// tenth of a second, the client stops reading once the response has
// started, waits three times as long, and still gets the whole file.
// The archive is larger than the connection holds, so the download is
// still being written while the client waits.
func TestHandleTargetDownload_OutlastsTheRequestLimit(t *testing.T) {
t.Parallel()
const (
limit = 100 * time.Millisecond
rows = 8
bodySize = 64 << 10
)
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7)
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
seedArchive(
t, delivery.ArchivePath(env.dbMgr, &wh, archive), rows, bodySize,
)
srv, accessLog := limitedServer(t, env, limit)
req, err := http.NewRequestWithContext(
t.Context(), http.MethodGet,
srv.URL+downloadPath(wh.ID, archive.ID), nil,
)
require.NoError(t, err)
for _, c := range env.cookies {
req.AddCookie(c)
}
resp, err := srv.Client().Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode)
time.Sleep(3 * limit)
zr, err := gzip.NewReader(resp.Body)
require.NoError(t, err)
var (
got map[string]json.RawMessage
events []json.RawMessage
)
require.NoError(t, json.NewDecoder(zr).Decode(&got))
require.NoError(t, json.Unmarshal(got["archived_events"], &events))
assert.Len(t, events, rows)
// Reading to the end makes the gzip reader check that the file was
// finished.
_, err = io.ReadAll(zr)
require.NoError(t, err)
// Close waits for the handler, so the access log line is written.
srv.Close()
var access map[string]any
require.NoError(t, json.Unmarshal(accessLog.Bytes(), &access))
assert.EqualValues(t, http.StatusOK, access["status"])
assert.GreaterOrEqual(t,
access["latency_ms"], float64(limit.Milliseconds()),
"the download must outlast the request limit",
)
}
// brokenWriter is a response writer whose writes fail once the
// response has started, as they do when the client goes away.
type brokenWriter struct {
*httptest.ResponseRecorder
}
func (b brokenWriter) Write(p []byte) (int, error) {
if b.Body.Len() > 0 {
return 0, errClientGone
}
return b.ResponseRecorder.Write(p)
}
// TestHandleTargetDownload_AbortsWhenItFails proves a download that
// fails after its response has started aborts the connection, so the
// client sees a failed download rather than a file that looks
// complete and does not decompress.
func TestHandleTargetDownload_AbortsWhenItFails(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7)
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
req := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, downloadPath(wh.ID, archive.ID), nil,
)
for _, c := range env.cookies {
req.AddCookie(c)
}
w := brokenWriter{ResponseRecorder: httptest.NewRecorder()}
assert.PanicsWithValue(t, http.ErrAbortHandler, func() {
targetRouter(env).ServeHTTP(w, req)
})
assert.Equal(t, http.StatusOK, w.Code)
}
+11 -6
View File
@@ -120,18 +120,23 @@ func (h *Handlers) applyTargetEdit(
) {
name := r.PostFormValue("name")
if name == "" {
http.Error(
w, "Name is required", http.StatusBadRequest,
)
http.Error(w, "Name is required", http.StatusBadRequest)
return
}
configJSON, err := h.buildTargetConfig(
w, r, target.Type, targetFormInputFrom(r),
configJSON, errMsg, err := h.buildTargetConfig(
r.Context(), target.Type, targetFormInputFrom(r),
)
if err != nil {
// buildTargetConfig has already written the response.
h.serverError(w, r, "failed to encode target config", err)
return
}
if errMsg != "" {
http.Error(w, errMsg, http.StatusBadRequest)
return
}
+6 -2
View File
@@ -37,14 +37,18 @@ const (
editAuthHeader = "Authorization: Bearer " + editBearerSecret
)
// targetRouter mounts the target create and edit routes on a chi
// router so the handlers see the URL parameters they read.
// targetRouter mounts the target create, edit and download routes on
// a chi router so the handlers see the URL parameters they read.
func targetRouter(env *sourceTestEnv) *chi.Mux {
router := chi.NewRouter()
router.Post(
"/hook/{sourceID}/targets",
env.handlers.HandleTargetCreate(),
)
router.Get(
"/hook/{sourceID}/targets/{targetID}/download",
env.handlers.HandleTargetDownload(),
)
router.Get(
"/hook/{sourceID}/targets/{targetID}/edit",
env.handlers.HandleTargetEdit(),
@@ -1,6 +1,7 @@
package handlers_test
import (
"html"
"net/http"
"net/url"
"testing"
@@ -43,12 +44,16 @@ func TestTargetRefusal_PrivateDestinationSaysHowToAllowIt(
form.Set("type", string(targetType))
form.Set("url", editBlockedURL)
// A refused add shows the webhook page again, where
// the hint is HTML-escaped; a refused edit answers in
// plain text.
added := serveTarget(
env, http.MethodPost, targetsPath, form,
)
assert.Equal(t, http.StatusBadRequest, added.Code)
assert.Contains(
t, added.Body.String(), privateRefusalHint,
t, added.Body.String(),
html.EscapeString(privateRefusalHint),
)
form.Set("url", editOriginalURL)
+5 -4
View File
@@ -90,13 +90,14 @@ func retriesErrorMessage(err error) string {
", or 0 for fire-and-forget"
}
// targetMaxRetries reads and validates max_retries from a target form
// targetMaxRetries reads and validates max_retries from a target edit
// submission, answering the request with a 400 and reporting false
// when the value is set but invalid.
//
// Both the create and the edit path go through here, so the two
// cannot come to disagree about what a valid retry count is. The
// wording matches the timeout control on the same submission.
// It and the create path (newTarget) both use parseMaxRetries and
// retriesErrorMessage, so the two cannot come to disagree about what a
// valid retry count is. The wording matches the timeout control on
// the same submission.
func targetMaxRetries(
w http.ResponseWriter,
r *http.Request,
+66
View File
@@ -0,0 +1,66 @@
package handlers_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
// TestHandleTargetToggle_DoesNotUndoAnEdit proves that a toggle which
// loaded the target before an edit of it was saved does not write the
// old name and settings back over the edit. The edit is submitted from
// a callback on the toggle's own read of the target, so it is saved
// after that read and before the toggle writes.
func TestHandleTargetToggle_DoesNotUndoAnEdit(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
wh, tgt := seedHTTPTarget(t, env, "", "")
require.True(t, tgt.Active)
var (
edited bool
editCode int
)
require.NoError(t, env.db.DB().Callback().Query().
After("gorm:query").
Register("test:edit_after_toggle_read", func(tx *gorm.DB) {
// The edit reads the target too; only the toggle's read,
// the first, submits it.
if tx.Statement.Table != "targets" || edited {
return
}
edited = true
editCode = submitTargetEdit(
env, wh.ID, tgt.ID,
editForm(editReplacedURL, "", ""),
).Code
}),
)
req := postRequest(
"/hook/"+wh.ID+"/targets/"+tgt.ID+"/toggle",
env.cookies,
map[string]string{paramSourceID: wh.ID, paramTargetID: tgt.ID},
)
w := httptest.NewRecorder()
env.handlers.HandleTargetToggle().ServeHTTP(w, req)
require.Equal(t, http.StatusSeeOther, w.Code)
require.Equal(t, http.StatusSeeOther, editCode)
stored := storedTarget(t, env, tgt.ID)
assert.False(t, stored.Active)
assert.Equal(t, "edited-name", stored.Name)
assert.Equal(t, 5, stored.MaxRetries)
assert.Equal(
t, editReplacedURL, storedHTTPConfig(t, env, tgt.ID).URL,
)
}
+6 -5
View File
@@ -82,9 +82,9 @@ func TestNavbarUsesWebhookTerminology(t *testing.T) {
})
assert.Contains(t, body, "Retention: 14 days")
assert.Contains(t, body, `class="btn-text">Webhooks</a>`)
assert.Contains(t, body, `class="btn-secondary">Webhooks</a>`)
assert.Contains(
t, body, `class="btn-text w-full text-left">Webhooks</a>`,
t, body, `class="btn-secondary w-full">Webhooks</a>`,
)
assert.Contains(
t, body,
@@ -163,7 +163,7 @@ func TestEventLogPageIsCalledFullEventLog(t *testing.T) {
)
assert.Contains(
t, detailBody,
`<a href="/hook/wh-1/events" class="btn-text text-sm">Full Event Log</a>`,
`<a href="/hook/wh-1/events" class="btn-small">Full Event Log</a>`,
"the link under recent events",
)
@@ -331,8 +331,9 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
assert.Contains(t, body, "https://hooks.example.com/h/abc123")
assert.Contains(
t, body,
`hidden data-copy-target="entrypoint-url-ep-1"`,
"the button must start hidden and be revealed by script",
`<button type="button" hidden data-copy-target="entrypoint-url-ep-1"`,
"the copy control must be a button, start hidden and be "+
"revealed by script",
)
// renderTemplate streams to the ResponseWriter, so an abort
+9 -5
View File
@@ -11,6 +11,7 @@ import (
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/logfield"
"sneak.berlin/go/webhooker/internal/middleware"
)
const (
@@ -57,7 +58,8 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
h.log.Info("webhook request received",
"entrypoint_uuid", entrypointUUID,
"method", r.Method,
"remote_addr", r.RemoteAddr,
"remoteIP", middleware.RemoteIP(r),
"clientIP", middleware.ClientIP(r),
)
if !entrypoint.Active {
@@ -270,10 +272,12 @@ func requestEventSource(
// createAndFanOut writes the event and one pending delivery per target,
// and adds them to the webhook's running totals, in a single
// transaction, then hands the tasks to the delivery engine. It is the
// only path by which an event and its deliveries are created, so a
// resubmitted event is retried, SSRF-guarded and circuit-broken
// exactly as a received one is.
// transaction, then hands the tasks to the delivery engine. Every
// event is created here, received or resubmitted, so a resubmitted
// event is retried, SSRF-guarded and circuit-broken exactly as a
// received one is. Per-delivery replay is the one other path that
// creates a delivery: it adds one to an existing event without
// coming through here.
//
// The tasks are returned as well as queued, so a caller can report how
// many targets the event went to.
+124
View File
@@ -0,0 +1,124 @@
package handlers_test
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"testing"
"github.com/go-chi/chi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/middleware"
)
// TestHandleWebhook_LogsClientNextToThePeer checks that the
// receiver's "webhook request received" line carries both addresses:
// remoteIP, the connecting peer, and clientIP, the client the access
// log attributes the request to.
func TestHandleWebhook_LogsClientNextToThePeer(t *testing.T) {
t.Parallel()
// untrustedPeer is outside the trusted 10.0.0.0/8, so its
// X-Forwarded-For is ignored and it is the client.
const untrustedPeer = "192.0.2.10"
cases := map[string]struct {
peer string
wantRemote string
wantClient string
}{
"trusted proxy with a forwarded chain": {
peer: "10.0.0.1:44444",
wantRemote: "10.0.0.1",
wantClient: "198.51.100.7",
},
"untrusted peer": {
peer: untrustedPeer + ":5555",
wantRemote: untrustedPeer,
wantClient: untrustedPeer,
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
mw *middleware.Middleware
db *database.Database
)
app := newTestAppWithConfig(t, &config.Config{
DataDir: t.TempDir(),
TrustedProxies: []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/8"),
},
}, &h, &mw, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
buf := new(bytes.Buffer)
h.SetLogForTest(slog.New(slog.NewJSONHandler(buf, nil)))
webhook := seedWebhook(t, db)
seedEntrypoint(t, db, webhook.ID)
// Logging is what works the client address out, so the
// request goes through it as it does in production.
router := chi.NewRouter()
router.Use(mw.Logging())
router.Post("/h/{uuid}", h.HandleWebhook())
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost,
"/h/ep-"+webhook.ID, strings.NewReader("{}"),
)
req.RemoteAddr = tc.peer
req.Header.Set("X-Forwarded-For", "198.51.100.7, 10.0.0.2")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
line := receivedLine(t, buf)
assert.Equal(t, tc.wantRemote, line["remoteIP"])
assert.Equal(t, tc.wantClient, line["clientIP"])
})
}
}
// receivedLine returns the one "webhook request received" line in the
// captured JSON log.
func receivedLine(t *testing.T, buf *bytes.Buffer) map[string]any {
t.Helper()
var found []map[string]any
for line := range strings.SplitSeq(
strings.TrimSpace(buf.String()), "\n",
) {
var entry map[string]any
require.NoError(t, json.Unmarshal([]byte(line), &entry))
if entry["msg"] == "webhook request received" {
found = append(found, entry)
}
}
require.Len(t, found, 1)
return found[0]
}
+120 -61
View File
@@ -5,6 +5,7 @@ import (
"io"
"log/slog"
"strings"
"sync"
"testing"
"unicode/utf8"
@@ -18,15 +19,11 @@ import (
// width.
const budget = 64
// sampleRunes is how many runes wide the values in the charge test
// are. The handlers add a constant per field — a pair of quotes when
// the value needs quoting — so the per-rune charge is only visible
// once it is amortised over a run of them.
const sampleRunes = 64
// quotingSlack is that constant: the pair of quotes a handler adds to
// a value that needs them and omits from one that does not.
const quotingSlack = 2
// batchRunes is how many consecutive code points the charge test logs
// in one value from U+1000 up. Logging each of those on its own line
// is too slow for the suite under the race detector; 4,096 at a time
// is 271 batches, each logged on two lines, so 542 lines per handler.
const batchRunes = 4096
// newHandlers are the two handlers internal/logger can install. Time
// is dropped so a line's width is a function of its value alone —
@@ -66,46 +63,48 @@ func renderedWidth(
return buf.Len()
}
// chargeTestRunes is the set of code points the charge test measures:
// every rune in the first two planes' worth of the BMP that the
// handlers are most likely to treat specially, the separators that
// only slog's JSON handler escapes, and a stratified sample across
// the rest of Unicode so the astral charge is exercised on more than
// one hand-picked rune.
func chargeTestRunes() []rune {
const (
denseCeiling = 0x800
stride = 1021
surrogateLo = 0xD800
surrogateHi = 0xDFFF
)
// emittedBytes is what a handler writes for the runes of s alone, in a
// value that starts with prefix: the width of a line carrying prefix
// and then s twice, less that of a line carrying prefix and s once.
// Both values start the same way and hold the same runes, so the text
// handler quotes both or neither, and the quotes cancel along with the
// prefix and everything else on the line.
func emittedBytes(
newHandler func(io.Writer) slog.Handler,
prefix, s string,
) int {
return renderedWidth(newHandler, prefix+s+s) -
renderedWidth(newHandler, prefix+s)
}
var runes []rune
// firstUndercharged returns the first rune in s that the handler
// writes in more bytes than EncodedBytes charges for it, and how many
// runes in s are undercharged that way. It measures one rune per line,
// in a value of that rune alone and again after a space, which makes
// the text handler quote the value. The charge test calls it on the
// code points below U+1000, and from there up only on a batch that has
// already failed, to name the code points rather than just their range.
func firstUndercharged(
newHandler func(io.Writer) slog.Handler,
s string,
) (rune, int) {
first, count := rune(-1), 0
keep := func(r rune) {
if r >= surrogateLo && r <= surrogateHi {
return
for _, r := range s {
charge := logfield.EncodedBytes(r)
if emittedBytes(newHandler, "", string(r)) <= charge &&
emittedBytes(newHandler, " ", string(r)) <= charge {
continue
}
runes = append(runes, r)
if count == 0 {
first = r
}
count++
}
for r := range rune(denseCeiling) {
keep(r)
}
for _, r := range []rune{
0x2028, 0x2029, 0x200B, 0x4E00, 0xE000, 0xFFFD,
0x1000C, 0x1F600, 0xE0001, 0x10FFFF,
} {
keep(r)
}
for r := rune(denseCeiling); r <= utf8.MaxRune; r += stride {
keep(r)
}
return runes
return first, count
}
// TestEncodedBytes_ChargesAtLeastWhatTheHandlersEmit is the property
@@ -114,33 +113,93 @@ func chargeTestRunes() []rune {
// how a stated ceiling becomes false without any test noticing, so
// the charge is measured against what the handlers actually write
// rather than against the escaping rules as read.
//
// Every code point below U+1000 is checked on its own, for both
// handlers. That range holds the quote, the backslash and the control
// characters the handlers escape, next to code points each handler
// writes in fewer bytes than their charge, which in a sum would cover
// a neighbour charged too little. Each is measured in a value of it
// alone and again in one the text handler quotes, because that handler
// writes U+007F as one raw byte in a value it leaves bare but as \x7f,
// four bytes, in one it quotes.
//
// From U+1000 up the text handler writes every code point in exactly
// its charge, so the rest of Unicode is checked batchRunes at a time:
// each batch's summed charge must cover what the handler writes for
// the whole batch. The sums there can miss the JSON handler alone
// writing one code point in more bytes than its charge, when it writes
// others in the same batch in fewer.
func TestEncodedBytes_ChargesAtLeastWhatTheHandlersEmit(t *testing.T) {
t.Parallel()
var below strings.Builder
for r := range rune(0x1000) {
below.WriteRune(r)
}
var batches []string
for lo := rune(0x1000); lo <= utf8.MaxRune; lo += batchRunes {
var batch strings.Builder
for r := lo; r < lo+batchRunes; r++ {
// Surrogate halves are not runes a string can carry.
if utf8.ValidRune(r) {
batch.WriteRune(r)
}
}
batches = append(batches, batch.String())
}
// What EncodedBytes charges for each batch. Under -race -cover this
// takes longer than logging the batches, so it is worked out once,
// by whichever handler finishes logging first, while the other is
// still logging.
charged := sync.OnceValue(func() []int {
costs := make([]int, len(batches))
for i, batch := range batches {
for _, r := range batch {
costs[i] += logfield.EncodedBytes(r)
}
}
return costs
})
for name, newHandler := range newHandlers() {
t.Run(name, func(t *testing.T) {
t.Parallel()
// 'a' is a printable ASCII rune, charged exactly one
// byte, so it is the zero point the other runes are
// measured against.
base := renderedWidth(
newHandler, strings.Repeat("a", sampleRunes),
)
for _, r := range chargeTestRunes() {
got := renderedWidth(
newHandler,
strings.Repeat(string(r), sampleRunes),
if first, count := firstUndercharged(newHandler, below.String()); count > 0 {
t.Errorf(
"%d code points below U+1000 cost more than "+
"EncodedBytes charges, the first U+%04X",
count, first,
)
charged := sampleRunes *
(logfield.EncodedBytes(r) - 1)
}
require.LessOrEqual(
t, got-base, charged+quotingSlack,
"U+%04X costs more on the line than "+
"EncodedBytes charges for it",
r,
emitted := make([]int, len(batches))
for i, batch := range batches {
emitted[i] = emittedBytes(newHandler, "", batch)
}
for i, cost := range charged() {
if emitted[i] <= cost {
continue
}
lo := rune(0x1000 + i*batchRunes)
first, count := firstUndercharged(
newHandler, batches[i],
)
t.Errorf(
"U+%04X to U+%04X emit %d bytes but are "+
"charged %d; %d of them cost more than "+
"EncodedBytes charges, the first U+%04X",
lo, lo+batchRunes-1, emitted[i], cost,
count, first,
)
}
})
+38
View File
@@ -9,6 +9,7 @@ import (
"time"
"go.uber.org/fx"
"go.uber.org/fx/fxevent"
"sneak.berlin/go/webhooker/internal/globals"
)
@@ -106,3 +107,40 @@ func (l *Logger) Identify() {
func (l *Logger) Writer() io.Writer {
return os.Stdout
}
// FxLogger writes fx's own events through a slog logger: how the
// dependency graph was built at DEBUG, since it repeats on every
// start; the start and stop hooks, the start itself and the signal
// that stops the service at INFO; every failure at ERROR.
//
// The formatting is fx's own fxevent.SlogLogger. That logger takes a
// single level for every event that is not a failure, so FxLogger
// holds one at each level and picks between them.
type FxLogger struct {
graph *fxevent.SlogLogger
lifecycle *fxevent.SlogLogger
}
// NewFxLogger returns an FxLogger that writes through log.
func NewFxLogger(log *slog.Logger) *FxLogger {
graph := &fxevent.SlogLogger{Logger: log}
graph.UseLogLevel(slog.LevelDebug)
lifecycle := &fxevent.SlogLogger{Logger: log}
lifecycle.UseLogLevel(slog.LevelInfo)
return &FxLogger{graph: graph, lifecycle: lifecycle}
}
// LogEvent implements fxevent.Logger.
func (f *FxLogger) LogEvent(event fxevent.Event) {
switch event.(type) {
case *fxevent.Supplied, *fxevent.Provided, *fxevent.Replaced,
*fxevent.Decorated, *fxevent.BeforeRun, *fxevent.Run,
*fxevent.Invoking, *fxevent.Invoked,
*fxevent.LoggerInitialized:
f.graph.LogEvent(event)
default:
f.lifecycle.LogEvent(event)
}
}
+54
View File
@@ -1,13 +1,23 @@
package logger_test
import (
"bytes"
"encoding/json"
"errors"
"log/slog"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"go.uber.org/fx/fxevent"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logger"
)
var errStopHook = errors.New("stop hook failed on purpose")
func testGlobals() *globals.Globals {
return &globals.Globals{
Appname: "test-app",
@@ -57,3 +67,47 @@ func TestEnableDebugLogging(t *testing.T) {
// Test debug logging
l.Get().Debug("debug message", "test", true)
}
// TestFxLogger_Levels starts and stops an fx app that reports its own
// events through NewFxLogger, as cmd/webhooker does, and reads back
// what reached the handler: the graph at DEBUG, the start at INFO and
// a failed stop hook at ERROR, each as a structured record.
func TestFxLogger_Levels(t *testing.T) {
t.Parallel()
var out bytes.Buffer
log := slog.New(slog.NewJSONHandler(
&out, &slog.HandlerOptions{Level: slog.LevelDebug},
))
app := fx.New(
fx.WithLogger(func() fxevent.Logger {
return logger.NewFxLogger(log)
}),
fx.Invoke(func(lc fx.Lifecycle) {
lc.Append(fx.StopHook(func() error { return errStopHook }))
}),
)
require.NoError(t, app.Start(t.Context()))
require.ErrorIs(t, app.Stop(t.Context()), errStopHook)
levels := map[string]string{}
decoder := json.NewDecoder(&out)
for decoder.More() {
var record struct {
Level string `json:"level"`
Msg string `json:"msg"`
}
require.NoError(t, decoder.Decode(&record))
levels[record.Msg] = record.Level
}
assert.Equal(t, "DEBUG", levels["provided"])
assert.Equal(t, "INFO", levels["started"])
assert.Equal(t, "ERROR", levels["OnStop hook failed"])
}
+55 -11
View File
@@ -63,6 +63,12 @@ const (
// capturingMiddleware returns a Middleware whose logger writes JSON
// lines into the returned buffer, so the access log can be asserted
// on directly.
//
// It trusts 192.0.2.1, the peer address httptest.NewRequestWithContext
// gives a request, as a proxy, the way a deployment trusts its reverse
// proxy: a request built that way and carrying X-Forwarded-For is
// logged with the client that header names as clientIP, and one
// without it with the peer.
func capturingMiddleware(t *testing.T) (*middleware.Middleware, *bytes.Buffer) {
t.Helper()
@@ -72,7 +78,10 @@ func capturingMiddleware(t *testing.T) (*middleware.Middleware, *bytes.Buffer) {
&slog.HandlerOptions{Level: slog.LevelInfo},
))
cfg := &config.Config{Environment: config.EnvironmentDev}
cfg := &config.Config{
Environment: config.EnvironmentDev,
TrustedProxies: trustedProxies("192.0.2.1/32"),
}
return middleware.NewForTest(log, cfg, nil), buf
}
@@ -81,7 +90,7 @@ func capturingMiddleware(t *testing.T) (*middleware.Middleware, *bytes.Buffer) {
// internal/logger can select: slog's text handler, which
// internal/logger/logger.go installs when stderr is a tty. It escapes
// differently from the JSON one, so the line bound has to be asserted
// against both.
// against both. It trusts the same peer.
func capturingTextMiddleware(
t *testing.T,
) (*middleware.Middleware, *bytes.Buffer) {
@@ -93,7 +102,10 @@ func capturingTextMiddleware(
&slog.HandlerOptions{Level: slog.LevelInfo},
))
cfg := &config.Config{Environment: config.EnvironmentDev}
cfg := &config.Config{
Environment: config.EnvironmentDev,
TrustedProxies: trustedProxies("192.0.2.1/32"),
}
return middleware.NewForTest(log, cfg, nil), buf
}
@@ -334,11 +346,12 @@ func oversizedHeaders(value string) map[string]string {
// sizeCase is one way of pointing 8 KB of client-chosen text at the
// access log.
type sizeCase struct {
target string
headers map[string]string
wantStatus int
wantURL string
bound int
target string
headers map[string]string
wantStatus int
wantURL string
wantClientIP string
bound int
}
// lineSizeCases enumerates every part of a request that reaches the
@@ -375,8 +388,7 @@ func lineSizeCases() map[string]sizeCase {
}
// The url field on a 5xx keeps the concrete path, so it reaches its
// own budget on the same line as the three header fields. That is
// the widest access log line the service can be made to write.
// own budget on the same line as the three header fields.
longPath := "/boom/" + strings.Repeat("x", oversizedSegmentBytes)
wantLongURL := longPath[:maxFieldBytes] + truncationSuffix
@@ -420,6 +432,29 @@ func lineSizeCases() map[string]sizeCase {
}
}
// From a trusted proxy, clientIP is read out of X-Forwarded-For,
// which the client writes. What bounds the field is that only one
// address from the header is written, and it is written parsed, with
// no zone. An IPv6 address with all eight groups at four digits is
// the longest such address; here it carries an 8 KB zone, which must
// not reach the line. It goes on the 5xx line with all three header
// fields at their budget.
const longestIPv6 = "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
forwarded := oversizedHeaders(oversizedValue("h"))
forwarded[headerXFF] = oversizedValue("h") + ", " +
longestIPv6 + "%" + oversizedValue("h")
cases["oversized X-Forwarded-For from a trusted proxy "+
"with a 5xx concrete url"] = sizeCase{
target: longPath,
headers: forwarded,
wantStatus: http.StatusInternalServerError,
wantURL: wantLongURL,
wantClientIP: longestIPv6,
bound: maxCappedLineBytes,
}
return cases
}
@@ -460,6 +495,14 @@ func TestAccessLog_LineSizeDoesNotTrackInputSize(t *testing.T) {
require.Len(t, entries, 1)
assert.Equal(t, tc.wantURL, entries[0]["url"])
// Set only by the X-Forwarded-For case, where it proves
// the header was read rather than ignored.
if tc.wantClientIP != "" {
assert.Equal(
t, tc.wantClientIP, entries[0]["clientIP"],
)
}
// The markers sit at the far end of the client-chosen
// text, so their absence is what proves the redaction and
// the truncation actually ran.
@@ -648,7 +691,8 @@ func TestAccessLog_RetainsEveryOtherField(t *testing.T) {
for _, key := range []string{
"request_start", "method", "url", "useragent", "request_id",
"referer", "proto", "remoteIP", "status", "latency_ms",
"referer", "proto", "remoteIP", "clientIP", "status",
"latency_ms",
} {
assert.Contains(t, entries[0], key)
}
+200
View File
@@ -0,0 +1,200 @@
package middleware_test
import (
"bytes"
"context"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/middleware"
)
const (
// forwardedChain is the X-Forwarded-For a request arrives with:
// the client, then a second proxy inside trustedProxyCIDR that the
// request passed through before reaching trustedPeer.
forwardedChain = clientIPv4 + ", 10.0.0.2"
// untrustedPeer is a peer outside trustedProxyCIDR, so its
// X-Forwarded-For is ignored and the peer is the client.
untrustedPeer = "192.0.2.10:5555"
// oneRequestPerMinute is the receiver limit these tests install:
// the second request on a path is rejected, and the aggregate
// limit is ReceiverAggregateMultiplierConst.
oneRequestPerMinute = 1
)
// clientLogSite is one log line that names the client. build wraps the
// middleware that writes it around a handler, and requests is how many
// identical requests it takes before the line is written.
type clientLogSite struct {
build func(m *middleware.Middleware) http.Handler
requests int
}
// clientLogSites maps the message of each line that names the client
// to the way to make it be written.
func clientLogSites() map[string]clientLogSite {
served := func(*middleware.Middleware) http.Handler {
return okHandler()
}
receiver := func(m *middleware.Middleware) http.Handler {
return m.ReceiverRateLimit()(okHandler())
}
login := func(m *middleware.Middleware) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
m.RecordLoginFailure(r, "someone")
w.WriteHeader(http.StatusUnauthorized)
},
)
}
csrf := func(m *middleware.Middleware) http.Handler {
return m.CSRF(http.HandlerFunc(forbidden))(okHandler())
}
passwordChange := func(m *middleware.Middleware) http.Handler {
return m.PasswordChangeRateLimit()(okHandler())
}
replay := func(m *middleware.Middleware) http.Handler {
return m.ReplayRateLimit()(okHandler())
}
resubmit := func(m *middleware.Middleware) http.Handler {
return m.ResubmitRateLimit()(okHandler())
}
return map[string]clientLogSite{
"http request": {
build: served,
requests: 1,
},
"webhook receiver rate limit exceeded": {
build: receiver,
requests: oneRequestPerMinute + 1,
},
// The aggregate limit sits in front of the per-entrypoint
// one, so the requests that one rejects count towards it.
"webhook receiver aggregate rate limit exceeded": {
build: receiver,
requests: middleware.ReceiverAggregateMultiplierConst*
oneRequestPerMinute + 1,
},
"login failure limit exceeded": {
build: login,
requests: middleware.LoginRateLimitConst + 1,
},
"csrf: token validation failed": {
build: csrf,
requests: 1,
},
"password change rate limit exceeded": {
build: passwordChange,
requests: middleware.PasswordChangeRateLimitConst + 1,
},
"delivery replay rate limit exceeded": {
build: replay,
requests: middleware.ReplayRateLimitConst + 1,
},
"event resubmit rate limit exceeded": {
build: resubmit,
requests: middleware.ResubmitRateLimitConst + 1,
},
}
}
// clientLogLines sends the site's requests from peer, each carrying
// forwardedChain, through Logging and then the site, as production
// does, and returns the logged lines whose message is msg.
func clientLogLines(
t *testing.T, site clientLogSite, msg, peer string,
) []map[string]any {
t.Helper()
buf := new(bytes.Buffer)
log := slog.New(slog.NewJSONHandler(
buf,
&slog.HandlerOptions{Level: slog.LevelDebug},
))
cfg := &config.Config{
Environment: config.EnvironmentDev,
ReceiverRateLimit: oneRequestPerMinute,
TrustedProxies: trustedProxies(trustedProxyCIDR),
}
m := middleware.NewForTest(
log, cfg, newTestSessionManager(cfg, log, nil),
)
handler := m.Logging()(site.build(m))
for range site.requests {
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost, "/h/x", nil,
)
req.RemoteAddr = peer
req.Header.Set(headerXFF, forwardedChain)
handler.ServeHTTP(httptest.NewRecorder(), req)
}
var lines []map[string]any
for _, entry := range accessLogEntries(t, buf) {
if entry["msg"] == msg {
lines = append(lines, entry)
}
}
return lines
}
// TestClientIP_LoggedNextToThePeer checks that every line that names
// the client carries both addresses: remoteIP, the connecting peer,
// and clientIP, the client the rate limiters key on.
func TestClientIP_LoggedNextToThePeer(t *testing.T) {
t.Parallel()
cases := map[string]struct {
peer string
wantRemote string
wantClient string
}{
"trusted proxy with a forwarded chain": {
peer: trustedPeer,
wantRemote: "10.0.0.1",
wantClient: clientIPv4,
},
"untrusted peer": {
peer: untrustedPeer,
wantRemote: "192.0.2.10",
wantClient: "192.0.2.10",
},
}
for msg, site := range clientLogSites() {
for name, tc := range cases {
t.Run(msg+"/"+name, func(t *testing.T) {
t.Parallel()
lines := clientLogLines(t, site, msg, tc.peer)
require.NotEmpty(t, lines, "%q was never logged", msg)
for _, line := range lines {
assert.Equal(t, tc.wantRemote, line["remoteIP"])
assert.Equal(t, tc.wantClient, line["clientIP"])
}
})
}
}
}
+5 -4
View File
@@ -45,10 +45,10 @@ func (m *Middleware) CSRF(
// unauthenticated client: a POST with no token to
// /hook/<any length of any text>/edit lands here. The
// method and path are capped against the same budgets as
// the access log. remote_addr is set by net/http from the
// accepted connection rather than by the client, and
// the access log. remoteIP and clientIP are the same
// addresses the access log carries, and
// csrf.FailureReason returns one of gorilla/csrf's own
// fixed error values, so neither is client-sized.
// fixed error values, so none of them is client-sized.
m.log.Warn("csrf: token validation failed",
"method", logfield.Truncate(
r.Method, maxLogMethodBytes,
@@ -56,7 +56,8 @@ func (m *Middleware) CSRF(
"path", logfield.Truncate(
r.URL.Path, logfield.MaxBytes,
),
"remote_addr", r.RemoteAddr,
"remoteIP", RemoteIP(r),
"clientIP", ClientIP(r),
"reason", csrf.FailureReason(r),
)
forbidden.ServeHTTP(w, r)
+6
View File
@@ -132,6 +132,12 @@ func (g *LoginGuard) TrackedKeysForTest() (int, int) {
// passwordChangeRateLimit constant.
const PasswordChangeRateLimitConst = passwordChangeRateLimit
// ReplayRateLimitConst exposes the replayRateLimit constant.
const ReplayRateLimitConst = replayRateLimit
// ResubmitRateLimitConst exposes the resubmitRateLimit constant.
const ResubmitRateLimitConst = resubmitRateLimit
// ReceiverAggregateMultiplierConst exposes the
// receiverAggregateMultiplier constant.
const ReceiverAggregateMultiplierConst = receiverAggregateMultiplier
+2
View File
@@ -385,6 +385,8 @@ func (m *Middleware) RecordLoginFailure(
"path", logfield.Truncate(
r.URL.Path, logfield.MaxBytes,
),
"remoteIP", RemoteIP(r),
"clientIP", ClientIP(r),
)
}
+48 -11
View File
@@ -3,6 +3,7 @@
package middleware
import (
"context"
"log/slog"
"net"
"net/http"
@@ -69,18 +70,19 @@ const (
// url, useragent, referer 3*(512+11) = 1569
// request_id 128+11 = 139
// method 32+11 = 43
// fixed portion = 336
// fixed portion = 405
// ----
// 2087
// 2156
//
// The 512 is logfield.MaxBytes; the 11 is the truncation marker,
// charged on top of each budget rather than inside it.
//
// The fixed portion is the JSON punctuation, the field names, the
// level and the message, both timestamps at their longest, an IPv6
// remoteIP with a zone, a three-digit status and a full-width int64
// latency. Stated at 2560 so the figure carries headroom rather
// than sitting on the arithmetic.
// level and the message, both timestamps at their longest, remoteIP
// and clientIP each charged as an IPv6 address with a zone, a
// three-digit status and a full-width int64 latency. Stated at 2560
// so the figure carries headroom rather than sitting on the
// arithmetic.
//
// The tty text handler in internal/logger is covered by the same
// figure. logfield.EncodedBytes charges every rune at least what
@@ -88,8 +90,8 @@ const (
// bytes strconv.Quote spends on a non-printable rune at or above
// U+10000, which is four more than the JSON handler ever spends —
// so each budget bounds the encoded field under either handler.
// The text handler's fixed portion is 286, the smaller of the two,
// which puts its worst case at 2037.
// The text handler's fixed portion is 351, the smaller of the two,
// which puts its worst case at 2102.
//
// It is also the ceiling on every OTHER line this service writes
// THROUGH SLOG that carries text an UNAUTHENTICATED client
@@ -215,6 +217,28 @@ func ipFromHostPort(hp string) string {
return h
}
// RemoteIP returns the address of the connecting peer, without its
// port. Behind a reverse proxy it is the proxy. Every log line that
// names the client logs it as remoteIP, next to clientIP.
func RemoteIP(r *http.Request) string {
return ipFromHostPort(r.RemoteAddr)
}
// clientIPKey is the request context key under which Logging stores
// the value ClientIP returns.
type clientIPKey struct{}
// ClientIP returns the address the request is attributed to, which
// Logging works out once per request with clientAddr in ratelimit.go
// and logs as clientIP. The other lines that name the client read it
// from here, so all of them agree. It is empty for a request Logging
// has not seen.
func ClientIP(r *http.Request) string {
ip, _ := r.Context().Value(clientIPKey{}).(string)
return ip
}
type loggingResponseWriter struct {
http.ResponseWriter
@@ -316,6 +340,13 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
lrw := newLoggingResponseWriter(w)
ctx := r.Context()
// When RemoteAddr is not an address, the peer's own
// text is all the request can be attributed to.
clientIP := RemoteIP(r)
if addr, ok := s.clientAddr(r); ok {
clientIP = addr.String()
}
defer func() {
latency := time.Since(start)
requestID := ""
@@ -350,13 +381,16 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
r.Referer(), logfield.MaxBytes,
),
"proto", r.Proto,
"remoteIP", ipFromHostPort(r.RemoteAddr),
"remoteIP", RemoteIP(r),
"clientIP", clientIP,
"status", lrw.statusCode,
"latency_ms", latency.Milliseconds(),
)
}()
next.ServeHTTP(lrw, r)
next.ServeHTTP(lrw, r.WithContext(
context.WithValue(ctx, clientIPKey{}, clientIP),
))
})
}
}
@@ -566,7 +600,10 @@ func bodyLimitedMethod(method string) bool {
}
// MaxBodySize returns middleware that limits the size of
// POST/PUT/PATCH request bodies to maxBytes. It must be registered
// POST/PUT/PATCH request bodies to maxBytes. A request with any other
// method passes through uncapped, deliberately: no route behind it
// reads a body on GET, HEAD or DELETE. A handler that starts to needs
// its method added to bodyLimitedMethod first. It must be registered
// before any middleware that parses the body — notably CSRF, which
// calls r.PostFormValue — so that form parsing happens under this
// cap rather than net/http's 10 MB default.
+8 -15
View File
@@ -12,7 +12,6 @@ import (
"testing"
"time"
"github.com/gorilla/sessions"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
@@ -78,14 +77,7 @@ func newTestSessionManager(
key[i] = byte(i)
}
store := sessions.NewCookieStore(key)
store.Options = &sessions.Options{
Path: "/",
MaxAge: 86400 * 7,
HttpOnly: true,
Secure: false,
SameSite: http.SameSiteLaxMode,
}
store := session.NewStore(key)
var now func() time.Time
@@ -738,10 +730,8 @@ func TestNoCache_SetsHeaders(t *testing.T) {
const testBodyLimit int64 = 64
// maxBodySizeHandler wraps a sentinel handler in MaxBodySize with
// testBodyLimit. The sentinel records whether it ran and how much of
// the body it managed to read, so tests can distinguish "never
// reached" from "reached but truncated".
// maxBodySizeResult is what runMaxBodySize's sentinel handler saw,
// together with the response.
type maxBodySizeResult struct {
called bool
read int
@@ -749,6 +739,10 @@ type maxBodySizeResult struct {
response *httptest.ResponseRecorder
}
// runMaxBodySize wraps a sentinel handler in MaxBodySize with
// testBodyLimit and serves req through it. The sentinel records
// whether it ran and how much of the body it managed to read, so
// tests can distinguish "never reached" from "reached but truncated".
func runMaxBodySize(
t *testing.T,
req *http.Request,
@@ -931,8 +925,7 @@ func metricsAuthMiddleware(
}
key := make([]byte, testKeySize)
store := sessions.NewCookieStore(key)
store.Options = &sessions.Options{Path: "/", MaxAge: 86400}
store := session.NewStore(key)
sessManager := session.NewForTest(store, cfg, log, key, nil)
+41 -18
View File
@@ -202,44 +202,61 @@ func (m *Middleware) forwardedClientAddr(
}
// rateLimitKey is the client identity every rate limiter in this
// package buckets on. Forwarded headers are honoured only when the
// direct peer (RemoteAddr) is inside the configured trusted-proxy
// set; otherwise the peer address itself is the key. Without that
// gate any client could mint a fresh bucket per request, or starve
// another client's bucket, by picking an X-Forwarded-For value —
// which makes every limit here decorative against a deliberate
// attacker.
//
// The address that identifies the client is then reduced to a bucket
// by bucketKey: full address for IPv4, /64 prefix for IPv6.
// package buckets on: the address clientAddr attributes the request
// to, reduced to a bucket by bucketKey — full address for IPv4, /64
// prefix for IPv6.
func (m *Middleware) rateLimitKey(r *http.Request) (string, error) {
return m.clientKey(r), nil
}
// clientKey computes the bucket key described on rateLimitKey.
func (m *Middleware) clientKey(r *http.Request) string {
peer, err := netip.ParseAddr(ipFromHostPort(r.RemoteAddr))
if err != nil {
addr, ok := m.clientAddr(r)
if !ok {
// Not an address we can reason about; key on the raw
// value, the most specific identity left. Distinct
// RemoteAddr values stay in distinct buckets, so this
// path cannot silently collapse unrelated clients
// together. On a Unix-socket listener every peer
// carries the same RemoteAddr and so shares one bucket,
// which is the fail-closed direction.
// which is the fail-closed direction. An empty RemoteAddr
// is a different case, which net/http never produces for
// a TCP listener and only a hand-built request carries,
// but it fails closed the same way: every such request
// shares the one bucket keyed on the empty string.
return r.RemoteAddr
}
return bucketKey(addr)
}
// clientAddr is the address a request is attributed to. The rate
// limiters key on it and the logs name it as clientIP.
//
// Forwarded headers are honoured only when the direct peer
// (RemoteAddr) is inside the configured trusted-proxy set; otherwise
// the peer address itself is the client. Without that gate any client
// could mint a fresh bucket per request, or starve another client's
// bucket, by picking an X-Forwarded-For value — which makes every
// limit here decorative against a deliberate attacker.
//
// ok is false when RemoteAddr is not an address at all.
func (m *Middleware) clientAddr(r *http.Request) (netip.Addr, bool) {
peer, err := netip.ParseAddr(ipFromHostPort(r.RemoteAddr))
if err != nil {
return netip.Addr{}, false
}
peer = normalizeAddr(peer)
if !m.isTrustedProxy(peer) {
return bucketKey(peer)
return peer, true
}
if addr, ok := m.forwardedClientAddr(r); ok {
return bucketKey(addr)
return addr, true
}
return bucketKey(peer)
return peer, true
}
// tooManyRequests returns the 429 handler used by the
@@ -262,6 +279,8 @@ func (m *Middleware) tooManyRequests(
"path", logfield.Truncate(
r.URL.Path, logfield.MaxBytes,
),
"remoteIP", RemoteIP(r),
"clientIP", ClientIP(r),
)
http.Error(w, responseMessage, http.StatusTooManyRequests)
}
@@ -286,8 +305,12 @@ func (m *Middleware) tooManyRequests(
func (m *Middleware) floodTooManyRequests(
logMessage, responseMessage string,
) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
m.log.Debug(logMessage)
return func(w http.ResponseWriter, r *http.Request) {
m.log.Debug(
logMessage,
"remoteIP", RemoteIP(r),
"clientIP", ClientIP(r),
)
http.Error(w, responseMessage, http.StatusTooManyRequests)
}
}
+17
View File
@@ -1012,6 +1012,23 @@ func TestRateLimitKey_UnparseablePeerKeepsDistinctBuckets(
)
}
// TestRateLimitKey_EmptyPeerSharesOneBucket pins what the fallback
// does with an empty RemoteAddr: it keys on the empty string, so every
// such request shares one bucket. That is the fail-closed direction
// and is kept on purpose; only a hand-built request carries an empty
// RemoteAddr.
func TestRateLimitKey_EmptyPeerSharesOneBucket(t *testing.T) {
t.Parallel()
m := rateLimitMiddleware(t, &config.Config{})
assert.Empty(
t, clientKeyFor(t, m, ""),
"every peer with an empty RemoteAddr must key on the "+
"empty string and so share one bucket",
)
}
// TestPostRateLimit_IPv6SharesBucketWithinSlash64 is the behavioural
// half, and the regression test for the bypass itself: a client that
// rotates source addresses inside its own routed /64 must stay in one
+71
View File
@@ -0,0 +1,71 @@
package middleware
import (
"context"
"errors"
"net/http"
"time"
)
// Timeout returns middleware that gives each request limit to finish:
// it cancels the request's context once limit has passed, and answers
// 504 when the handler then returns without having started its
// response.
//
// It replaces chi's middleware.Timeout, which writes that 504 even
// after the handler has sent its own status. A download that outlasts
// the limit has already sent its 200 and the whole file, so the late
// 504 changes nothing for the client: the access log and the metrics
// would record it in place of the 200, and net/http would complain of
// a superfluous WriteHeader.
func (s *Middleware) Timeout(
limit time.Duration,
) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(
w http.ResponseWriter,
r *http.Request,
) {
ctx, cancel := context.WithTimeout(r.Context(), limit)
defer cancel()
tw := &timeoutResponseWriter{ResponseWriter: w}
next.ServeHTTP(tw, r.WithContext(ctx))
if !tw.started &&
errors.Is(ctx.Err(), context.DeadlineExceeded) {
w.WriteHeader(http.StatusGatewayTimeout)
}
})
}
}
// timeoutResponseWriter records whether the handler has started its
// response.
type timeoutResponseWriter struct {
http.ResponseWriter
started bool
}
func (w *timeoutResponseWriter) WriteHeader(code int) {
w.started = true
w.ResponseWriter.WriteHeader(code)
}
func (w *timeoutResponseWriter) Write(b []byte) (int, error) {
// A Write without a WriteHeader starts the response too: net/http
// sends 200 in front of it.
w.started = true
//nolint:wrapcheck // Pass the writer's own error through unchanged.
return w.ResponseWriter.Write(b)
}
// Unwrap lets http.ResponseController reach the writer underneath, so
// a handler can still set a write deadline through this wrapper.
func (w *timeoutResponseWriter) Unwrap() http.ResponseWriter {
return w.ResponseWriter
}
+56
View File
@@ -0,0 +1,56 @@
package middleware_test
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestTimeout proves the request limit answers 504 to a handler that
// outlasts it without starting its response, and leaves a response the
// handler has started with the status it sent. Both are what the
// access log records.
func TestTimeout(t *testing.T) {
t.Parallel()
const limit = 10 * time.Millisecond
for _, tc := range []struct {
name string
sent int // the status the handler sends, or 0 for none
want int
}{
{name: "not started", sent: 0, want: http.StatusGatewayTimeout},
{name: "started", sent: http.StatusOK, want: http.StatusOK},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
m, buf := capturingMiddleware(t)
handler := m.Logging()(m.Timeout(limit)(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if tc.sent != 0 {
w.WriteHeader(tc.sent)
}
<-r.Context().Done()
},
)))
w := httptest.NewRecorder()
handler.ServeHTTP(w, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil,
))
assert.Equal(t, tc.want, w.Code)
entries := accessLogEntries(t, buf)
require.Len(t, entries, 1)
assert.EqualValues(t, tc.want, entries[0]["status"])
})
}
}
+535
View File
@@ -0,0 +1,535 @@
//go:build browser
// This test needs a headless browser, so it is built only with the
// browser build tag: `make test` leaves it out, and `make test-browser`
// runs it in the browser image that Dockerfile.browser pins.
package server_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/chromedp/cdproto/browser"
"github.com/chromedp/cdproto/log"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
)
const (
// browserTimeout bounds everything one test does in the browser.
browserTimeout = 60 * time.Second
// settleTimeout bounds the wait for an element to show or hide.
settleTimeout = 5 * time.Second
// The window size of a phone, narrow enough that the pages show
// the mobile menu button instead of the navigation links.
phoneWidth = 390
phoneHeight = 844
)
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
// event log in a headless browser, served by the real router and so
// under the real Content-Security-Policy, and checks that the pages'
// Alpine.js directives and the copy control work.
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
t.Parallel()
ctx, problems := startBrowser(t)
env := newTestEnv(t)
srv := httptest.NewServer(env.router)
t.Cleanup(srv.Close)
userID, _ := env.seedUser(t, "browser", "browser-password")
webhook := env.seedWebhook(t, userID)
require.NoError(t, env.db.DB().Omit(clause.Associations).Create(
&database.Entrypoint{
WebhookID: webhook.ID,
Path: "3c9e1f7a-5b2d-4e8a-9f6c-2a7d1e4b8c05",
Active: true,
},
).Error)
event := env.seedEvent(t, webhook.ID, `{"hello":"browser"}`)
target := env.seedTarget(t, webhook.ID)
dlv := env.seedFailedDelivery(t, webhook.ID, event.ID, target.ID)
webhookDB, err := env.dbMgr.GetDB(webhook.ID)
require.NoError(t, err)
require.NoError(t, webhookDB.Omit(clause.Associations).Create(
&database.DeliveryResult{
DeliveryID: dlv.ID,
AttemptNum: 1,
StatusCode: http.StatusBadGateway,
},
).Error)
require.NoError(t, chromedp.Run(
ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")),
))
page := srv.URL + "/hook/" + webhook.ID
checkAddEntrypoint(ctx, t, page)
// Each target type, with the fields its add target form submits, in
// page order. Only http and slack have a url field.
targetTypes := []struct {
name string
fields string
values map[string]string
}{
{
"http", "csrf_token name type url headers timeout max_retries",
map[string]string{"url": publicTargetURL},
},
{
"slack", "csrf_token name type url max_retries",
map[string]string{"url": publicTargetURL},
},
{
"database", "csrf_token name type expiry",
map[string]string{"expiry": "720h"},
},
{"log", "csrf_token name type", nil},
}
for _, tt := range targetTypes {
checkAddTarget(
ctx, t, page, tt.name, strings.Fields(tt.fields), tt.values,
)
}
checkRefusedTarget(ctx, t, page)
checkCopy(ctx, t, page)
checkEventLog(ctx, t, page+"/events", event.ID, target.Name)
checkMobileMenu(ctx, t, page)
assert.Empty(t, problems(), "the browser reported problems")
}
// startBrowser starts a headless browser for one test. It returns the
// context that drives it, and a function listing what the browser
// reported going wrong on its pages: console warnings and errors,
// which is how Alpine.js reports an expression it cannot run; uncaught
// exceptions; and every entry in the browser's own security log, which
// is where it reports each script, style, image or request the
// Content-Security-Policy refused.
//
// The browser library finds the browser on PATH. Without one the first
// chromedp.Run fails, and with it the test.
func startBrowser(t *testing.T) (context.Context, func() []string) {
t.Helper()
allocCtx, cancelAlloc := chromedp.NewExecAllocator(
t.Context(),
append(
chromedp.DefaultExecAllocatorOptions[:],
// Dockerfile.browser runs the test as root, where the
// browser's sandbox cannot start.
chromedp.NoSandbox,
)...,
)
t.Cleanup(cancelAlloc)
ctx, cancel := chromedp.NewContext(allocCtx)
t.Cleanup(cancel)
ctx, cancelTimeout := context.WithTimeout(ctx, browserTimeout)
t.Cleanup(cancelTimeout)
var (
mu sync.Mutex
problems []string
)
chromedp.ListenTarget(ctx, func(ev any) {
var problem string
switch ev := ev.(type) {
case *runtime.EventConsoleAPICalled:
if ev.Type != runtime.APITypeWarning &&
ev.Type != runtime.APITypeError {
return
}
args := make([]string, 0, len(ev.Args))
for _, arg := range ev.Args {
args = append(args, string(arg.Value))
}
problem = strings.Join(args, " ")
case *runtime.EventExceptionThrown:
problem = ev.ExceptionDetails.Error()
case *log.EventEntryAdded:
if ev.Entry.Source != log.SourceSecurity {
return
}
problem = ev.Entry.Text
default:
return
}
mu.Lock()
defer mu.Unlock()
problems = append(problems, problem)
})
return ctx, func() []string {
mu.Lock()
defer mu.Unlock()
return slices.Clone(problems)
}
}
// setCookies gives the browser the cookies for the server at base.
func setCookies(base string, cookies []*http.Cookie) chromedp.ActionFunc {
return chromedp.ActionFunc(func(ctx context.Context) error {
for _, c := range cookies {
err := network.SetCookie(c.Name, c.Value).
WithURL(base).
Do(ctx)
if err != nil {
return fmt.Errorf("set cookie %s: %w", c.Name, err)
}
}
return nil
})
}
// loadPage opens url and waits for Alpine.js to start, which it does
// by removing every x-cloak attribute. Until then x-cloak hides the
// elements Alpine would hide, so a check made earlier proves nothing.
func loadPage(url string) chromedp.Tasks {
return chromedp.Tasks{
chromedp.Navigate(url),
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
}
}
// shown waits up to settleTimeout for the elements matching a CSS
// selector or an XPath expression to be rendered, and reports whether
// they were. The wait is needed because Alpine.js shows an element on
// the next animation frame, not at once.
func shown(ctx context.Context, selector string) bool {
ctx, cancel := context.WithTimeout(ctx, settleTimeout)
defer cancel()
return chromedp.Run(
ctx, chromedp.WaitVisible(selector, chromedp.BySearch),
) == nil
}
// hidden is shown's opposite: it waits for the elements to be hidden.
func hidden(ctx context.Context, selector string) bool {
ctx, cancel := context.WithTimeout(ctx, settleTimeout)
defer cancel()
return chromedp.Run(
ctx, chromedp.WaitNotVisible(selector, chromedp.BySearch),
) == nil
}
// click clicks the element matching an XPath expression.
func click(ctx context.Context, t *testing.T, xpath string) {
t.Helper()
require.NoError(t, chromedp.Run(
ctx, chromedp.Click(xpath, chromedp.BySearch),
))
}
// checkAddEntrypoint loads a webhook page and checks that the add
// entrypoint form stays hidden until the Add button beside its heading
// is clicked. The click looks for a button element there, so it also
// checks that Add is one.
func checkAddEntrypoint(ctx context.Context, t *testing.T, url string) {
t.Helper()
form := `form[action$="/entrypoints"]`
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
assert.True(t, hidden(ctx, form),
"the add entrypoint form shows before Add is clicked")
click(ctx, t, `//h2[text()="Entrypoints"]/following-sibling::button`)
assert.True(t, shown(ctx, form),
"the add entrypoint form stays hidden when Add is clicked")
}
// publicTargetURL is a destination the server accepts for an http or
// slack target. It is a literal public address, so accepting it needs
// no DNS.
const publicTargetURL = "https://93.184.216.34/hook"
// The parts of the targets section's add target form the checks below
// find and click. Add is the button beside the Targets heading; each
// Cancel is found from the button beside it, since both are on the
// page at once.
const (
addTarget = `//h2[text()="Targets"]/following-sibling::button`
typeSelect = `//select[@aria-label="Target type"]`
nextButton = `//button[text()="Next"]`
cancelChoice = nextButton + `/following-sibling::button[text()="Cancel"]`
saveButton = `//form[contains(@action, "/targets")]//button[text()="Save"]`
cancelFields = saveButton + `/following-sibling::button[text()="Cancel"]`
targetName = `form[action$="/targets"] input[name="name"]`
submittedKeys = `[...new FormData(
document.querySelector('form[action$="/targets"]')).keys()]`
)
// checkAddTarget loads a webhook page and walks the add target form for
// one target type. The form shows nothing until Add is clicked; Add
// shows only the type choice; Cancel there closes it; Next shows the
// type's own fields in place of the choice, and the form then submits
// exactly fields, so a field another type uses, such as url, is absent;
// Cancel closes it again. It then adds a target of the type, filling in
// values, and checks that the section lists it with that type.
func checkAddTarget(
ctx context.Context,
t *testing.T,
url, targetType string,
fields []string,
values map[string]string,
) {
t.Helper()
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
assert.Truef(t, hidden(ctx, typeSelect),
"%s: the type choice shows before Add is clicked", targetType)
assert.Truef(t, hidden(ctx, targetName),
"%s: the fields show before Add is clicked", targetType)
click(ctx, t, addTarget)
assert.Truef(t, shown(ctx, typeSelect),
"%s: Add does not show the type choice", targetType)
assert.Truef(t, hidden(ctx, targetName),
"%s: Add shows the fields before Next", targetType)
click(ctx, t, cancelChoice)
assert.Truef(t, hidden(ctx, typeSelect),
"%s: Cancel does not close the type choice", targetType)
chooseTargetType(ctx, t, targetType)
var submitted []string
require.NoError(t, chromedp.Run(
ctx, chromedp.Evaluate(submittedKeys, &submitted),
))
assert.Equalf(t, fields, submitted,
"%s: the form does not submit exactly the type's fields", targetType)
click(ctx, t, cancelFields)
assert.Truef(t, hidden(ctx, targetName),
"%s: Cancel does not close the fields", targetType)
assert.Truef(t, shown(ctx, addTarget),
"%s: Add does not come back after Cancel", targetType)
name := "added-" + targetType
chooseTargetType(ctx, t, targetType)
require.NoError(t, chromedp.Run(
ctx, chromedp.SetValue(targetName, name, chromedp.ByQuery),
))
for field, value := range values {
require.NoError(t, chromedp.Run(ctx, chromedp.SetValue(
`form[action$="/targets"] [name="`+field+`"]`, value,
chromedp.ByQuery,
)))
}
click(ctx, t, saveButton)
assert.Truef(t, shown(ctx, `//span[text()="`+name+
`"]/following-sibling::div/span[text()="`+targetType+`"]`),
"%s: the added target is not listed with its type", targetType)
}
// chooseTargetType clicks Add, picks targetType and clicks Next, and
// checks that the type's fields then show in place of the type choice.
func chooseTargetType(ctx context.Context, t *testing.T, targetType string) {
t.Helper()
click(ctx, t, addTarget)
require.NoError(t, chromedp.Run(
ctx, chromedp.SetValue(typeSelect, targetType, chromedp.BySearch),
))
click(ctx, t, nextButton)
assert.Truef(t, shown(ctx, targetName),
"%s: Next does not show the fields", targetType)
assert.Truef(t, hidden(ctx, typeSelect),
"%s: Next leaves the type choice showing", targetType)
assert.Truef(t, hidden(ctx, addTarget),
"%s: Add still shows while the form is open", targetType)
}
// checkRefusedTarget submits an http target the server refuses, a
// loopback destination, and checks that the page comes back with the
// form open on the http fields, the values entered and the reason, and
// that after Cancel the next Add starts with an empty form and no
// reason.
func checkRefusedTarget(ctx context.Context, t *testing.T, url string) {
t.Helper()
const (
refusedURL = "http://127.0.0.1/hook"
urlField = `form[action$="/targets"] input[name="url"]`
reason = `//div[@class="alert-error"]`
)
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
chooseTargetType(ctx, t, "http")
require.NoError(t, chromedp.Run(
ctx,
chromedp.SetValue(targetName, "refused", chromedp.ByQuery),
chromedp.SetValue(urlField, refusedURL, chromedp.ByQuery),
))
click(ctx, t, saveButton)
assert.True(t, shown(ctx, reason),
"a refused target does not show the reason")
var name, typed string
require.NoError(t, chromedp.Run(
ctx,
chromedp.Value(targetName, &name, chromedp.ByQuery),
chromedp.Value(urlField, &typed, chromedp.ByQuery),
))
assert.Equal(t, "refused", name,
"a refused target does not keep the name entered")
assert.Equal(t, refusedURL, typed,
"a refused target does not keep the url entered")
assert.True(t, shown(ctx, targetName),
"a refused target does not come back with the form open")
assert.True(t, hidden(ctx, typeSelect),
"a refused target comes back on the type choice")
click(ctx, t, cancelFields)
chooseTargetType(ctx, t, "http")
assert.True(t, hidden(ctx, reason),
"after Cancel, the next Add still shows the reason")
require.NoError(t, chromedp.Run(
ctx,
chromedp.Value(targetName, &name, chromedp.ByQuery),
chromedp.Value(urlField, &typed, chromedp.ByQuery),
))
assert.Empty(t, name, "after Cancel, the next Add keeps the name entered")
assert.Empty(t, typed, "after Cancel, the next Add keeps the url entered")
}
// checkCopy loads a webhook page and checks that the Copy control beside
// its entrypoint's URL is a button, and that clicking it copies the URL
// and says so: the button reads "Copied" only once the copy succeeded.
func checkCopy(ctx context.Context, t *testing.T, url string) {
t.Helper()
copyButton := `//button[@data-copy-target]`
// A browser lets the page in its active tab write to the clipboard
// on a click. A headless browser refuses unless told to allow it.
require.NoError(t, chromedp.Run(
ctx,
browser.SetPermission(
&browser.PermissionDescriptor{Name: "clipboard-write"},
browser.PermissionSettingGranted,
),
loadPage(url),
))
click(ctx, t, copyButton)
assert.True(t, shown(ctx, copyButton+`[text()="Copied"]`),
`clicking Copy does not show "Copied"`)
}
// checkEventLog loads the event log and checks that clicking an event's
// row expands it, that in there clicking its delivery shows the
// delivery's attempts and clicking again hides them, and that clicking
// the event's row again collapses it.
func checkEventLog(
ctx context.Context, t *testing.T, url, eventID, targetName string,
) {
t.Helper()
// The event's row shows its ID, and its Resubmit form is in the part
// that expands. The delivery's row there shows the target's name.
eventRow := `//span[text()="` + eventID + `"]`
expanded := `form[action$="/resubmit"]`
deliveryRow := `//span[text()="` + targetName + `"]`
attempt := `//span[text()="Attempt 1"]`
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
assert.True(t, hidden(ctx, expanded), "the event starts expanded")
click(ctx, t, eventRow)
assert.True(t, shown(ctx, expanded), "clicking the event does not expand it")
assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown")
click(ctx, t, deliveryRow)
assert.True(t, shown(ctx, attempt),
"clicking the delivery does not show its attempts")
click(ctx, t, deliveryRow)
assert.True(t, hidden(ctx, attempt),
"clicking the delivery again does not hide its attempts")
click(ctx, t, eventRow)
assert.True(t, hidden(ctx, expanded),
"clicking the event again does not collapse it")
}
// checkMobileMenu loads a page in a phone-sized window and checks that
// the menu button opens and closes the mobile menu.
func checkMobileMenu(ctx context.Context, t *testing.T, url string) {
t.Helper()
// The menu button is the only button directly in the navigation
// bar's top row. Profile is a link only the mobile menu has.
button := `//nav/div/button`
menu := `//nav//a[text()="Profile"]`
require.NoError(t, chromedp.Run(
ctx,
chromedp.EmulateViewport(phoneWidth, phoneHeight),
loadPage(url),
))
assert.True(t, hidden(ctx, menu), "the mobile menu starts open")
click(ctx, t, button)
assert.True(t, shown(ctx, menu), "the menu button does not open the menu")
click(ctx, t, button)
assert.True(t, hidden(ctx, menu), "the menu button does not close the menu")
}
+69
View File
@@ -0,0 +1,69 @@
package server_test
import (
"compress/gzip"
"encoding/json"
"net/http"
"regexp"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
)
// TestHook_DownloadArchive follows the Download link the webhook page
// shows for a database target, and only for it, and gets the archive
// as a gzipped JSON file. Signed out, the link leads to the login page.
func TestHook_DownloadArchive(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "archivist", "somepassword")
cookies := env.authCookies(t, userID, "archivist")
wh := env.seedWebhook(t, userID)
env.seedTarget(t, wh.ID)
archive := &database.Target{
WebhookID: wh.ID,
Name: "kept",
Type: database.TargetTypeDatabase,
Active: true,
}
require.NoError(t,
env.db.DB().Omit(clause.Associations).Create(archive).Error,
)
page := env.get("/hook/"+wh.ID, cookies)
require.Equal(t, http.StatusOK, page.Code)
links := regexp.MustCompile(
`href="(/hook/[^/"]+/targets/[^/"]+/download)"`,
).FindAllStringSubmatch(page.Body.String(), -1)
require.Len(t, links, 1, "only the database target has a Download")
link := links[0][1]
assert.Equal(t,
"/hook/"+wh.ID+"/targets/"+archive.ID+"/download", link,
)
w := env.get(link, cookies)
require.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "application/gzip", w.Header().Get("Content-Type"))
zr, err := gzip.NewReader(w.Body)
require.NoError(t, err)
var got map[string]json.RawMessage
require.NoError(t, json.NewDecoder(zr).Decode(&got))
assert.JSONEq(t,
`{"id":"`+wh.ID+`","name":"routed"}`, string(got["webhook"]),
)
w = env.get(link, nil)
assert.Equal(t, http.StatusSeeOther, w.Code)
assert.Contains(t, w.Header().Get("Location"), "/pages/login")
}
+2 -2
View File
@@ -191,7 +191,7 @@ func TestErrorPage_PanicOnAdminPage(t *testing.T) {
w := serve(
server.NewRouterWithPageProbeForTest(
env.log.Get(), env.cfg, env.mw, env.hnd,
t, env.log, env.cfg, env.mw, env.hnd,
true, panicProbeHandler,
),
server.PageProbePattern,
@@ -200,7 +200,7 @@ func TestErrorPage_PanicOnAdminPage(t *testing.T) {
w = serve(
server.NewRouterWithProbeForTest(
env.log.Get(), env.cfg, env.mw, env.hnd,
t, env.log, env.cfg, env.mw, env.hnd,
true, panicProbeHandler,
),
server.ProbePattern,
+56 -25
View File
@@ -1,13 +1,18 @@
package server
import (
"context"
"log/slog"
"net/http"
"testing"
"github.com/getsentry/sentry-go"
"github.com/go-chi/chi"
"github.com/stretchr/testify/require"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/middleware"
)
@@ -34,23 +39,53 @@ func SentryClientOptionsForTest(
return sentryClientOptions(dsn, release)
}
// CleanShutdownForTest runs the server's stop hook, cleanShutdown,
// against hs: a server the test started itself, so it can hold a
// request open across the drain. Sentry is off.
func CleanShutdownForTest(ctx context.Context, hs *http.Server) {
s := &Server{log: slog.New(slog.DiscardHandler), httpServer: hs}
s.cleanShutdown(ctx)
}
// newServerForTest builds a Server through New, as the application
// does, on a lifecycle that is never started: the hooks New adds to
// it never run, so nothing listens.
func newServerForTest(
t *testing.T,
log *logger.Logger,
cfg *config.Config,
mw *middleware.Middleware,
h *handlers.Handlers,
) *Server {
t.Helper()
s, err := New(fxtest.NewLifecycle(t), ServerParams{
Logger: log,
Config: cfg,
Middleware: mw,
Handlers: h,
})
require.NoError(t, err)
return s
}
// NewRouterForTest builds the real route tree via SetupRoutes with
// the supplied middleware and handlers, bypassing the fx lifecycle
// and the HTTP listener. Tests use it so that route-group middleware
// registration order is exercised exactly as it ships, rather than
// against a hand-rebuilt chain that could drift from routes.go.
// the supplied middleware and handlers, on a Server from New whose
// lifecycle is never started, so no HTTP listener runs. Tests use it
// so that route-group middleware registration order is exercised
// exactly as it ships, rather than against a hand-rebuilt chain that
// could drift from routes.go.
func NewRouterForTest(
log *slog.Logger,
t *testing.T,
log *logger.Logger,
cfg *config.Config,
mw *middleware.Middleware,
h *handlers.Handlers,
) http.Handler {
s := &Server{
log: log,
mw: mw,
h: h,
params: ServerParams{Config: cfg},
}
t.Helper()
s := newServerForTest(t, log, cfg, mw, h)
s.SetupRoutes()
return s.router
@@ -83,19 +118,17 @@ const ProbePattern = "/probe"
// option and the recoverer registered outside it is the thing a test
// has to be able to pin.
func NewRouterWithProbeForTest(
log *slog.Logger,
t *testing.T,
log *logger.Logger,
cfg *config.Config,
mw *middleware.Middleware,
h *handlers.Handlers,
sentryEnabled bool,
probe http.HandlerFunc,
) http.Handler {
s := &Server{
log: log,
mw: mw,
h: h,
params: ServerParams{Config: cfg},
}
t.Helper()
s := newServerForTest(t, log, cfg, mw, h)
s.sentryEnabled.Store(sentryEnabled)
s.SetupRoutes()
s.router.Handle(ProbePattern, probe)
@@ -113,19 +146,17 @@ const PageProbePattern = "/pages/probe"
// it, so the probe runs behind that group's own middleware exactly as
// the group's real routes do.
func NewRouterWithPageProbeForTest(
log *slog.Logger,
t *testing.T,
log *logger.Logger,
cfg *config.Config,
mw *middleware.Middleware,
h *handlers.Handlers,
sentryEnabled bool,
probe http.HandlerFunc,
) http.Handler {
s := &Server{
log: log,
mw: mw,
h: h,
params: ServerParams{Config: cfg},
}
t.Helper()
s := newServerForTest(t, log, cfg, mw, h)
s.sentryEnabled.Store(sentryEnabled)
s.SetupRoutes()
+2 -2
View File
@@ -199,7 +199,7 @@ func TestPanicProbeChild(t *testing.T) {
env := newTestEnv(t)
router := server.NewRouterWithProbeForTest(
env.log.Get(), env.cfg, env.mw, env.hnd,
t, env.log, env.cfg, env.mw, env.hnd,
false, panicProbeHandler,
)
@@ -253,7 +253,7 @@ func TestSentryStillSeesAPanic(t *testing.T) {
require.NoError(t, err)
router := server.NewRouterWithProbeForTest(
env.log.Get(), env.cfg, env.mw, env.hnd,
t, env.log, env.cfg, env.mw, env.hnd,
true, panicProbeHandler,
)
+2 -2
View File
@@ -67,11 +67,11 @@ func TestResponseControllerThroughProductionRouter(t *testing.T) {
routers := map[string]http.Handler{
server.ProbePattern: server.NewRouterWithProbeForTest(
env.log.Get(), env.cfg, env.mw, env.hnd,
t, env.log, env.cfg, env.mw, env.hnd,
tc.sentryEnabled, probe,
),
server.PageProbePattern: server.NewRouterWithPageProbeForTest(
env.log.Get(), env.cfg, env.mw, env.hnd,
t, env.log, env.cfg, env.mw, env.hnd,
tc.sentryEnabled, probe,
),
}
+215
View File
@@ -0,0 +1,215 @@
package server_test
import (
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// maxResubmits bounds the requests the tests below send to the
// resubmit route. The route's rate limit belongs to the middleware;
// this only has to sit well above it, so that a route without the
// limiter fails its test instead of looping.
const maxResubmits = 100
// resubmitPath is the resubmit route for one stored event.
func resubmitPath(webhookID, eventID string) string {
return "/hook/" + webhookID + "/events/" + eventID + "/resubmit"
}
// csrfForm is a resubmit form carrying the given CSRF token.
func csrfForm(token string) url.Values {
form := url.Values{}
form.Set("csrf_token", token)
return form
}
// TestEventResubmit_SignedOutRequestsNeverReachTheRateLimit pins
// RequireAuth on the resubmit route. The handler also turns away a
// request without a session, with the same redirect, so a refusal
// alone would pass without RequireAuth. What RequireAuth adds is that
// it refuses such a request before the route's rate limit, so a
// signed-out client cannot spend the budget a signed-in user
// resubmits from. Each request carries a CSRF token valid for its own
// cookie, so CSRF lets it through to RequireAuth.
func TestEventResubmit_SignedOutRequestsNeverReachTheRateLimit(
t *testing.T,
) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "resubmitter", "somepassword")
wh := env.seedWebhook(t, userID)
evt := env.seedEvent(t, wh.ID, `{"resubmit":"me"}`)
path := resubmitPath(wh.ID, evt.ID)
logsPath := "/hook/" + wh.ID + "/events"
token, signedOut := env.csrfFrom(t, "/pages/login", nil)
for i := range maxResubmits {
w := env.post(path, csrfForm(token), signedOut)
require.Equal(t, http.StatusSeeOther, w.Code, "request %d", i)
require.Equal(
t, "/pages/login", w.Header().Get("Location"),
"request %d", i,
)
}
require.Equal(
t, int64(1), env.countEvents(t, wh.ID),
"a signed-out request must store nothing",
)
token, cookies := env.csrfFrom(
t, logsPath, env.authCookies(t, userID, "resubmitter"),
)
env.requireNotice(
t, env.post(path, csrfForm(token), cookies),
logsPath, "resubmit-no-targets",
"this source has no active targets", cookies,
)
}
// TestEventResubmit_RefusedWithoutAValidCSRFToken pins CSRF on the
// resubmit route: a signed-in user's POST is refused with 403, and
// stores nothing, unless it carries the token issued to that user's
// own browser.
func TestEventResubmit_RefusedWithoutAValidCSRFToken(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "resubmitter", "somepassword")
wh := env.seedWebhook(t, userID)
evt := env.seedEvent(t, wh.ID, `{"resubmit":"me"}`)
path := resubmitPath(wh.ID, evt.ID)
logsPath := "/hook/" + wh.ID + "/events"
token, cookies := env.csrfFrom(
t, logsPath, env.authCookies(t, userID, "resubmitter"),
)
otherBrowsers, _ := env.csrfFrom(t, "/pages/login", nil)
for name, form := range map[string]url.Values{
"no token": {},
"a malformed token": csrfForm("not-a-token"),
"another browser's token": csrfForm(otherBrowsers),
} {
assert.Equal(
t, http.StatusForbidden,
env.post(path, form, cookies).Code, name,
)
}
assert.Equal(
t, int64(1), env.countEvents(t, wh.ID),
"a refused request must store nothing",
)
// The same request with the user's own token goes through, so the
// refusals above were the token's doing.
env.requireNotice(
t, env.post(path, csrfForm(token), cookies),
logsPath, "resubmit-no-targets",
"this source has no active targets", cookies,
)
}
// TestEventResubmit_AnotherWebhooksEvent404s pins, on the route as
// registered, that a signed-in user gets 404, and nothing is stored,
// for an event of a webhook another user owns, which the handler's
// ownership check refuses, and for another webhook's event posted
// under a webhook the user does own, which the event lookup refuses.
// The user's own event, posted the same way, is accepted, so the
// second 404 comes from the lookup and not from a route that never
// passed the event ID to the handler.
func TestEventResubmit_AnotherWebhooksEvent404s(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
ownerID, _ := env.seedUser(t, "owner", "somepassword")
owners := env.seedWebhook(t, ownerID)
ownersEvent := env.seedEvent(t, owners.ID, `{"owner":"only"}`)
intruderID, _ := env.seedUser(t, "intruder", "somepassword")
intruders := env.seedWebhook(t, intruderID)
intrudersEvent := env.seedEvent(
t, intruders.ID, `{"intruder":"own"}`,
)
intrudersLogs := "/hook/" + intruders.ID + "/events"
token, cookies := env.csrfFrom(
t, intrudersLogs, env.authCookies(t, intruderID, "intruder"),
)
for name, path := range map[string]string{
"another user's webhook": resubmitPath(
owners.ID, ownersEvent.ID,
),
"another webhook's event": resubmitPath(
intruders.ID, ownersEvent.ID,
),
} {
w := env.post(path, csrfForm(token), cookies)
assert.Equal(t, http.StatusNotFound, w.Code, name)
}
assert.Equal(t, int64(1), env.countEvents(t, owners.ID))
assert.Equal(t, int64(1), env.countEvents(t, intruders.ID))
env.requireNotice(
t,
env.post(
resubmitPath(intruders.ID, intrudersEvent.ID),
csrfForm(token), cookies,
),
intrudersLogs, "resubmit-no-targets",
"this source has no active targets", cookies,
)
}
// TestEventResubmit_RateLimited pins the rate limit on the resubmit
// route: a signed-in user's resubmits are accepted until the budget
// is spent, and then refused with 429.
func TestEventResubmit_RateLimited(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "resubmitter", "somepassword")
wh := env.seedWebhook(t, userID)
evt := env.seedEvent(t, wh.ID, `{"resubmit":"me"}`)
path := resubmitPath(wh.ID, evt.ID)
token, cookies := env.csrfFrom(
t, "/hook/"+wh.ID+"/events",
env.authCookies(t, userID, "resubmitter"),
)
limited := false
for range maxResubmits {
code := env.post(path, csrfForm(token), cookies).Code
if code == http.StatusTooManyRequests {
limited = true
break
}
require.Equal(
t, http.StatusSeeOther, code,
"a resubmit within the budget must be accepted",
)
}
assert.True(
t, limited, "repeated resubmits must eventually be refused",
)
}
+5 -1
View File
@@ -73,7 +73,7 @@ func (s *Server) setupGlobalMiddleware() {
}
s.router.Use(s.mw.CORS())
s.router.Use(middleware.Timeout(requestTimeout))
s.router.Use(s.mw.Timeout(requestTimeout))
// Panic recovery, deliberately here rather than first. It has to
// run inside every middleware that observes the response, so the
@@ -312,6 +312,10 @@ func (s *Server) setupSourceRoutes() {
"/targets/{targetID}/edit",
s.h.HandleTargetEditSubmit(),
)
r.Get(
"/targets/{targetID}/download",
s.h.HandleTargetDownload(),
)
r.Post(
"/targets/{targetID}/delete",
s.h.HandleTargetDelete(),
+63 -4
View File
@@ -136,7 +136,7 @@ func newTestEnvWithConfig(
t.Cleanup(app.RequireStop)
return &testEnv{
router: server.NewRouterForTest(log.Get(), cfg, mw, hnd),
router: server.NewRouterForTest(t, log, cfg, mw, hnd),
sess: sess,
db: db,
dbMgr: dbMgr,
@@ -444,6 +444,23 @@ func (e *testEnv) countDeliveries(
return count
}
// countEvents reports how many events a webhook's database holds.
func (e *testEnv) countEvents(t *testing.T, webhookID string) int64 {
t.Helper()
webhookDB, err := e.dbMgr.GetDB(webhookID)
require.NoError(t, err)
var count int64
require.NoError(
t,
webhookDB.Model(&database.Event{}).Count(&count).Error,
)
return count
}
// storedHash reads the current password hash for a username.
func (e *testEnv) storedHash(t *testing.T, username string) string {
t.Helper()
@@ -531,6 +548,48 @@ func TestStaticServesOnlyGetAndHead(t *testing.T) {
}
}
// --- every page route group ---
// TestPageRouteGroups_OversizeBody_RejectedBeforeCSRF pins the body
// cap ahead of CSRF and RequireAuth in every page route group. The
// requests carry no session and no CSRF token, so if either ran first
// the answer would be a 403 or a redirect to the login page rather
// than 413, and CSRF would issue its cookie (see
// TestPagesLogin_UnderLimit_NoToken_CSRFRejects). /settings has no
// POST route, but its group's middleware runs before the method is
// matched, so a POST there still reaches CSRF's form parsing if the
// cap moves after it. The user and webhook in the paths need not
// exist: nothing after the cap runs.
func TestPageRouteGroups_OversizeBody_RejectedBeforeCSRF(
t *testing.T,
) {
t.Parallel()
env := newTestEnv(t)
form := url.Values{}
form.Set("name", oversizeValue())
for _, path := range []string{
"/pages/login",
"/user/nobody/password",
"/settings/",
"/hooks/new",
"/hook/nonexistent/edit",
} {
w := env.post(path, form, nil)
assert.Equal(
t, http.StatusRequestEntityTooLarge, w.Code, path,
)
assert.False(
t, csrfCookieSet(w),
"CSRF middleware must not run for an oversized body to %s",
path,
)
}
}
// --- /pages group ---
// TestPagesLogin_OversizeBody_RejectedBeforeCSRF proves the cap runs
@@ -1178,12 +1237,12 @@ func TestHook_LinksBetweenPages(t *testing.T) {
// mobile menu link.
{
"/user/navigator/",
`href="([^"]+)" class="btn-text">Webhooks<`,
`href="([^"]+)" class="btn-secondary">Webhooks<`,
list,
},
{
"/user/navigator/",
`href="([^"]+)" class="btn-text w-full[^"]*">Webhooks<`,
`href="([^"]+)" class="btn-secondary w-full">Webhooks<`,
list,
},
{list, `href="(/hook/[^"]+)"`, page},
@@ -1610,7 +1669,7 @@ func TestTwoMetricsRoutersInOneProcess(t *testing.T) {
)
third := &testEnv{
router: server.NewRouterForTest(
first.log.Get(), first.cfg, first.mw, first.hnd,
t, first.log, first.cfg, first.mw, first.hnd,
),
}
+26 -3
View File
@@ -39,6 +39,12 @@ const (
// refuses to spend, leaving it for the hooks that run after the
// server: the delivery engine, the healthcheck, the webhook DB
// manager and the database close.
//
// Its value is not tuned to those hooks, which take about a
// millisecond between them. It is what the 5s fx stop timeout in
// cmd/webhooker leaves after a full ShutdownTimeout drain, so a
// drain that starts on a full budget still gets all of
// ShutdownTimeout.
TailHookReserve = 2 * time.Second
// sentryFlushTimeout is the longest wait for Sentry to flush
@@ -59,6 +65,16 @@ const (
// key off it, and a zero exit would read as a deliberate stop.
const StartupFailureExitCode = 1
// DrainBudget reports how long the HTTP drain may wait for in-flight
// requests when remaining is the time left on the fx stop context as
// the server's stop hook starts. The hooks before the server can
// already have spent part of the budget, so the drain takes its time
// out of what they left, never out of TailHookReserve. Zero or less
// means no wait at all.
func DrainBudget(remaining time.Duration) time.Duration {
return min(ShutdownTimeout, remaining-TailHookReserve)
}
// SentryFlushBudget reports how long the Sentry flush may run when
// remaining is the time left on the fx stop context after the HTTP
// drain. sentry.Flush takes a bare duration and honours no context,
@@ -261,10 +277,17 @@ func (s *Server) cleanupForExit() {
s.log.Info("cleaning up")
}
// cleanShutdown drains the HTTP server and flushes Sentry inside what
// is left of the fx stop budget. A context carrying no deadline — a
// caller outside the fx lifecycle — gets the full ShutdownTimeout.
func (s *Server) cleanShutdown(ctx context.Context) {
ctxShutdown, shutdownCancel := context.WithTimeout(
ctx, ShutdownTimeout,
)
drain := ShutdownTimeout
if deadline, ok := ctx.Deadline(); ok {
drain = DrainBudget(time.Until(deadline))
}
ctxShutdown, shutdownCancel := context.WithTimeout(ctx, drain)
defer shutdownCancel()
err := s.httpServer.Shutdown(ctxShutdown)
+135
View File
@@ -1,13 +1,148 @@
package server_test
import (
"context"
"net"
"net/http"
"testing"
"testing/synctest"
"time"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/server"
)
// TestDrainBudget covers the clamp that keeps the HTTP drain from
// spending the tail hooks' share of the fx stop budget when the hooks
// before the server have already used part of it.
func TestDrainBudget(t *testing.T) {
t.Parallel()
tests := []struct {
name string
remaining time.Duration
want time.Duration
}{
{
name: "only the reserve is left",
remaining: server.TailHookReserve,
want: 0,
},
{
name: "earlier hooks spent part of the budget",
remaining: server.TailHookReserve + time.Second,
want: time.Second,
},
{
name: "capped at the nominal timeout",
remaining: time.Hour,
want: server.ShutdownTimeout,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, server.DrainBudget(tt.remaining))
})
}
}
// TestCleanShutdown_LeavesTailHookReserve stops the server with a
// request still in flight, after the hooks before it have spent all
// of the stop budget but TailHookReserve. The drain must give up at
// once rather than wait for the request: what is left belongs to the
// hooks after the server, the database close among them. A drain
// bounded only by ShutdownTimeout waits until the stop context
// expires, and fx then skips those hooks.
//
// The test runs in a synctest bubble, whose clock moves only while
// every goroutine in it is blocked, so a drain that gives up at once
// leaves the stop context unexpired however slow the host is. The
// request travels over net.Pipe because a goroutine waiting on a
// real socket would stop that clock from moving at all.
func TestCleanShutdown_LeavesTailHookReserve(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
entered := make(chan struct{})
release := make(chan struct{})
hs := &http.Server{
Handler: http.HandlerFunc(
func(http.ResponseWriter, *http.Request) {
close(entered)
<-release
},
),
ReadHeaderTimeout: time.Second,
}
srvConn, cliConn := net.Pipe()
listener := pipeListener{
conns: make(chan net.Conn, 1),
closed: make(chan struct{}),
}
listener.conns <- srvConn
go func() { _ = hs.Serve(listener) }()
// Cleanups run last first: the handler returns, then closing
// the client end ends the server's write of the response.
t.Cleanup(func() { _ = cliConn.Close() })
t.Cleanup(func() { close(release) })
_, err := cliConn.Write(
[]byte("GET / HTTP/1.1\r\nHost: webhooker.test\r\n\r\n"),
)
require.NoError(t, err)
<-entered
stopCtx, cancel := context.WithTimeout(
t.Context(), server.TailHookReserve,
)
defer cancel()
server.CleanShutdownForTest(stopCtx, hs)
require.NoError(
t, stopCtx.Err(), "the drain spent the tail hooks' reserve",
)
})
}
// pipeListener is the net.Listener http.Server.Serve needs to serve
// the server end of a net.Pipe: Accept returns that one connection,
// then waits until Close, as a real listener with no more clients
// does.
type pipeListener struct {
conns chan net.Conn
closed chan struct{}
}
func (l pipeListener) Accept() (net.Conn, error) {
select {
case conn := <-l.conns:
return conn, nil
case <-l.closed:
return nil, net.ErrClosed
}
}
func (l pipeListener) Close() error {
close(l.closed)
return nil
}
// Addr is never called by http.Server.Serve.
func (pipeListener) Addr() net.Addr {
return nil
}
// TestSentryFlushBudget covers the clamp that keeps the Sentry flush
// from spending the tail hooks' share of the fx stop budget.
// sentry.Flush ignores the stop context, so without the clamp a
-10
View File
@@ -1,10 +0,0 @@
package session
import "github.com/gorilla/sessions"
// NewStore exposes the production cookie-store constructor so tests
// exercise the store the application actually runs with, rather than a
// lookalike assembled in the test.
func NewStore(key []byte) *sessions.CookieStore {
return newStore(key)
}
+7
View File
@@ -8,6 +8,13 @@ import (
"sneak.berlin/go/webhooker/internal/config"
)
// NewStore exposes the production cookie-store constructor so tests
// exercise the store the application actually runs with, rather than a
// lookalike assembled in the test.
func NewStore(key []byte) *sessions.CookieStore {
return newStore(key)
}
// NewForTest creates a Session with a pre-configured cookie store for use
// in tests. This bypasses the fx lifecycle and database dependency, allowing
// middleware and handler tests to use real session functionality. The key
+5 -4
View File
@@ -1,15 +1,16 @@
#!/bin/sh
# script/assets: extract Alpine.js from its npm package tarball, committed
# in 3p/, to static/js/alpine.min.js, where go:embed reads it. The
# extracted file is not committed. script/test, make build and make dev run
# this first.
# in 3p/, to static/js/alpine.min.js, where go:embed reads it. The package
# is @alpinejs/csp, Alpine's build for pages whose Content-Security-Policy
# forbids eval. The extracted file is not committed. script/test, make
# build and make dev run this first.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
tar -xzOf 3p/alpinejs-3.14.9.tgz package/dist/cdn.min.js \
tar -xzOf 3p/alpinejs-csp-3.14.9.tgz package/dist/cdn.min.js \
>static/js/alpine.min.js
}
+5 -1
View File
@@ -27,7 +27,11 @@
# Those figures predate tests hashing the admin password at 1 MB instead of
# 64 MB (https://git.eeqj.de/sneak/webhooker/pulls/404). After that change, in
# a cache-defeated build at host load 44-109 (2026-10-02), internal/handlers
# took 8.5s and the slowest package was internal/database at 15.8s.
# took 8.5s and the slowest package was internal/database at 15.8s. Once its
# retention tests seeded 50 rows per insert instead of 500
# (https://git.eeqj.de/sneak/webhooker/issues/198), internal/database took
# 7.3s and the slowest package was internal/handlers at 8.1s to 10.0s, at host
# load 25-48 (2026-10-02).
#
# -p 4 -parallel 8 keep the run under 2 GB of memory: at most four test
# binaries build or run at once, each with at most eight parallel tests. Under
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
# script/test-browser: run the browser test in internal/server. It runs in
# Docker: Dockerfile.browser builds the test and runs it in a digest-pinned
# headless browser image, so the host needs no browser.
#
# --no-cache-filter=browser runs the test again even when nothing changed;
# it must name the stage in Dockerfile.browser that runs it.
# --output=type=cacheonly leaves no image behind to clean up.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
docker build \
-f Dockerfile.browser \
--no-cache-filter=browser \
--progress=plain \
--output=type=cacheonly \
.
}
main "$@"
+48 -1
View File
@@ -1 +1,48 @@
/* Webhooker custom styles — see input.css for Tailwind theme */
/*
* The two shared styles for the controls a user clicks. Every page loads
* this file after tailwind.css. It is plain CSS: make css does not build
* it.
*
* A button is btn-primary, btn-secondary or btn-danger, from input.css.
* A secondary or inline action, such as Copy beside an entrypoint URL or
* Edit beside a target, is btn-small.
*
* Each card on the webhook list is a card-elevated link as a whole. It
* shows an Open label in btn-small and takes btn-small's focus outline.
*
* The rules join tailwind.css's components layer, where input.css puts
* its own, so a utility class on an element still overrides them.
*/
@layer components {
/* input.css gives its buttons no pointer cursor. */
.btn-primary,
.btn-secondary,
.btn-danger {
cursor: pointer;
}
.btn-small {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.625rem;
border: 1px solid var(--color-gray-300);
border-radius: var(--radius-md);
background-color: var(--color-white);
color: var(--color-primary-700);
font-size: var(--text-xs);
line-height: 1rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
}
.btn-small:hover {
border-color: var(--color-primary-500);
background-color: var(--color-primary-50);
}
.btn-small:focus-visible,
.card-elevated:focus-visible {
outline: 2px solid var(--color-primary-500);
outline-offset: 2px;
}
}
+105
View File
@@ -57,3 +57,108 @@
init();
}
})();
// Alpine.js components.
//
// The pages' Content-Security-Policy forbids eval, so the UI loads
// Alpine's CSP build, which cannot run expressions written in the
// markup: a directive in templates/ may only name a property or method,
// and each x-data names a component registered here. This script runs
// before Alpine, whose script tag is deferred, so this listener is in
// place when Alpine starts.
document.addEventListener("alpine:init", function () {
"use strict";
// Something a click shows and hides: the mobile menu, an add form,
// an event in the event log, a delivery's attempts.
window.Alpine.data("collapsible", function () {
return {
open: false,
toggle() {
this.open = !this.open;
},
get closed() {
return !this.open;
},
// Turns a downward caret up while open.
get caretClass() {
return { "rotate-180": this.open };
},
};
});
// The targets section's add target form, in three steps: closed,
// choosing a type, then filling in that type's fields. targetType
// is empty until Next takes it from the type select.
//
// The reason and the fields' values come from the properties below
// rather than from the markup, because each type's fields are made
// afresh from the markup whenever that type is chosen. A refused
// submission comes back with its type, reason and values in the
// section's data attributes, and starts on that type's fields with
// them. Cancel empties these properties and resets the form, which
// holds whatever was typed, so the next Add starts with an empty
// form and no reason.
window.Alpine.data("targetForm", function () {
return {
choosing: false,
targetType: "",
reason: "",
name: "",
url: "",
headers: "",
timeout: "",
maxRetries: "",
expiry: "",
init() {
const refused = this.$root.dataset;
this.targetType = refused.type;
this.reason = refused.reason;
this.name = refused.name;
this.url = refused.destination;
this.headers = refused.headers;
this.timeout = refused.timeout;
this.maxRetries = refused.maxRetries;
this.expiry = refused.expiry;
},
add() {
this.choosing = true;
},
next() {
this.targetType = this.$refs.type.value;
this.choosing = false;
},
cancel() {
this.choosing = false;
this.targetType = "";
this.reason = "";
this.name = "";
this.url = "";
this.headers = "";
this.timeout = "";
this.maxRetries = "";
this.expiry = "";
this.$refs.form.reset();
},
get filling() {
return this.targetType !== "";
},
get closed() {
return !this.choosing && !this.filling;
},
get isHttp() {
return this.targetType === "http";
},
get isSlack() {
return this.targetType === "slack";
},
get isDatabase() {
return this.targetType === "database";
},
get isLog() {
return this.targetType === "log";
},
};
});
});

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