Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9a4da00fb |
@@ -28,10 +28,10 @@ jobs:
|
||||
|
||||
- name: Fingerprint the build context
|
||||
# Writes the hash of the commit being checked into the context, which
|
||||
# invalidates the `COPY . .` layer of every check stage: a commit
|
||||
# that was never linted, format-checked, stylesheet-checked, tested
|
||||
# and built cannot report success from cache.
|
||||
# invalidates the `COPY . .` layer of both check stages: a commit
|
||||
# that was never linted, format-checked, tested and built cannot
|
||||
# report success from cache.
|
||||
run: git rev-parse HEAD > .ci-fingerprint
|
||||
|
||||
- name: Build Docker image (runs make fmt-check, golangci-lint, the stylesheet check, make test, make build)
|
||||
- name: Build Docker image (runs make fmt-check, golangci-lint, make test, make build)
|
||||
run: script/cibuild
|
||||
|
||||
+1
-39
@@ -25,55 +25,17 @@ COPY . .
|
||||
# would need a docker daemon inside the build. Keep these steps in step with
|
||||
# Dockerfile.lint, including --network=none (see its header for why).
|
||||
RUN make fmt-check
|
||||
RUN script/assets
|
||||
RUN --network=none golangci-lint config verify --config .golangci.yml
|
||||
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
|
||||
|
||||
# Stylesheet stages. static/css/tailwind.css is generated, by this pinned
|
||||
# tailwindcss, from static/css/input.css and the files its @source lines
|
||||
# name. `make css` (script/css) writes it out from the css-output stage.
|
||||
# The css-check stage fails when the committed file differs from what is
|
||||
# generated; `make check` runs it, and so does the build stage below.
|
||||
#
|
||||
# tailwindcss v4.2.1 standalone CLI, released 2026-02-23: one binary per
|
||||
# architecture, each pinned by its sha256 from the release's sha256sums.txt.
|
||||
# debian:bookworm-slim, 2026-10-02: the binary needs glibc.
|
||||
FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 AS tailwind-amd64
|
||||
ADD --checksum=sha256:39e8d4e24b3c83b0a6e69e100a972fbc75d5fef8dce47b3ddac3cf92dea81fe3 --chmod=755 \
|
||||
https://github.com/tailwindlabs/tailwindcss/releases/download/v4.2.1/tailwindcss-linux-x64 /usr/local/bin/tailwindcss
|
||||
|
||||
FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 AS tailwind-arm64
|
||||
ADD --checksum=sha256:d87e6486bb3f70b04ef1dcaacc4ee6548a5a15fbf521b31bc24d2c774f68a951 --chmod=755 \
|
||||
https://github.com/tailwindlabs/tailwindcss/releases/download/v4.2.1/tailwindcss-linux-arm64 /usr/local/bin/tailwindcss
|
||||
|
||||
# TARGETARCH, set by docker, is the architecture being built for.
|
||||
FROM tailwind-${TARGETARCH} AS css
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN tailwindcss -i static/css/input.css -o /out/tailwind.css --minify
|
||||
|
||||
FROM scratch AS css-output
|
||||
COPY --from=css /out/tailwind.css /
|
||||
|
||||
# Both files are split after each "}", one rule per line, so that when they
|
||||
# differ the diff shows the rules that differ.
|
||||
FROM css AS css-check
|
||||
RUN sed 's/}/}\n/g' static/css/tailwind.css > /tmp/committed.css \
|
||||
&& sed 's/}/}\n/g' /out/tailwind.css > /tmp/generated.css \
|
||||
&& diff -U0 /tmp/committed.css /tmp/generated.css || { \
|
||||
echo "static/css/tailwind.css is not what make css generates; run make css" >&2; \
|
||||
exit 1; \
|
||||
}
|
||||
|
||||
# Build stage
|
||||
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
||||
# Using Debian-based image because gorm.io/driver/sqlite pulls in
|
||||
# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl.
|
||||
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder
|
||||
|
||||
# Depend on the lint and stylesheet check stages passing
|
||||
# Depend on lint stage passing
|
||||
COPY --from=lint /src/go.sum /dev/null
|
||||
COPY --from=css-check /out/tailwind.css /dev/null
|
||||
|
||||
# jq is a runtime dependency of script/ci-mark-superseded, which the test
|
||||
# suite executes. git is what script/version derives the version with.
|
||||
|
||||
@@ -31,10 +31,6 @@ FROM deps AS lint
|
||||
|
||||
COPY . .
|
||||
|
||||
# static/static.go embeds the Alpine.js file this extracts from 3p/; without
|
||||
# it the static package does not compile and cannot be linted.
|
||||
RUN script/assets
|
||||
|
||||
# `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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: bootstrap setup assets test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css css-check 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
|
||||
@@ -74,7 +74,4 @@ hooks:
|
||||
@script/install-precommit
|
||||
|
||||
css:
|
||||
@script/css
|
||||
|
||||
css-check:
|
||||
@script/css-check
|
||||
tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
|
||||
|
||||
@@ -19,14 +19,12 @@ before deploying one.
|
||||
### Prerequisites
|
||||
|
||||
- Go 1.26.1+ (the version in `go.mod`)
|
||||
- Docker (for `make lint` and `make css`, and so for `make check`, for the
|
||||
browser test in `make test-browser`, for 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
|
||||
digest-pinned linter image via `Dockerfile.lint`. The same holds for
|
||||
tailwindcss (see [Stylesheet](#stylesheet)).
|
||||
digest-pinned linter image via `Dockerfile.lint`.
|
||||
|
||||
### Quick Start
|
||||
|
||||
@@ -38,7 +36,7 @@ cd webhooker
|
||||
# Install the Go toolchain if missing, and the Go dependencies
|
||||
make bootstrap
|
||||
|
||||
# Run all checks (test, lint, format check, stylesheet check)
|
||||
# Run all checks (test, lint, format check)
|
||||
make check
|
||||
|
||||
# Run the server from the clone. DATA_DIR defaults to
|
||||
@@ -61,7 +59,7 @@ 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 + css-check (CI gate)
|
||||
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
|
||||
make run # build, then run ./bin/webhooker
|
||||
@@ -69,8 +67,7 @@ make dev # go run ./cmd/webhooker
|
||||
make deps # go mod download + go mod tidy
|
||||
make docker # Build Docker image
|
||||
make hooks # Install git pre-commit hook that runs script/precommit
|
||||
make css # Regenerate static/css/tailwind.css (tailwindcss in Docker)
|
||||
make css-check # Fail if static/css/tailwind.css is stale (writes nothing)
|
||||
make css # Regenerate static/css/tailwind.css (needs tailwindcss)
|
||||
make clean # Remove bin/
|
||||
```
|
||||
|
||||
@@ -572,9 +569,9 @@ A start that finds no `webhooker.db` in `DATA_DIR`, or a zero-length
|
||||
one (which SQLite opens as an empty database), also logs
|
||||
`created a new, empty database` at `WARN`, with the file's path,
|
||||
shortly before the banner. On a deployment that has run before, that
|
||||
line means `webhooker.db` was lost: either the file was missing, most
|
||||
often because the volume holding `DATA_DIR` is not mounted, or it was
|
||||
zero-length, as a truncated copy leaves it.
|
||||
line means `webhooker.db` was lost: either `DATA_DIR` was empty, most
|
||||
often because its volume is not mounted, or the file was zero-length,
|
||||
as a truncated copy leaves it.
|
||||
|
||||
#### Recovering a lost admin password
|
||||
|
||||
@@ -1067,8 +1064,7 @@ Archive databases are the one exception the service is built for: the
|
||||
archive writer closes and reopens its handle around writes (debounced
|
||||
to at most one reopen per second), so an operator can move an
|
||||
`archive-….db` away for offline retention while the service runs,
|
||||
and it is recreated on the next write. The webhook page names each
|
||||
`database` target's archive file. See
|
||||
and it is recreated on the next write. See
|
||||
[Database Architecture](#database-architecture). That is a
|
||||
move-the-file-away workflow, not a substitute for the backup procedures
|
||||
above.
|
||||
@@ -1093,10 +1089,8 @@ with any `-wal`/`-shm` beside it, or wait until there are none.
|
||||
1. Stop the service.
|
||||
|
||||
2. Restore the **whole set together**: `webhooker.db` *and* every
|
||||
`events-*.db` *and* every `archive-*.db`. A restore that leaves out
|
||||
`webhooker.db` or an `events-*.db` is reported, not refused; one
|
||||
that leaves out an `archive-*.db` or a `-wal` (step 3) is not
|
||||
reported at all. Every database is opened `mode=rwc`, so a
|
||||
`events-*.db` *and* every `archive-*.db`. A partial restore is
|
||||
reported, not refused. Every database is opened `mode=rwc`, so a
|
||||
missing `events-{uuid}.db` is **created empty**: the webhook comes
|
||||
back with its configuration intact and its entire event history
|
||||
gone. The first start after the restore logs
|
||||
@@ -1130,7 +1124,7 @@ unconditionally against whatever files it finds:
|
||||
- the main database on connect — `Setting`, `User`, `APIKey`, `Webhook`,
|
||||
`Entrypoint`, `Target`
|
||||
- each event database when it is lazily opened — `Event`, `Delivery`,
|
||||
`DeliveryResult`, `EventTotals`, `TargetTotals`, `EntrypointTotals`
|
||||
`DeliveryResult`, `EventTotals`, `TargetTotals`
|
||||
- each archive database on every open and reopen
|
||||
|
||||
There is no schema version table, no migration ledger, and no down
|
||||
@@ -1295,11 +1289,11 @@ 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. Thirteen of the Makefile's nineteen targets are thin
|
||||
shims that call them; `build`, `run`, `dev`, `deps`, `clean` and `version`
|
||||
are inline commands with no script behind them, though `build`, `run` and
|
||||
`dev` first run `script/assets`, and `build` and `version` both take their
|
||||
value from `script/version`.
|
||||
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
|
||||
take their value from `script/version`.
|
||||
|
||||
`script/test`, `make build` and `make dev` each run `script/assets`
|
||||
first, which writes the ignored `static/js/alpine.min.js` (see
|
||||
@@ -1321,11 +1315,7 @@ We provide:
|
||||
- `script/lint` — run golangci-lint in Docker (see Linting below)
|
||||
- `script/fmt` — format all code (writes)
|
||||
- `script/fmt-check` — check formatting (read-only)
|
||||
- `script/css` — regenerate `static/css/tailwind.css` in Docker (writes;
|
||||
see [Stylesheet](#stylesheet))
|
||||
- `script/css-check` — fail if `static/css/tailwind.css` differs from what
|
||||
`script/css` would generate (read-only)
|
||||
- `script/check` — run test, lint, fmt-check, and css-check
|
||||
- `script/check` — run test, lint, and fmt-check
|
||||
- `script/version` — output the version to stamp into the binary (see
|
||||
[Version stamping](#version-stamping))
|
||||
- `script/docker` — build the Docker image tagged via
|
||||
@@ -1350,31 +1340,20 @@ markup. The CSP build runs no expressions, so every Alpine directive 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, its edit pages 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; a refused save on the target edit page
|
||||
and on the webhook edit page comes back with the reason and every value
|
||||
entered; the Copy button beside an entrypoint URL reads "Copied" once clicked;
|
||||
an entrypoint's Edit button shows its edit form in place of its description and
|
||||
hides until the form closes, Cancel hides the form and drops what was typed, as
|
||||
does leaving the page and going back to it, and Save changes the description;
|
||||
of the recent events on the webhook page only the newest starts expanded, each
|
||||
expands and collapses, and Open leads to the event's own page; an event in the
|
||||
event log expands and collapses when its row's caret or its ID is clicked, and
|
||||
from the keyboard, but not when its ID is selected with the mouse, and a
|
||||
delivery's attempts inside it expand and collapse; 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.
|
||||
A browser test in `internal/server` loads the webhook page and the event log
|
||||
under the real policy and checks that: both add forms stay hidden until Add is
|
||||
clicked; choosing Slack in the add target form leaves the HTTP fields out of
|
||||
what it submits, also after leaving the page and going back to it, when the
|
||||
browser restores the choice; 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
|
||||
@@ -1387,9 +1366,7 @@ apply. The directory is `3p/` rather than `vendor/` because Go treats a root
|
||||
where `go:embed` picks it up. `script/test`, `make build` and `make dev` run
|
||||
it first, and the Dockerfile builds through `make test` and `make build`, so
|
||||
nothing downloads Alpine.js. The extracted file is not committed, and
|
||||
`.dockerignore` keeps any host copy out of the build context. `static/static.go`
|
||||
names every file it embeds, so a build that skips the extraction, such as a
|
||||
bare `go build`, fails with an error naming `js/alpine.min.js`.
|
||||
`.dockerignore` keeps any host copy out of the build context.
|
||||
|
||||
To move to a new version: download
|
||||
`https://registry.npmjs.org/@alpinejs/csp/-/csp-<version>.tgz`, check it against
|
||||
@@ -1398,23 +1375,6 @@ the `dist.integrity` hash listed at
|
||||
`3p/` with it as `alpinejs-csp-<version>.tgz`, update its file name in
|
||||
`script/assets`, and run `make check` and `make test-browser`.
|
||||
|
||||
## Stylesheet
|
||||
|
||||
`static/css/tailwind.css` is generated by Tailwind and committed. To change the
|
||||
styles, edit the templates, `static/js/app.js`,
|
||||
`internal/handlers/recent_events.go` or `static/css/input.css`, run `make css`,
|
||||
and commit the regenerated file with the change. Tailwind takes classes only
|
||||
from the files that `input.css` names in its `@source` lines; a class written in
|
||||
any other file is not generated until that file is named there too. `make check`
|
||||
and the image build fail when the committed file differs from what `make css`
|
||||
generates. `static/css/style.css` is hand-written and is not generated.
|
||||
|
||||
`make css` runs the Tailwind standalone CLI in Docker, at the version and sha256
|
||||
pinned in the Dockerfile's stylesheet stages; it is never installed on the host.
|
||||
To move to a new version, change the version in both download URLs and both
|
||||
sha256 sums, taken from the release's `sha256sums.txt`, then run `make css` and
|
||||
commit the result.
|
||||
|
||||
## Rationale
|
||||
|
||||
Webhook integrations between services are inherently fragile. The
|
||||
@@ -1558,9 +1518,6 @@ tier** (event ingestion, delivery, and logging).
|
||||
│ ┌──────────────┐ (one row per target: running counts │
|
||||
│ │ TargetTotals │ of its deliveries) │
|
||||
│ └──────────────┘ │
|
||||
│ ┌──────────────────┐ (one row per entrypoint: when the │
|
||||
│ │ EntrypointTotals │ last event arrived on its URL) │
|
||||
│ └──────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -1607,13 +1564,6 @@ more entrypoints (receiver URLs) and one or more targets (delivery
|
||||
destinations) into a logical unit. A user creates a webhook to set up
|
||||
event routing.
|
||||
|
||||
The new webhook form can also give the webhook its first targets: an
|
||||
optional HTTP target URL creates an `http` target named `HTTP`, and the
|
||||
archive checkbox creates a `database` target named `Archive` whose
|
||||
`expiry` is the pruning chosen beside it (never, 1h, 12h, 24h, 30d, 90d
|
||||
or 365d). Both are validated as on the add target form, and the webhook
|
||||
and its targets are created together or not at all.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------------- | ------- | ----------- |
|
||||
| `id` | UUID | Primary key |
|
||||
@@ -1681,11 +1631,6 @@ different event sources that all feed into the same processing pipeline
|
||||
(e.g., one entrypoint for GitHub, another for Stripe, both routing to
|
||||
the same targets).
|
||||
|
||||
The webhook page shows, for each entrypoint, when the last event arrived
|
||||
on its URL, which retention leaves in place, or "never" if none ever has,
|
||||
and how many events arrived on it within the webhook's retention period.
|
||||
A resubmitted event did not arrive on the URL and counts in neither.
|
||||
|
||||
#### Target
|
||||
|
||||
A delivery destination for events. Each target defines where and how
|
||||
@@ -1722,11 +1667,8 @@ events should be forwarded.
|
||||
own archive database
|
||||
(`archive-{webhook_name}-{target_name}-{target_uuid}.db`) for long-term
|
||||
retention, with an optional creation-validated expiry (default: keep
|
||||
forever). The new webhook form, the add target form and the target edit
|
||||
form all offer the same expiries: never, 1h, 12h, 24h, 30d, 90d or 365d.
|
||||
The target list shows the expiry in plain units, such as "30 days". No
|
||||
external delivery and no retries; an archive write failure fails the
|
||||
delivery. See the database target section under
|
||||
forever). No external delivery and no retries; an archive write
|
||||
failure fails the delivery. See the database target section under
|
||||
"Per-Webhook Event Databases" for the full semantics.
|
||||
- **`log`** — Write the event to the application log (stdout). Useful
|
||||
for debugging.
|
||||
@@ -1898,11 +1840,10 @@ retries) is individually logged for full observability.
|
||||
|
||||
**Relations:** Belongs to Delivery.
|
||||
|
||||
#### EventTotals, TargetTotals and EntrypointTotals
|
||||
#### EventTotals and TargetTotals
|
||||
|
||||
Running counts in each event database, read by the statistics pane at the
|
||||
top of the webhook page and by the webhook list, and each entrypoint's last
|
||||
event, read by the webhook page's entrypoint list. `EventTotals` is one row:
|
||||
top of the webhook page and by the webhook list. `EventTotals` is one row:
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------------- | --------- | ----------- |
|
||||
@@ -1921,26 +1862,18 @@ event, read by the webhook page's entrypoint list. `EventTotals` is one row:
|
||||
| `deliveries_removed` | integer | Its deliveries retention has deleted |
|
||||
| `failed_removed` | integer | Its failed deliveries retention has deleted |
|
||||
|
||||
`EntrypointTotals` is one row per entrypoint, created by the first event
|
||||
that arrives on its URL:
|
||||
|
||||
| Field | Type | Description |
|
||||
| --------------- | --------- | ----------- |
|
||||
| `entrypoint_id` | UUID | The entrypoint (primary key) |
|
||||
| `last_event_at` | timestamp | When the newest event arrived on its URL; a resubmitted event leaves it as it is, and so does retention |
|
||||
|
||||
Each count changes in the transaction that writes or deletes the rows it counts,
|
||||
and each `last_event_at` in the transaction that stores the event. The pane's
|
||||
lifetime events are `events`, and its lifetime deliveries and failures are
|
||||
`deliveries` and `failed` summed over the targets; each figure within retention
|
||||
is the same less what retention removed, so neither needs the rows themselves.
|
||||
Its last event is `last_event_at` in `EventTotals`, so it still shows once
|
||||
retention has removed every event; each entrypoint's last event, from
|
||||
`EntrypointTotals`, does too. Its last-10-minutes and last-24-hours figures are
|
||||
counted from the `events` and `deliveries` indexes over just that window, the
|
||||
deliveries in one query grouped by target. Its failure percentage for a window
|
||||
is the deliveries that became `failed` in it out of all that became `delivered`
|
||||
or `failed` in it, and a dash when none did.
|
||||
Each count changes in the transaction that writes or deletes the rows it
|
||||
counts. The pane's lifetime events are `events`, and its lifetime
|
||||
deliveries and failures are `deliveries` and `failed` summed over the
|
||||
targets; each figure within retention is the same less what retention
|
||||
removed, so neither needs the rows themselves. Its last event is
|
||||
`last_event_at`, written in the transaction that stores the event, so it
|
||||
still shows once retention has removed every event. Its last-10-minutes and
|
||||
last-24-hours figures are counted from the `events` and `deliveries`
|
||||
indexes over just that window, the deliveries in one query grouped by
|
||||
target. Its failure percentage for a window is the deliveries that became
|
||||
`failed` in it out of all that became `delivered` or `failed` in it, and
|
||||
a dash when none did.
|
||||
|
||||
The webhook list at `/hooks` shows three of the pane's figures for each
|
||||
webhook: its events within retention and its last event, both from
|
||||
@@ -1950,12 +1883,6 @@ counted with the pane's query. It opens each webhook's event database once
|
||||
with the number of webhooks and, for each, with the deliveries that
|
||||
finished in the last 24 hours, never with the events stored.
|
||||
|
||||
The target list on the webhook page shows, for each target, its
|
||||
`delivered` and `failed` totals, which retention does not reduce, and its
|
||||
deliveries that became `delivered` and `failed` in the last 24 hours,
|
||||
counted with the pane's query. Deliveries still `pending` or `retrying`
|
||||
count in neither.
|
||||
|
||||
#### Event-tier indexes
|
||||
|
||||
These indexes on the per-webhook event databases are declared in the model
|
||||
@@ -1963,36 +1890,28 @@ tags, so `AutoMigrate` creates them on a fresh database:
|
||||
|
||||
| Table | Columns | Serves |
|
||||
| ------------------ | --------------------------- | ------ |
|
||||
| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, and the webhook page's statistics and target list and the webhook list, which count each target's deliveries by status and when they finished |
|
||||
| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, and the webhook page's statistics and the webhook list, which count each target's deliveries by status and when they finished |
|
||||
| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which counts and deletes the deliveries of expired events |
|
||||
| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events |
|
||||
| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events |
|
||||
| `events` | `resubmitted_from_id`, `deleted_at` | The event log, which counts the events resubmitted from each event on a page |
|
||||
| `events` | `entrypoint_id`, `deleted_at`, `resubmitted_from_id`, `created_at` | The webhook page's entrypoint list, which counts the events that arrived on each entrypoint's URL within the retention period |
|
||||
| `events` | `created_at` | Retention, which selects expired events by age |
|
||||
|
||||
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention leaves
|
||||
it out. SQLite keeps no statistics on these tables, and without them it rates
|
||||
the `deleted_at` index, which every live row matches, above an index on a column
|
||||
matched against several values or compared with a range. So every index but the
|
||||
last also covers `deleted_at`. It comes second in the `event_id` and
|
||||
`delivery_id` indexes, so that retention can use them without it. The event
|
||||
log's count, the one query on the `resubmitted_from_id` index, always carries
|
||||
`deleted_at IS NULL` and uses both columns. The entrypoint list's count, the one
|
||||
query on the `entrypoint_id` index, uses all four, `resubmitted_from_id IS NULL`
|
||||
leaving out resubmitted copies and `created_at` last because it compares it with
|
||||
a range (`>=`). In the statistics' `events` index `deleted_at` comes first,
|
||||
because they compare `created_at` with a range (`>=`) and SQLite narrows by a
|
||||
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention
|
||||
leaves it out. SQLite keeps no statistics on these tables, and without them it
|
||||
rates the `deleted_at` index, which every live row matches, above an index on
|
||||
a column matched against several values or compared with a range. So every
|
||||
index but the last also covers `deleted_at`. It comes second, so that
|
||||
retention can use the index without it, except in `events`, where the
|
||||
statistics compare `created_at` with a range (`>=`) and SQLite narrows by a
|
||||
range only on the last column it uses.
|
||||
|
||||
#### Common Fields
|
||||
|
||||
Every entity except `Setting`, `EventTotals`, `TargetTotals` and
|
||||
`EntrypointTotals` includes these fields from `BaseModel`. `Setting` is a bare
|
||||
key-value row with no `id`, no timestamps and no soft delete. Of the three
|
||||
totals tables, `event_totals` holds counts and `last_event_at`, keyed by a
|
||||
numeric `id`; `target_totals` holds counts, keyed by `target_id`; and
|
||||
`entrypoint_totals` holds `last_event_at`, keyed by `entrypoint_id`:
|
||||
Every entity except `Setting`, `EventTotals` and `TargetTotals` includes
|
||||
these fields from `BaseModel`. `Setting` is a bare key-value row with no
|
||||
`id`, no timestamps and no soft delete, and the two totals tables hold
|
||||
counts, plus `last_event_at` in `event_totals`, keyed by a numeric `id`
|
||||
and by `target_id`:
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------ | --------- | ----------- |
|
||||
@@ -2034,9 +1953,8 @@ encryption key is generated and stored, and an `admin` user is created.
|
||||
- **Events** — captured incoming webhook payloads
|
||||
- **Deliveries** — event-to-target pairings and their status
|
||||
- **DeliveryResults** — individual delivery attempt logs
|
||||
- **EventTotals**, **TargetTotals** and **EntrypointTotals** — running
|
||||
counts of the above, the deliveries per target, and each entrypoint's
|
||||
last event, kept through retention
|
||||
- **EventTotals** and **TargetTotals** — running counts of the above,
|
||||
the deliveries per target, kept through retention
|
||||
|
||||
Per-webhook databases are created automatically when a webhook is
|
||||
created. They are managed by the `WebhookDBManager` component, which
|
||||
@@ -2142,37 +2060,6 @@ 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.
|
||||
|
||||
The webhook page shows, for each `database` target, its archive file's
|
||||
name, its size on disk and when it was last written. The size counts
|
||||
the `.db` and its `-wal` together, and the last write is the later of
|
||||
their two modification times, since a write lands in the `-wal` first.
|
||||
Both are read from the files' metadata; the archive is never opened.
|
||||
Before the first write, and after the file has been moved away, the page
|
||||
shows `not created yet` beside the name.
|
||||
|
||||
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
|
||||
@@ -2751,10 +2638,11 @@ 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; 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.
|
||||
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.
|
||||
See `#### What DEBUG=true exposes` under Configuration.
|
||||
|
||||
What that ceiling does **not** cover, stated here so the figure is not
|
||||
@@ -3030,18 +2918,15 @@ returns to the page that was asked for.
|
||||
| `POST` | `/hook/{id}/edit` | Edit webhook submission |
|
||||
| `POST` | `/hook/{id}/delete` | Delete webhook |
|
||||
| `GET` | `/hook/{id}/events` | Full Event Log |
|
||||
| `GET` | `/hook/{id}/events/{eventID}` | One event's own page: its details, its whole body and every delivery of it |
|
||||
| `GET` | `/hook/{id}/events/{eventID}/body` | Download an event's stored body. The pages show a body as text, cut at 32 KiB in the recent events and the event log, and leave a binary one out, so this is the only route that serves the stored bytes; it is offered wherever a body is cut or binary |
|
||||
| `GET` | `/hook/{id}/events/{eventID}/body` | Download an event's full stored body. The log page renders each body only up to its cap, so this is the only route that serves a whole one; it is offered wherever a body is shown truncated |
|
||||
| `POST` | `/hook/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) |
|
||||
| `POST` | `/hook/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) |
|
||||
| `POST` | `/hook/{id}/entrypoints` | Add entrypoint to webhook |
|
||||
| `POST` | `/hook/{id}/entrypoints/{entrypointID}/edit` | Change an entrypoint's description; its URL stays the same |
|
||||
| `POST` | `/hook/{id}/entrypoints/{entrypointID}/delete` | Delete an entrypoint |
|
||||
| `POST` | `/hook/{id}/entrypoints/{entrypointID}/toggle` | Enable or disable an entrypoint |
|
||||
| `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 |
|
||||
|
||||
@@ -3101,7 +2986,7 @@ webhooker/
|
||||
│ │ ├── model_event.go # Event entity (per-webhook DB)
|
||||
│ │ ├── model_delivery.go # Delivery entity (per-webhook DB)
|
||||
│ │ ├── model_delivery_result.go # DeliveryResult entity (per-webhook DB)
|
||||
│ │ ├── model_totals.go # EventTotals, TargetTotals and EntrypointTotals (per-webhook DB)
|
||||
│ │ ├── model_totals.go # EventTotals and TargetTotals (per-webhook DB)
|
||||
│ │ ├── model_apikey.go # APIKey entity
|
||||
│ │ ├── password.go # Argon2id hashing and verification
|
||||
│ │ ├── retention.go # Retention reaper (per-webhook event expiry)
|
||||
@@ -3123,7 +3008,6 @@ 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
|
||||
@@ -3177,10 +3061,10 @@ webhooker/
|
||||
│ └── 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 # Stages: lint, stylesheet, test+build, Alpine runtime
|
||||
├── Dockerfile # Three stages: lint, test+build, Alpine runtime
|
||||
├── Dockerfile.lint # Lint-only image built by script/lint
|
||||
├── Dockerfile.browser # Browser test image built by script/test-browser
|
||||
├── Makefile # 13 of 19 targets shim script/; 6 are inline
|
||||
├── Makefile # 11 of 18 targets shim script/; 7 are inline
|
||||
├── go.mod / go.sum
|
||||
└── .golangci.yml # Linter configuration
|
||||
```
|
||||
@@ -3377,9 +3261,9 @@ check, see [The login endpoint](#the-login-endpoint).
|
||||
`ENTRYPOINT` script, which sets the data directory's owner and mode
|
||||
before the app starts; the image's health check; and `docker exec`,
|
||||
unless given `--user`
|
||||
- GORM soft deletes on every entity that carries `BaseModel`, which is all of
|
||||
them but `Setting`, `EventTotals`, `TargetTotals` and `EntrypointTotals`
|
||||
(data preserved for audit)
|
||||
- GORM soft deletes on every entity that carries `BaseModel`, which is
|
||||
all of them but `Setting`, `EventTotals` and `TargetTotals` (data
|
||||
preserved for audit)
|
||||
|
||||
### Shutdown
|
||||
|
||||
@@ -3390,9 +3274,9 @@ each hook. The order, read off the fx stop-hook log:
|
||||
|
||||
1. `ArchiveSweeper`
|
||||
2. `RetentionReaper`
|
||||
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
|
||||
3. `server` — the HTTP drain, bounded separately by
|
||||
`server.ShutdownTimeout` (**3 seconds**), then a Sentry flush if
|
||||
`SENTRY_DSN` is set
|
||||
4. `delivery.Engine` — waits for its workers, then closes the archive
|
||||
databases
|
||||
5. `healthcheck`
|
||||
@@ -3412,30 +3296,23 @@ 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. The reserve is that
|
||||
remainder, not a figure sized to the tail, which takes about a
|
||||
millisecond.
|
||||
(`server.TailHookReserve`) for the tail, which is far more than the
|
||||
microseconds it needs.
|
||||
|
||||
That reserve belongs to the tail hooks, not to the server hook, and
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
The value is chosen to sit inside the container stop grace period.
|
||||
Docker's default `docker stop` grace is 10 seconds and the Dockerfile
|
||||
@@ -3494,26 +3371,17 @@ Three properties are load-bearing:
|
||||
|
||||
### Docker
|
||||
|
||||
The Dockerfile uses a multi-stage build. Each stage is pinned by
|
||||
digest, and the lint and builder stages are separate images so the
|
||||
linter's version is fixed independently of the compiler's:
|
||||
The Dockerfile uses a three-stage build. Each stage is pinned by
|
||||
digest, and the two check stages are separate images so the linter's
|
||||
version is fixed independently of the compiler's:
|
||||
|
||||
1. **Lint stage** (`golangci/golangci-lint:v2.12.2`, Debian-based) —
|
||||
installs `make`, downloads dependencies, copies the source, and runs
|
||||
`make fmt-check`, then `script/assets` to extract Alpine.js from
|
||||
`3p/`, then `golangci-lint config verify` and `golangci-lint run`,
|
||||
both with `--network=none`.
|
||||
2. **Stylesheet stages** (`debian:bookworm-slim`, with the Tailwind
|
||||
standalone CLI pinned by version and sha256, one binary per
|
||||
architecture) — generate `static/css/tailwind.css` from
|
||||
`static/css/input.css` and the files its `@source` lines name.
|
||||
`css-check` fails when the committed file differs from the generated
|
||||
one, and `make css` writes the generated file out from `css-output`
|
||||
(see [Stylesheet](#stylesheet)).
|
||||
3. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint
|
||||
and `css-check` stages passing (it copies a file from each), runs
|
||||
`make test` and `make build` (both extract Alpine.js from `3p/`
|
||||
first), and finally
|
||||
`make fmt-check`, then `golangci-lint config verify` and
|
||||
`golangci-lint run`, both with `--network=none`.
|
||||
2. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint
|
||||
stage passing (it copies a file from it), runs `make test` and
|
||||
`make build` (both extract Alpine.js from `3p/` first), and finally
|
||||
rebuilds the binary with `CGO_ENABLED=1` and static linking so it
|
||||
runs on musl. Both builds go through `make build`, the relink adding
|
||||
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that
|
||||
@@ -3521,7 +3389,7 @@ linter's version is fixed independently of the compiler's:
|
||||
given, otherwise derived from the `.git` in the context, and the
|
||||
stage fails if a context with `.git` would stamp `unknown` (see
|
||||
[Version stamping](#version-stamping)).
|
||||
4. **Runtime stage** (`alpine:3.21`) — copies the static binary and
|
||||
3. **Runtime stage** (`alpine:3.21`) — copies the static binary and
|
||||
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker`
|
||||
directory for all SQLite databases, exposes port 8080, and includes
|
||||
a health check against `/.well-known/healthcheck`. It sets no
|
||||
@@ -3533,18 +3401,18 @@ The lint stage invokes `golangci-lint` directly rather than `make lint`:
|
||||
it is already the pinned linter image, and `make lint` builds
|
||||
`Dockerfile.lint`, which would need a docker daemon inside this build.
|
||||
|
||||
The lint and builder stages use Debian rather than Alpine because
|
||||
Both check stages use Debian rather than Alpine because
|
||||
`gorm.io/driver/sqlite` pulls in `mattn/go-sqlite3`, which needs CGO
|
||||
and does not compile against musl. Only the final binary is statically
|
||||
linked, which is what lets it run on the Alpine runtime image.
|
||||
|
||||
`script/cibuild` — `docker build .` — is the CI gate: the checks run
|
||||
inside the image, so a build that succeeds is a repo that is formatted,
|
||||
linted, tested and compiled, with a current stylesheet. `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; of
|
||||
the steps `make check` runs, only `script/test` and `script/fmt-check`
|
||||
run on the host.
|
||||
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; of the steps
|
||||
`make check` runs, only `script/test` and `script/fmt-check` run on the
|
||||
host.
|
||||
|
||||
#### CI gate honesty
|
||||
|
||||
@@ -3554,10 +3422,9 @@ check meaningless. The `check` workflow therefore writes
|
||||
`.ci-fingerprint` into the build context before building. Its value is
|
||||
the hash of the commit being checked, so every commit, docs-only ones
|
||||
and a squash merge whose tree matches an already-built branch included,
|
||||
gets a new fingerprint, invalidates the `COPY . .` layer of every check
|
||||
stage, and really runs `make fmt-check`, `golangci-lint`, the stylesheet
|
||||
check, `make test`, and `make build`. A run that reports success ran
|
||||
them.
|
||||
gets a new fingerprint, invalidates the `COPY . .` layer of both check
|
||||
stages, and really runs `make fmt-check`, `golangci-lint`, `make test`,
|
||||
and `make build`. A run that reports success ran them.
|
||||
|
||||
The module download layer sits above `COPY . .` and stays cached.
|
||||
|
||||
|
||||
@@ -38,19 +38,17 @@ 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 HTTP drain plus the Sentry flush that
|
||||
// follows it in the same hook, and each is clamped to the stop
|
||||
// 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
|
||||
// context's remaining time less server.TailHookReserve rather than
|
||||
// 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.
|
||||
// running for its own fixed 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 and every amount of budget the hooks
|
||||
// before the server may already have spent.
|
||||
// across every drain length.
|
||||
//
|
||||
// This does not make the database close unconditional: the
|
||||
// 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.
|
||||
// ArchiveSweeper and RetentionReaper hooks run before the server
|
||||
// and can still consume the whole budget on their own.
|
||||
const stopTimeout = 5 * time.Second
|
||||
|
||||
// exitUsage is the status for a command line this binary cannot make
|
||||
|
||||
@@ -252,40 +252,22 @@ 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.
|
||||
//
|
||||
// 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.
|
||||
// Shrinking either budget, or unbounding 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 spent := time.Duration(0); spent <= stopTimeout; spent += step {
|
||||
remaining := stopTimeout - spent
|
||||
longest := max(server.DrainBudget(remaining), 0)
|
||||
for drain := time.Duration(0); drain <= server.ShutdownTimeout; drain += step {
|
||||
hook := drain + server.SentryFlushBudget(stopTimeout-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,
|
||||
)
|
||||
}
|
||||
require.LessOrEqual(
|
||||
t, hook+tailHeadroom, stopTimeout,
|
||||
"a %s drain leaves the tail hooks short", drain,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,77 +199,6 @@ func TestStatisticsQueriesUseTheirIndexes(t *testing.T) {
|
||||
"(deleted_at=? AND created_at>?)")
|
||||
}
|
||||
|
||||
// TestResubmitCountUsesItsIndex does the same for the event log's count
|
||||
// of the events resubmitted from each of a page's events (resubmitCounts
|
||||
// in the handlers). It passes a full page of 25 ids: with an index on
|
||||
// resubmitted_from_id alone, SQLite uses it for three ids and turns to
|
||||
// the deleted_at index from five.
|
||||
func TestResubmitCountUsesItsIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mgr, lc := setupTestWebhookDBManager(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, lc.Start(ctx))
|
||||
|
||||
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
||||
|
||||
db, err := mgr.GetDB(uuid.New().String())
|
||||
require.NoError(t, err)
|
||||
|
||||
dry := db.Session(&gorm.Session{DryRun: true})
|
||||
|
||||
page := make([]string, 25)
|
||||
for i := range page {
|
||||
page[i] = uuid.New().String()
|
||||
}
|
||||
|
||||
var counts []struct{ Total int }
|
||||
|
||||
assertPlanUses(t, db, dry.Model(&database.Event{}).
|
||||
Select("resubmitted_from_id, count(*) AS total").
|
||||
Where("resubmitted_from_id IN ?", page).
|
||||
Group("resubmitted_from_id").Find(&counts),
|
||||
"idx_events_resubmitted_from_id "+
|
||||
"(resubmitted_from_id=? AND deleted_at=?)")
|
||||
}
|
||||
|
||||
// TestEntrypointEventsUseTheirIndex does the same for the webhook
|
||||
// page's count, for each entrypoint, of the events that arrived on its
|
||||
// URL since the retention cutoff (addEntrypointEvents in the
|
||||
// handlers), which must come from the index alone. It passes 25
|
||||
// entrypoints, as TestResubmitCountUsesItsIndex passes 25 events.
|
||||
func TestEntrypointEventsUseTheirIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mgr, lc := setupTestWebhookDBManager(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, lc.Start(ctx))
|
||||
|
||||
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
||||
|
||||
db, err := mgr.GetDB(uuid.New().String())
|
||||
require.NoError(t, err)
|
||||
|
||||
dry := db.Session(&gorm.Session{DryRun: true})
|
||||
|
||||
entrypoints := make([]string, 25)
|
||||
for i := range entrypoints {
|
||||
entrypoints[i] = uuid.New().String()
|
||||
}
|
||||
|
||||
var rows []struct{ Events int }
|
||||
|
||||
assertPlanUses(t, db, dry.Model(&database.Event{}).
|
||||
Select("entrypoint_id, count(*) AS events").
|
||||
Where("entrypoint_id IN ? AND resubmitted_from_id IS NULL",
|
||||
entrypoints).
|
||||
Where("created_at >= ?", time.Now()).
|
||||
Group("entrypoint_id").Find(&rows),
|
||||
"COVERING INDEX idx_events_entrypoint_id "+
|
||||
"(entrypoint_id=? AND deleted_at=? AND "+
|
||||
"resubmitted_from_id=? AND created_at>?)")
|
||||
}
|
||||
|
||||
// assertPlanUses asserts that SQLite's plan for a statement GORM built
|
||||
// in a dry run, run with the same SQL and arguments GORM would send,
|
||||
// names each of the given indexes.
|
||||
|
||||
@@ -19,16 +19,11 @@ type Event struct {
|
||||
// narrows by a < only on the last column it uses. Its final delete
|
||||
// has no deleted_at condition and uses the index on created_at
|
||||
// alone. The other tables keep the unindexed BaseModel created_at.
|
||||
// DeletedAt is also the second column of the resubmitted_from_id
|
||||
// index, for the reason DeliveryResult gives. The entrypoint_id
|
||||
// index, for the webhook page's entrypoint list, has it second too,
|
||||
// resubmitted_from_id third, and created_at last, which the list
|
||||
// compares with a range.
|
||||
CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2;index:idx_events_entrypoint_id,priority:4" json:"createdAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1;index:idx_events_resubmitted_from_id,priority:2;index:idx_events_entrypoint_id,priority:2" json:"deletedAt,omitzero"`
|
||||
CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2" json:"createdAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1" json:"deletedAt,omitzero"`
|
||||
|
||||
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
|
||||
EntrypointID string `gorm:"type:uuid;not null;index:idx_events_entrypoint_id,priority:1" json:"entrypointId"`
|
||||
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
|
||||
EntrypointID string `gorm:"type:uuid;not null" json:"entrypointId"`
|
||||
|
||||
// Request data
|
||||
Method string `gorm:"not null" json:"method"`
|
||||
@@ -37,8 +32,8 @@ type Event struct {
|
||||
ContentType string `json:"contentType"`
|
||||
|
||||
// BodyBytes is the size of Body in bytes, recorded when the event
|
||||
// is stored, so that the recent events list, which reads only the
|
||||
// start of each body, knows the whole body's size.
|
||||
// is stored so the recent events list can show it without reading
|
||||
// the body.
|
||||
BodyBytes int64 `gorm:"not null" json:"bodyBytes"`
|
||||
|
||||
// ResubmittedFromID names the event this one was copied from by
|
||||
@@ -47,7 +42,7 @@ type Event struct {
|
||||
// existed. It is not a foreign key: the source event can be
|
||||
// reaped by retention while its copies remain, and the id is
|
||||
// kept as the record of where the copy came from either way.
|
||||
ResubmittedFromID *string `gorm:"type:uuid;index:idx_events_resubmitted_from_id,priority:1;index:idx_events_entrypoint_id,priority:3" json:"resubmittedFromId,omitempty"`
|
||||
ResubmittedFromID *string `gorm:"type:uuid;index" json:"resubmittedFromId,omitempty"`
|
||||
|
||||
// Relations. No model marshals the record it belongs to, so
|
||||
// Webhook and Entrypoint are left out of the JSON.
|
||||
|
||||
@@ -52,21 +52,6 @@ func (TargetTotals) TableName() string {
|
||||
return "target_totals"
|
||||
}
|
||||
|
||||
// EntrypointTotals is one row per entrypoint, created by the first
|
||||
// event that arrives on its URL: when the newest such event arrived,
|
||||
// which retention leaves as it is. A resubmitted copy did not arrive
|
||||
// on the URL and does not change it.
|
||||
type EntrypointTotals struct {
|
||||
EntrypointID string `gorm:"type:uuid;primaryKey"`
|
||||
|
||||
LastEventAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
// TableName names the table AddEntrypointTotals updates.
|
||||
func (EntrypointTotals) TableName() string {
|
||||
return "entrypoint_totals"
|
||||
}
|
||||
|
||||
// AddEventTotals adds each count in add to the webhook's event totals,
|
||||
// and records add.LastEventAt as when the newest event arrived if it is
|
||||
// set. Call it on the transaction that writes or deletes the events it
|
||||
@@ -112,25 +97,3 @@ func AddTargetTotals(tx *gorm.DB, add TargetTotals) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddEntrypointTotals records add.LastEventAt as when the newest event
|
||||
// arrived on the URL of the entrypoint add.EntrypointID names, creating
|
||||
// its row the first time. Call it on the transaction that stores the
|
||||
// event.
|
||||
func AddEntrypointTotals(tx *gorm.DB, add EntrypointTotals) error {
|
||||
err := tx.Exec(
|
||||
`INSERT INTO entrypoint_totals (entrypoint_id, last_event_at)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT (entrypoint_id) DO UPDATE SET
|
||||
last_event_at = excluded.last_event_at`,
|
||||
add.EntrypointID, add.LastEventAt,
|
||||
).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"adding to totals of entrypoint %s: %w",
|
||||
add.EntrypointID, err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -111,13 +111,6 @@ func (w *Webhook) RetainsForever() bool {
|
||||
return retainsForever(w.RetentionDays)
|
||||
}
|
||||
|
||||
// RetentionCutoff returns the time before which this webhook's events
|
||||
// have expired, as the reaper computes it, and false when the webhook
|
||||
// retains them forever.
|
||||
func (w *Webhook) RetentionCutoff(now time.Time) (time.Time, bool) {
|
||||
return retentionCutoff(now, w.RetentionDays)
|
||||
}
|
||||
|
||||
// RetentionLabel returns the webhook's retention policy as display
|
||||
// text, so that no template has to know about the sentinel value.
|
||||
func (w *Webhook) RetentionLabel() string {
|
||||
|
||||
@@ -3,7 +3,7 @@ package database
|
||||
// Migrate runs database migrations for the main application database.
|
||||
// Only configuration-tier models are stored in the main database.
|
||||
// Event-tier models (Event, Delivery, DeliveryResult, EventTotals,
|
||||
// TargetTotals, EntrypointTotals) live in
|
||||
// TargetTotals) live in
|
||||
// per-webhook dedicated databases managed by WebhookDBManager.
|
||||
func (d *Database) Migrate() error {
|
||||
return d.db.AutoMigrate(
|
||||
|
||||
@@ -49,7 +49,7 @@ var ErrSidecarNotRemoved = errors.New(
|
||||
// WebhookDBManager manages per-webhook SQLite database files
|
||||
// for event storage. Each webhook gets its own dedicated
|
||||
// database containing Events, Deliveries, DeliveryResults and the
|
||||
// running totals of them (EventTotals, TargetTotals, EntrypointTotals).
|
||||
// running totals of them (EventTotals, TargetTotals).
|
||||
// Database connections are opened lazily and cached.
|
||||
type WebhookDBManager struct {
|
||||
dataDir string
|
||||
@@ -112,25 +112,6 @@ func (m *WebhookDBManager) GetDB(
|
||||
return m.getDB(webhookID, false)
|
||||
}
|
||||
|
||||
// GetDBIf is GetDB, done only when check reports true. check runs under
|
||||
// the lock DeleteDB holds while it removes the files, so a caller can
|
||||
// confirm the webhook still exists and open its database with no delete
|
||||
// in between. The handle is nil when check reports false. check must
|
||||
// not call the manager.
|
||||
func (m *WebhookDBManager) GetDBIf(
|
||||
webhookID string, check func() (bool, error),
|
||||
) (*gorm.DB, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
ok, err := check()
|
||||
if err != nil || !ok {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return m.getDBLocked(webhookID, false)
|
||||
}
|
||||
|
||||
// asGormDB returns a value read from the cache as the database
|
||||
// handle it is.
|
||||
func asGormDB(val any, webhookID string) (*gorm.DB, error) {
|
||||
@@ -279,18 +260,12 @@ func (m *WebhookDBManager) getDB(
|
||||
return asGormDB(val, webhookID)
|
||||
}
|
||||
|
||||
// Slow path: open the database under the lock, looking in the
|
||||
// cache again first. A caller that raced another one here then
|
||||
// waits for its handle instead of opening a second one.
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
return m.getDBLocked(webhookID, isNew)
|
||||
}
|
||||
|
||||
// getDBLocked is getDB's slow path, run with m.mu held. It looks in the
|
||||
// cache again first: a caller that raced another one to the lock then
|
||||
// gets its handle instead of opening a second one.
|
||||
func (m *WebhookDBManager) getDBLocked(
|
||||
webhookID string, isNew bool,
|
||||
) (*gorm.DB, error) {
|
||||
if val, ok := m.dbs.Load(webhookID); ok {
|
||||
return asGormDB(val, webhookID)
|
||||
}
|
||||
@@ -381,7 +356,7 @@ func (m *WebhookDBManager) openDB(
|
||||
// Run migrations for event-tier models only
|
||||
err = db.AutoMigrate(
|
||||
&Event{}, &Delivery{}, &DeliveryResult{},
|
||||
&EventTotals{}, &TargetTotals{}, &EntrypointTotals{},
|
||||
&EventTotals{}, &TargetTotals{},
|
||||
)
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
|
||||
@@ -22,7 +22,6 @@ 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 (
|
||||
@@ -71,8 +70,7 @@ func setupArchiveTest(t *testing.T) *archiveEnv {
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
|
||||
gdb, err := gorm.Open(
|
||||
sqlite.Dialector{Conn: sqlDB},
|
||||
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
||||
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -170,8 +168,7 @@ func (env *archiveEnv) seedArchiveRows(
|
||||
require.NoError(t, err)
|
||||
|
||||
gdb, err := gorm.Open(
|
||||
sqlite.Dialector{Conn: sqlDB},
|
||||
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
||||
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -230,8 +227,7 @@ func countArchivedRows(path string) (int64, error) {
|
||||
defer func() { _ = sqlDB.Close() }()
|
||||
|
||||
gdb, err := gorm.Open(
|
||||
sqlite.Dialector{Conn: sqlDB},
|
||||
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
||||
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
|
||||
@@ -699,9 +699,9 @@ func (e *Engine) recoverInFlight(ctx context.Context) {
|
||||
default:
|
||||
}
|
||||
|
||||
// Opened even when its file is missing, so that a lost
|
||||
// database is reported at start, not when the webhook next
|
||||
// receives an event, which for a quiet webhook may be never.
|
||||
// Opened even when its file is missing, so that GetDB reports
|
||||
// a lost database at start, not when the webhook next receives
|
||||
// an event, which for a quiet webhook may be never.
|
||||
e.recoverWebhookDeliveries(ctx, webhookID)
|
||||
}
|
||||
}
|
||||
@@ -709,24 +709,7 @@ func (e *Engine) recoverInFlight(ctx context.Context) {
|
||||
func (e *Engine) recoverWebhookDeliveries(
|
||||
ctx context.Context, webhookID string,
|
||||
) {
|
||||
// The web interface is already serving, so the webhook may have
|
||||
// been deleted since the list was read. Opening its database then
|
||||
// would create the file again after the delete removed it.
|
||||
stillExists := func() (bool, error) {
|
||||
var count int64
|
||||
|
||||
err := e.database.DB().
|
||||
Model(&database.Webhook{}).
|
||||
Where("id = ?", webhookID).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("confirming webhook exists: %w", err)
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
webhookDB, err := e.dbManager.GetDBIf(webhookID, stillExists)
|
||||
webhookDB, err := e.dbManager.GetDB(webhookID)
|
||||
if err != nil {
|
||||
e.log.Error(
|
||||
"failed to get webhook database for recovery",
|
||||
@@ -737,10 +720,6 @@ func (e *Engine) recoverWebhookDeliveries(
|
||||
return
|
||||
}
|
||||
|
||||
if webhookDB == nil {
|
||||
return
|
||||
}
|
||||
|
||||
e.recoverPendingDeliveries(
|
||||
ctx, webhookDB, webhookID,
|
||||
)
|
||||
|
||||
@@ -24,7 +24,6 @@ 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.
|
||||
@@ -82,8 +81,7 @@ func iMainDB(t *testing.T) *gorm.DB {
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
|
||||
db, err := gorm.Open(
|
||||
sqlite.Dialector{Conn: sqlDB},
|
||||
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
||||
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -1171,51 +1169,6 @@ func TestRecoverInFlight_ReportsAMissingWebhookDatabase(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
// TestRecoverInFlight_SkipsAWebhookDeletedAfterTheListIsRead covers a
|
||||
// webhook deleted from the web interface while restart recovery runs.
|
||||
// Its database file is gone, and recovery must not create it again.
|
||||
func TestRecoverInFlight_SkipsAWebhookDeletedAfterTheListIsRead(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
mainDB := iMainDB(t)
|
||||
webhookID := uuid.New().String()
|
||||
iCreateWebhook(t, mainDB, webhookID, "deleted-during-recovery")
|
||||
|
||||
// The first query to return is recovery's read of the list of
|
||||
// webhooks. Deleting the webhook right after it puts the delete
|
||||
// between that read and the opening of the webhook's database.
|
||||
deleted := false
|
||||
|
||||
require.NoError(t, mainDB.Callback().Query().After("gorm:query").
|
||||
Register("delete-after-list", func(*gorm.DB) {
|
||||
if deleted {
|
||||
return
|
||||
}
|
||||
|
||||
deleted = true
|
||||
|
||||
require.NoError(t, mainDB.Delete(
|
||||
&database.Webhook{}, "id = ?", webhookID,
|
||||
).Error)
|
||||
}))
|
||||
|
||||
dbMgr := database.NewTestWebhookDBManager(t.TempDir())
|
||||
t.Cleanup(func() { _ = dbMgr.CloseAll() })
|
||||
|
||||
engine := delivery.NewTestEngineWithDB(
|
||||
database.NewTestDatabase(mainDB), dbMgr,
|
||||
slog.New(slog.DiscardHandler),
|
||||
&http.Client{Timeout: 5 * time.Second}, 1,
|
||||
)
|
||||
|
||||
engine.ExportRecoverInFlight(context.Background())
|
||||
|
||||
require.True(t, deleted)
|
||||
assert.False(t, dbMgr.DBExists(webhookID))
|
||||
}
|
||||
|
||||
// --- HTTP Config with custom headers ---
|
||||
|
||||
func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
|
||||
@@ -1507,32 +1460,6 @@ 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) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -26,7 +25,6 @@ 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"
|
||||
)
|
||||
|
||||
@@ -51,8 +49,7 @@ func testWebhookDB(t *testing.T) *gorm.DB {
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
|
||||
db, err := gorm.Open(
|
||||
sqlite.Dialector{Conn: sqlDB},
|
||||
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
||||
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -1059,21 +1056,6 @@ 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,
|
||||
) {
|
||||
@@ -1259,33 +1241,6 @@ 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
|
||||
@@ -1362,34 +1317,6 @@ 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,97 +376,3 @@ 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,9 +86,9 @@ func NewTargetConfigForm(
|
||||
}
|
||||
|
||||
// databaseConfigForm parses an archive target's optional expiry.
|
||||
// An absent, empty or never expiry yields an empty expiry, on which
|
||||
// the edit form starts at never; saving it unchanged stores never,
|
||||
// which means the same as an empty expiry. An expiry that is set
|
||||
// An absent or empty configuration is the keep-forever default and
|
||||
// yields an empty field, so re-saving the form unchanged stores the
|
||||
// same empty configuration it started with. An expiry that is set
|
||||
// but not a valid duration is an error, not a blank field.
|
||||
func databaseConfigForm(
|
||||
configJSON string,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
@@ -97,7 +97,7 @@ func targetConfigFields(
|
||||
) []ConfigField {
|
||||
switch t.Type {
|
||||
case database.TargetTypeSlack:
|
||||
return slackConfigFields(t)
|
||||
return slackConfigFields(t.Config)
|
||||
case database.TargetTypeHTTP:
|
||||
return httpConfigFields(t)
|
||||
case database.TargetTypeDatabase:
|
||||
@@ -119,11 +119,10 @@ func unavailableConfigFields() []ConfigField {
|
||||
}}
|
||||
}
|
||||
|
||||
// slackConfigFields describes a Slack target: its masked
|
||||
// webhook URL and its retry count. Only the masked URL is
|
||||
// shown; the full URL is the credential.
|
||||
func slackConfigFields(t *database.Target) []ConfigField {
|
||||
cfg, err := parseSlackConfig(t.Config)
|
||||
// slackConfigFields describes a Slack target. Only the masked
|
||||
// webhook URL is shown; the full URL is the credential.
|
||||
func slackConfigFields(configJSON string) []ConfigField {
|
||||
cfg, err := parseSlackConfig(configJSON)
|
||||
if err != nil {
|
||||
return unavailableConfigFields()
|
||||
}
|
||||
@@ -131,7 +130,7 @@ func slackConfigFields(t *database.Target) []ConfigField {
|
||||
return []ConfigField{{
|
||||
Label: "Webhook URL",
|
||||
Value: cfg.MaskedWebhookURL(),
|
||||
}, maxRetriesField(t)}
|
||||
}}
|
||||
}
|
||||
|
||||
// httpConfigFields describes an HTTP target: its destination
|
||||
@@ -171,7 +170,21 @@ func httpConfigFields(t *database.Target) []ConfigField {
|
||||
})
|
||||
}
|
||||
|
||||
fields = append(fields, maxRetriesField(t))
|
||||
return append(fields, retryFields(t)...)
|
||||
}
|
||||
|
||||
// retryFields describes a target's retry settings, which live
|
||||
// on the target row rather than in its configuration blob.
|
||||
func retryFields(t *database.Target) []ConfigField {
|
||||
retries := strconv.Itoa(t.MaxRetries)
|
||||
if t.MaxRetries == 0 {
|
||||
retries += " (fire-and-forget)"
|
||||
}
|
||||
|
||||
fields := []ConfigField{{
|
||||
Label: "Max Retries",
|
||||
Value: retries,
|
||||
}}
|
||||
|
||||
if t.MaxQueueSize > 0 {
|
||||
fields = append(fields, ConfigField{
|
||||
@@ -183,75 +196,37 @@ func httpConfigFields(t *database.Target) []ConfigField {
|
||||
return fields
|
||||
}
|
||||
|
||||
// maxRetriesField describes a target's retry count, which lives
|
||||
// on the target row rather than in its configuration blob.
|
||||
func maxRetriesField(t *database.Target) ConfigField {
|
||||
retries := strconv.Itoa(t.MaxRetries)
|
||||
if t.MaxRetries == 0 {
|
||||
retries += " (fire-and-forget)"
|
||||
}
|
||||
|
||||
return ConfigField{
|
||||
Label: "Max Retries",
|
||||
Value: retries,
|
||||
}
|
||||
}
|
||||
|
||||
// databaseConfigFields describes an archive target by its
|
||||
// expiry in plain units, such as "30 days", or "never" when
|
||||
// the archive is kept forever. An expiry that is set but not
|
||||
// a valid duration is reported as unavailable rather than
|
||||
// echoed back.
|
||||
// databaseConfigFields describes an archive target. Its
|
||||
// configuration is optional, and an absent or empty expiry
|
||||
// means the archive is kept forever. An expiry that is set
|
||||
// but not a valid duration is reported as unavailable rather
|
||||
// than echoed back.
|
||||
func databaseConfigFields(configJSON string) []ConfigField {
|
||||
expiry, err := parseArchiveExpiry(configJSON)
|
||||
if err != nil {
|
||||
return unavailableConfigFields()
|
||||
}
|
||||
expiry := archiveExpiryNever
|
||||
|
||||
value := archiveExpiryNever
|
||||
if expiry > 0 {
|
||||
value = plainDuration(expiry)
|
||||
if configJSON != "" {
|
||||
var cfg databaseTargetConfig
|
||||
|
||||
err := json.Unmarshal([]byte(configJSON), &cfg)
|
||||
if err != nil {
|
||||
return unavailableConfigFields()
|
||||
}
|
||||
|
||||
if cfg.Expiry != "" {
|
||||
if ValidateArchiveExpiry(cfg.Expiry) != nil {
|
||||
return unavailableConfigFields()
|
||||
}
|
||||
|
||||
expiry = cfg.Expiry
|
||||
}
|
||||
}
|
||||
|
||||
return []ConfigField{{
|
||||
Label: "Archive Expiry",
|
||||
Value: value,
|
||||
Value: expiry,
|
||||
}}
|
||||
}
|
||||
|
||||
// plainDuration writes a positive duration as a count of the
|
||||
// largest whole unit it divides into: "30 days", "12 hours",
|
||||
// "1 minute". A duration with a fraction of a second is
|
||||
// written as Go writes it.
|
||||
func plainDuration(d time.Duration) string {
|
||||
const day = 24 * time.Hour
|
||||
|
||||
units := []struct {
|
||||
size time.Duration
|
||||
name string
|
||||
}{
|
||||
{day, "day"},
|
||||
{time.Hour, "hour"},
|
||||
{time.Minute, "minute"},
|
||||
{time.Second, "second"},
|
||||
}
|
||||
|
||||
for _, unit := range units {
|
||||
if d%unit.size != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
count := int64(d / unit.size)
|
||||
if count == 1 {
|
||||
return "1 " + unit.name
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%d %ss", count, unit.name)
|
||||
}
|
||||
|
||||
return d.String()
|
||||
}
|
||||
|
||||
// MaskedWebhookURL returns the Slack webhook URL reduced to
|
||||
// its scheme and host, with the path, query and any userinfo
|
||||
// elided. The path segments are the credential, so none of
|
||||
|
||||
@@ -32,7 +32,6 @@ const (
|
||||
viewMaskedOrigin = viewExampleOrigin + "/..."
|
||||
viewUnavailable = "(unavailable)"
|
||||
viewExpiryNever = "never"
|
||||
viewMaxRetries = "Max Retries"
|
||||
)
|
||||
|
||||
func TestMaskedWebhookURL(t *testing.T) {
|
||||
@@ -158,7 +157,9 @@ func TestNewTargetViews_DeletedTarget(t *testing.T) {
|
||||
t, slackTargetName+" (deleted)", view.DisplayName(),
|
||||
)
|
||||
assert.Equal(
|
||||
t, viewFor(t, slackTarget()).Config, view.Config,
|
||||
t,
|
||||
map[string]string{"Webhook URL": slackMaskedURL},
|
||||
fieldMap(view.Config),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -188,32 +189,7 @@ func TestNewTargetViews_Slack(t *testing.T) {
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
map[string]string{
|
||||
"Webhook URL": slackMaskedURL,
|
||||
viewMaxRetries: "0 (fire-and-forget)",
|
||||
},
|
||||
fieldMap(view.Config),
|
||||
)
|
||||
}
|
||||
|
||||
// TestNewTargetViews_SlackRetries proves a Slack target shows
|
||||
// its retry count the same way an HTTP target does, and no
|
||||
// queue size even when one is stored: delivery never reads it.
|
||||
func TestNewTargetViews_SlackRetries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
target := slackTarget()
|
||||
target.MaxRetries = 2
|
||||
target.MaxQueueSize = 100
|
||||
|
||||
view := viewFor(t, target)
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
map[string]string{
|
||||
"Webhook URL": slackMaskedURL,
|
||||
viewMaxRetries: "2",
|
||||
},
|
||||
map[string]string{"Webhook URL": slackMaskedURL},
|
||||
fieldMap(view.Config),
|
||||
)
|
||||
}
|
||||
@@ -238,7 +214,7 @@ func TestNewTargetViews_HTTP(t *testing.T) {
|
||||
"Destination URL": viewMaskedOrigin,
|
||||
"Timeout": "30s",
|
||||
"Headers": "1 configured",
|
||||
viewMaxRetries: "5",
|
||||
"Max Retries": "5",
|
||||
"Max Queue Size": "100",
|
||||
},
|
||||
fields,
|
||||
@@ -262,7 +238,7 @@ func TestNewTargetViews_HTTPFireAndForget(t *testing.T) {
|
||||
t,
|
||||
map[string]string{
|
||||
"Destination URL": viewMaskedOrigin,
|
||||
viewMaxRetries: "0 (fire-and-forget)",
|
||||
"Max Retries": "0 (fire-and-forget)",
|
||||
},
|
||||
fieldMap(view.Config),
|
||||
)
|
||||
@@ -305,20 +281,14 @@ func TestNewTargetViews_Database(t *testing.T) {
|
||||
}{
|
||||
"empty config": {config: "", want: viewExpiryNever},
|
||||
"empty expiry": {config: `{}`, want: viewExpiryNever},
|
||||
"explicit": {
|
||||
config: `{"expiry":"720h"}`,
|
||||
want: "720h",
|
||||
},
|
||||
"never literal": {
|
||||
config: `{"expiry":"` + viewExpiryNever + `"}`,
|
||||
want: viewExpiryNever,
|
||||
},
|
||||
"1h": {config: `{"expiry":"1h"}`, want: "1 hour"},
|
||||
"12h": {config: `{"expiry":"12h"}`, want: "12 hours"},
|
||||
"24h": {config: `{"expiry":"24h"}`, want: "1 day"},
|
||||
"720h": {config: `{"expiry":"720h"}`, want: "30 days"},
|
||||
"2160h": {config: `{"expiry":"2160h"}`, want: "90 days"},
|
||||
"8760h": {config: `{"expiry":"8760h"}`, want: "365 days"},
|
||||
"36h": {config: `{"expiry":"36h"}`, want: "36 hours"},
|
||||
"1h30m": {config: `{"expiry":"1h30m"}`, want: "90 minutes"},
|
||||
"45s": {config: `{"expiry":"45s"}`, want: "45 seconds"},
|
||||
"1.5s": {config: `{"expiry":"1.5s"}`, want: "1.5s"},
|
||||
}
|
||||
|
||||
for name, tc := range tests {
|
||||
|
||||
@@ -3,6 +3,7 @@ package delivery
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -276,9 +277,10 @@ func (t *databaseTarget) releaseSweepWriter(
|
||||
}
|
||||
|
||||
// newWriter builds the writer for a database target's archive. The
|
||||
// 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.
|
||||
// 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.
|
||||
func (t *databaseTarget) newWriter(
|
||||
targetID string,
|
||||
) (*archiveWriter, error) {
|
||||
@@ -297,10 +299,12 @@ func (t *databaseTarget) newWriter(
|
||||
)
|
||||
}
|
||||
|
||||
w := newArchiveWriter(
|
||||
ArchivePath(t.eng.dbManager, &target.Webhook, &target),
|
||||
t.eng.log,
|
||||
dir := filepath.Dir(t.eng.dbManager.DBPath(target.WebhookID))
|
||||
name := ArchiveFileName(
|
||||
target.Webhook.Name, target.Name, target.ID,
|
||||
)
|
||||
|
||||
w := newArchiveWriter(filepath.Join(dir, name), t.eng.log)
|
||||
w.webhookID = target.WebhookID
|
||||
|
||||
return w, nil
|
||||
|
||||
@@ -515,47 +515,6 @@ func (w *archiveWriter) prune(expiry time.Duration) {
|
||||
}
|
||||
}
|
||||
|
||||
// ArchiveFileInfo is what the metadata of a database target's archive
|
||||
// file says about it.
|
||||
type ArchiveFileInfo struct {
|
||||
// Size is the bytes on disk of the file and its -wal together.
|
||||
Size int64
|
||||
|
||||
// Written is when the file or its -wal was last modified, whichever
|
||||
// is later: a write lands in the -wal first.
|
||||
Written time.Time
|
||||
}
|
||||
|
||||
// StatArchive reads the metadata of the archive file at path and of
|
||||
// its -wal, without opening the archive. With no file at path, which is
|
||||
// so before the first write and after the operator moved it away, the
|
||||
// error wraps fs.ErrNotExist.
|
||||
func StatArchive(path string) (ArchiveFileInfo, error) {
|
||||
file, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return ArchiveFileInfo{}, err
|
||||
}
|
||||
|
||||
info := ArchiveFileInfo{Size: file.Size(), Written: file.ModTime()}
|
||||
|
||||
wal, err := os.Stat(path + "-wal")
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return info, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return ArchiveFileInfo{}, err
|
||||
}
|
||||
|
||||
info.Size += wal.Size()
|
||||
|
||||
if wal.ModTime().After(info.Written) {
|
||||
info.Written = wal.ModTime()
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// fileExists reports whether a path currently exists.
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
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),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package delivery_test
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -18,7 +17,6 @@ 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 {
|
||||
@@ -44,8 +42,7 @@ func openArchiveDBForRead(
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
|
||||
gdb, err := gorm.Open(
|
||||
sqlite.Dialector{Conn: sqlDB},
|
||||
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
||||
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -184,49 +181,6 @@ func TestArchiveWriter_RecreatesAfterRemoval(
|
||||
assert.Equal(t, "b", got[0].EventID)
|
||||
}
|
||||
|
||||
// TestStatArchive proves StatArchive finds no file before the first
|
||||
// write; after a write still held in the -wal, counts the -wal in the
|
||||
// size and takes its later time as the last write; and finds no file
|
||||
// again once the file has been moved away.
|
||||
func TestStatArchive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
||||
|
||||
_, err := delivery.StatArchive(path)
|
||||
require.ErrorIs(t, err, fs.ErrNotExist)
|
||||
|
||||
// With the clock stopped, the reopen debounce never passes, so
|
||||
// the handle stays open after the write.
|
||||
stopped := time.Now()
|
||||
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||
w.SetNow(func() time.Time { return stopped })
|
||||
|
||||
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "a"}, 0))
|
||||
|
||||
written := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
earlier := written.Add(-time.Hour)
|
||||
require.NoError(t, os.Chtimes(path, earlier, earlier))
|
||||
require.NoError(t, os.Chtimes(path+"-wal", written, written))
|
||||
|
||||
file, err := os.Stat(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
wal, err := os.Stat(path + "-wal")
|
||||
require.NoError(t, err)
|
||||
require.Positive(t, wal.Size())
|
||||
|
||||
got, err := delivery.StatArchive(path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, file.Size()+wal.Size(), got.Size)
|
||||
assert.True(t, written.Equal(got.Written), got.Written)
|
||||
|
||||
removeArchiveFiles(t, path)
|
||||
|
||||
_, err = delivery.StatArchive(path)
|
||||
require.ErrorIs(t, err, fs.ErrNotExist)
|
||||
}
|
||||
|
||||
func TestArchiveWriter_ReopenDebounce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -216,9 +216,8 @@ func TestNewTargetConfigForm(t *testing.T) {
|
||||
assert.Empty(t, form.URL)
|
||||
}
|
||||
|
||||
// A keep-forever archive target yields an empty expiry, so the edit
|
||||
// form starts on never; saving it unchanged stores never, which means
|
||||
// the same as an empty expiry.
|
||||
// A keep-forever archive target must pre-fill as an empty field, so
|
||||
// saving the form back unchanged stores the same empty config.
|
||||
func TestNewTargetConfigForm_DatabaseNeverIsBlank(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -573,8 +573,6 @@ func TestRecoverPending_TargetDeleted(t *testing.T) {
|
||||
|
||||
s := newISetup(t)
|
||||
|
||||
iCreateWebhook(t, s.MainDB, s.WebhookID, "pending-recovery")
|
||||
|
||||
deliveryID := tSeedDeletedTarget(
|
||||
t, s, "gone-while-pending", "http://example.com/hook",
|
||||
database.DeliveryStatusPending,
|
||||
@@ -614,8 +612,6 @@ func TestRecoverPending_TargetDeleted_LeavesAnOwnedDeliveryAlone(
|
||||
|
||||
s := newISetup(t)
|
||||
|
||||
iCreateWebhook(t, s.MainDB, s.WebhookID, "owned-recovery")
|
||||
|
||||
deliveryID := tSeedDeletedTarget(
|
||||
t, s, "gone-but-owned", "http://example.com/hook",
|
||||
database.DeliveryStatusPending,
|
||||
|
||||
@@ -179,27 +179,6 @@ 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.
|
||||
|
||||
@@ -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; only tests do, and
|
||||
// what a test binds is fixture data. scan_guard_test.go fails if a
|
||||
// non-test file calls it.
|
||||
// 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.
|
||||
// (*gorm.DB).Pluck, Row and Raw all run through the normal callback
|
||||
// processor and are filtered.
|
||||
func (l *Logger) ParamsFilter(
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
package handlers
|
||||
|
||||
const (
|
||||
// archiveExpiryNever is the archive expiry that keeps archived
|
||||
// events forever. A stored empty expiry means the same.
|
||||
archiveExpiryNever = "never"
|
||||
|
||||
// tmplKeyArchiveExpiryChoices is the template data key for the
|
||||
// entries of a page's archive expiry select.
|
||||
tmplKeyArchiveExpiryChoices = "ArchiveExpiryChoices"
|
||||
)
|
||||
|
||||
// archiveExpiryChoice is one entry of a database target's archive
|
||||
// expiry select: the expiry stored, the label shown, and whether the
|
||||
// select starts on it.
|
||||
type archiveExpiryChoice struct {
|
||||
Value string
|
||||
Label string
|
||||
Selected bool
|
||||
}
|
||||
|
||||
// archiveExpiryChoices lists the archive expiries offered by the new
|
||||
// webhook page, the add target form and the target edit form.
|
||||
func archiveExpiryChoices() []archiveExpiryChoice {
|
||||
return []archiveExpiryChoice{
|
||||
{Value: archiveExpiryNever, Label: archiveExpiryNever},
|
||||
{Value: "1h", Label: "1h"},
|
||||
{Value: "12h", Label: "12h"},
|
||||
{Value: "24h", Label: "24h"},
|
||||
{Value: "720h", Label: "30d"},
|
||||
{Value: "2160h", Label: "90d"},
|
||||
{Value: "8760h", Label: "365d"},
|
||||
}
|
||||
}
|
||||
|
||||
// archiveExpiryOptions returns the choices with expiry selected; an
|
||||
// empty expiry selects never. An expiry that is not one of the
|
||||
// choices comes first as its own selected entry, so saving the form
|
||||
// unchanged keeps it.
|
||||
func archiveExpiryOptions(expiry string) []archiveExpiryChoice {
|
||||
if expiry == "" {
|
||||
expiry = archiveExpiryNever
|
||||
}
|
||||
|
||||
options := archiveExpiryChoices()
|
||||
|
||||
for i := range options {
|
||||
if options[i].Value == expiry {
|
||||
options[i].Selected = true
|
||||
|
||||
return options
|
||||
}
|
||||
}
|
||||
|
||||
own := archiveExpiryChoice{Value: expiry, Label: expiry, Selected: true}
|
||||
|
||||
return append([]archiveExpiryChoice{own}, options...)
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// expiryNever is the archive expiry that keeps archived events
|
||||
// forever.
|
||||
const expiryNever = "never"
|
||||
|
||||
// matched returns what the one group of pattern matched in page, at
|
||||
// each match.
|
||||
func matched(pattern, page string) []string {
|
||||
matches := regexp.MustCompile(pattern).FindAllStringSubmatch(page, -1)
|
||||
groups := make([]string, 0, len(matches))
|
||||
|
||||
for _, m := range matches {
|
||||
groups = append(groups, m[1])
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// expiryShown returns the archive expiries the webhook page's target
|
||||
// list shows.
|
||||
func expiryShown(
|
||||
t *testing.T, env *sourceTestEnv, webhookID string,
|
||||
) []string {
|
||||
t.Helper()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.handlers.HandleSourceDetail().ServeHTTP(w, getRequest(
|
||||
t, "/hook/"+webhookID, env.cookies,
|
||||
map[string]string{sourceIDParam: webhookID},
|
||||
))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
return matched(
|
||||
`Archive Expiry:</span>\s*<span>([^<]*)</span>`, w.Body.String(),
|
||||
)
|
||||
}
|
||||
|
||||
// expirySelected returns the target edit page and the expiries its
|
||||
// select starts on.
|
||||
func expirySelected(
|
||||
t *testing.T, env *sourceTestEnv, webhookID, targetID string,
|
||||
) (string, []string) {
|
||||
t.Helper()
|
||||
|
||||
w := serveTarget(
|
||||
env, http.MethodGet,
|
||||
"/hook/"+webhookID+"/targets/"+targetID+"/edit", nil,
|
||||
)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
page := w.Body.String()
|
||||
|
||||
return page, matched(`<option value="([^"]*)" selected>`, page)
|
||||
}
|
||||
|
||||
// TestArchiveExpiryChoices adds a database target with each archive
|
||||
// expiry the forms offer, and checks that it is stored as chosen,
|
||||
// shown in plain units in the target list, and that the target edit
|
||||
// form starts on it.
|
||||
func TestArchiveExpiryChoices(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
|
||||
choices := []struct{ value, shown string }{
|
||||
{expiryNever, expiryNever},
|
||||
{"1h", "1 hour"},
|
||||
{"12h", "12 hours"},
|
||||
{"24h", "1 day"},
|
||||
{"720h", "30 days"},
|
||||
{"2160h", "90 days"},
|
||||
{"8760h", "365 days"},
|
||||
}
|
||||
|
||||
for _, choice := range choices {
|
||||
t.Run(choice.value, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
webhook := seedWebhookWithRetention(t, env.db, 30)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("name", "archive")
|
||||
form.Set("type", string(database.TargetTypeDatabase))
|
||||
form.Set("expiry", choice.value)
|
||||
|
||||
w := serveTarget(
|
||||
env, http.MethodPost, "/hook/"+webhook.ID+"/targets", form,
|
||||
)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||
|
||||
targets := targetsForWebhook(t, env.db, webhook.ID)
|
||||
require.Len(t, targets, 1)
|
||||
assert.JSONEq(
|
||||
t, `{"expiry":"`+choice.value+`"}`, targets[0].Config,
|
||||
)
|
||||
|
||||
assert.Equal(
|
||||
t, []string{choice.shown},
|
||||
expiryShown(t, env, webhook.ID),
|
||||
)
|
||||
|
||||
_, selected := expirySelected(t, env, webhook.ID, targets[0].ID)
|
||||
assert.Equal(t, []string{choice.value}, selected)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestArchiveExpiryEditStartsOnStoredValue checks the edit form of a
|
||||
// database target whose stored expiry is empty, which selects never,
|
||||
// and of one whose expiry is not one of the choices, which is listed
|
||||
// first as its own selected entry and saved unchanged.
|
||||
func TestArchiveExpiryEditStartsOnStoredValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
|
||||
webhook := seedWebhookWithRetention(t, env.db, 30)
|
||||
empty := seedConfiguredTarget(
|
||||
t, env.db, webhook.ID, database.TargetTypeDatabase, "",
|
||||
)
|
||||
|
||||
_, selected := expirySelected(t, env, webhook.ID, empty.ID)
|
||||
assert.Equal(t, []string{expiryNever}, selected)
|
||||
|
||||
webhook = seedWebhookWithRetention(t, env.db, 30)
|
||||
unlisted := seedConfiguredTarget(
|
||||
t, env.db, webhook.ID, database.TargetTypeDatabase,
|
||||
`{"expiry":"36h"}`,
|
||||
)
|
||||
|
||||
assert.Equal(t, []string{"36 hours"}, expiryShown(t, env, webhook.ID))
|
||||
|
||||
page, selected := expirySelected(t, env, webhook.ID, unlisted.ID)
|
||||
assert.Equal(t, []string{"36h"}, selected)
|
||||
assert.Regexp(
|
||||
t,
|
||||
`<select id="expiry" name="expiry" class="input">\s*`+
|
||||
`<option value="36h" selected>36h</option>\s*`+
|
||||
`<option value="never">never</option>`,
|
||||
page,
|
||||
)
|
||||
assert.Contains(t, page, `<option value="8760h">365d</option>`)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("name", unlisted.Name)
|
||||
form.Set("expiry", "36h")
|
||||
|
||||
w := submitTargetEdit(env, webhook.ID, unlisted.ID, form)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||
assert.JSONEq(
|
||||
t, `{"expiry":"36h"}`, storedTarget(t, env, unlisted.ID).Config,
|
||||
)
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// TestHandleEntrypointToggle_DoesNotUndoAnEdit proves that a toggle
|
||||
// which loaded the entrypoint before an edit of its description was
|
||||
// saved does not write the old description back over the edit. The
|
||||
// edit is submitted from a callback on the toggle's own read of the
|
||||
// entrypoint, so it is saved after that read and before the toggle
|
||||
// writes.
|
||||
func TestHandleEntrypointToggle_DoesNotUndoAnEdit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
wh := seedWebhookWithRetention(t, env.db, 30)
|
||||
ep := seedEntrypoint(t, env.db, wh.ID)
|
||||
require.True(t, ep.Active)
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Post(
|
||||
"/hook/{sourceID}/entrypoints/{entrypointID}/edit",
|
||||
env.handlers.HandleEntrypointEdit(),
|
||||
)
|
||||
router.Post(
|
||||
"/hook/{sourceID}/entrypoints/{entrypointID}/toggle",
|
||||
env.handlers.HandleEntrypointToggle(),
|
||||
)
|
||||
|
||||
// post submits one of the entrypoint's forms as the test user and
|
||||
// returns the response's status code.
|
||||
post := func(action string, form url.Values) int {
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodPost,
|
||||
"/hook/"+wh.ID+"/entrypoints/"+ep.ID+"/"+action,
|
||||
strings.NewReader(form.Encode()),
|
||||
)
|
||||
req.Header.Set(
|
||||
"Content-Type", "application/x-www-form-urlencoded",
|
||||
)
|
||||
|
||||
for _, c := range env.cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
return w.Code
|
||||
}
|
||||
|
||||
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) {
|
||||
// Only the first read of an entrypoint, the toggle's,
|
||||
// submits the edit.
|
||||
if tx.Statement.Table != "entrypoints" || edited {
|
||||
return
|
||||
}
|
||||
|
||||
edited = true
|
||||
editCode = post(
|
||||
"edit", url.Values{"description": {"Billing sender"}},
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
require.Equal(t, http.StatusSeeOther, post("toggle", nil))
|
||||
require.Equal(t, http.StatusSeeOther, editCode)
|
||||
|
||||
var stored database.Entrypoint
|
||||
|
||||
require.NoError(
|
||||
t, env.db.DB().First(&stored, "id = ?", ep.ID).Error,
|
||||
)
|
||||
assert.False(t, stored.Active)
|
||||
assert.Equal(t, "Billing sender", stored.Description)
|
||||
}
|
||||
@@ -1,11 +1,6 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
@@ -16,21 +11,6 @@ type EntrypointView struct {
|
||||
Path string
|
||||
Description string
|
||||
Active bool
|
||||
|
||||
// Events is how many events arrived on the entrypoint's URL within
|
||||
// the webhook's retention period. LastEvent is when the newest
|
||||
// event ever to arrive on it did, relative, and LastEventUTC the
|
||||
// full time; both are empty when none ever did.
|
||||
Events int64
|
||||
LastEvent string
|
||||
LastEventUTC string
|
||||
}
|
||||
|
||||
// entrypointEvents is one entrypoint's count read by
|
||||
// addEntrypointEvents.
|
||||
type entrypointEvents struct {
|
||||
EntrypointID string
|
||||
Events int64
|
||||
}
|
||||
|
||||
// NewEntrypointViews projects entrypoints for rendering.
|
||||
@@ -52,60 +32,3 @@ func NewEntrypointViews(
|
||||
|
||||
return views
|
||||
}
|
||||
|
||||
// addEntrypointEvents fills in each view's event figures from the
|
||||
// webhook's event database: when the last event arrived on its URL,
|
||||
// from its EntrypointTotals row, and how many events arrived on it
|
||||
// since the webhook's retention cutoff, counted in one query over the
|
||||
// events' entrypoint_id index. Resubmitted copies did not arrive on
|
||||
// the URL and are left out of both.
|
||||
func addEntrypointEvents(
|
||||
webhookDB *gorm.DB,
|
||||
webhook *database.Webhook,
|
||||
views []EntrypointView,
|
||||
now time.Time,
|
||||
) error {
|
||||
ids := make([]string, len(views))
|
||||
byID := make(map[string]*EntrypointView, len(views))
|
||||
|
||||
for i := range views {
|
||||
ids[i] = views[i].ID
|
||||
byID[views[i].ID] = &views[i]
|
||||
}
|
||||
|
||||
var totals []database.EntrypointTotals
|
||||
|
||||
err := webhookDB.Where("entrypoint_id IN ?", ids).Find(&totals).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading entrypoint totals: %w", err)
|
||||
}
|
||||
|
||||
query := webhookDB.Model(&database.Event{}).
|
||||
Select("entrypoint_id, count(*) AS events").
|
||||
Where("entrypoint_id IN ? AND resubmitted_from_id IS NULL", ids)
|
||||
|
||||
cutoff, finite := webhook.RetentionCutoff(now)
|
||||
if finite {
|
||||
query = query.Where("created_at >= ?", cutoff)
|
||||
}
|
||||
|
||||
var counts []entrypointEvents
|
||||
|
||||
err = query.Group("entrypoint_id").Find(&counts).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("counting events by entrypoint: %w", err)
|
||||
}
|
||||
|
||||
for _, row := range totals {
|
||||
view := byID[row.EntrypointID]
|
||||
view.LastEvent = humanize.Time(row.LastEventAt)
|
||||
view.LastEventUTC =
|
||||
row.LastEventAt.UTC().Format(time.DateTime) + " UTC"
|
||||
}
|
||||
|
||||
for _, row := range counts {
|
||||
byID[row.EntrypointID].Events = row.Events
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm/clause"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
// entrypointRow returns the part of a rendered webhook page from an
|
||||
// entrypoint's URL to the next entrypoint's, which holds its figures.
|
||||
func entrypointRow(t *testing.T, page, entrypointID string) string {
|
||||
t.Helper()
|
||||
|
||||
_, row, found := strings.Cut(page, `id="entrypoint-url-`+entrypointID+`"`)
|
||||
require.True(t, found)
|
||||
|
||||
row, _, _ = strings.Cut(row, `id="entrypoint-url-`)
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
// lastEventShown matches an entrypoint row's last event arriving at at.
|
||||
func lastEventShown(at time.Time) string {
|
||||
return `Last Event:</span>\s*<span title="` +
|
||||
at.UTC().Format(time.DateTime) + ` UTC">[^<]+</span>`
|
||||
}
|
||||
|
||||
// eventsShown matches an entrypoint row's count of n events.
|
||||
func eventsShown(n int) string {
|
||||
return `Events Within Retention:</span>\s*<span>` +
|
||||
strconv.Itoa(n) + `</span>`
|
||||
}
|
||||
|
||||
// TestHandleSourceDetail_ShowsEntrypointEvents proves each entrypoint
|
||||
// on the webhook page shows its own figures: how many events arrived
|
||||
// through it within the webhook's retention period, leaving out one
|
||||
// older than that, and when the newest arrived, or "never" for an
|
||||
// entrypoint with none.
|
||||
func TestHandleSourceDetail_ShowsEntrypointEvents(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
dbMgr *database.WebhookDBManager
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
wh := &database.Webhook{
|
||||
UserID: deleteTestUserID, Name: "figures", RetentionDays: 7,
|
||||
}
|
||||
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
||||
|
||||
webhookDB, err := dbMgr.GetDB(wh.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
entrypoint := func() *database.Entrypoint {
|
||||
ep := &database.Entrypoint{
|
||||
WebhookID: wh.ID, Path: uuid.New().String(), Active: true,
|
||||
}
|
||||
require.NoError(t,
|
||||
db.DB().Omit(clause.Associations).Create(ep).Error)
|
||||
|
||||
return ep
|
||||
}
|
||||
|
||||
// event stores an event that arrived on ep's URL age ago and
|
||||
// records it as ep's last event, as the receiver does.
|
||||
event := func(ep *database.Entrypoint, age time.Duration) time.Time {
|
||||
e := &database.Event{
|
||||
WebhookID: wh.ID,
|
||||
EntrypointID: ep.ID,
|
||||
Method: http.MethodPost,
|
||||
}
|
||||
e.CreatedAt = time.Now().Add(-age)
|
||||
require.NoError(t,
|
||||
webhookDB.Omit(clause.Associations).Create(e).Error)
|
||||
require.NoError(t, database.AddEntrypointTotals(webhookDB,
|
||||
database.EntrypointTotals{
|
||||
EntrypointID: ep.ID, LastEventAt: e.CreatedAt,
|
||||
}))
|
||||
|
||||
return e.CreatedAt
|
||||
}
|
||||
|
||||
busy, quiet, unused := entrypoint(), entrypoint(), entrypoint()
|
||||
|
||||
event(busy, 8*24*time.Hour) // older than the 7 days kept
|
||||
event(busy, 3*time.Hour)
|
||||
busyLast := event(busy, time.Hour)
|
||||
quietLast := event(quiet, 2*24*time.Hour)
|
||||
|
||||
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
||||
|
||||
assert.Regexp(t, lastEventShown(busyLast), entrypointRow(t, body, busy.ID))
|
||||
assert.Regexp(t, eventsShown(2), entrypointRow(t, body, busy.ID))
|
||||
assert.Regexp(t, lastEventShown(quietLast), entrypointRow(t, body, quiet.ID))
|
||||
assert.Regexp(t, eventsShown(1), entrypointRow(t, body, quiet.ID))
|
||||
assert.Regexp(t, `Last Event:</span>\s*<span>never</span>`,
|
||||
entrypointRow(t, body, unused.ID))
|
||||
assert.Regexp(t, eventsShown(0), entrypointRow(t, body, unused.ID))
|
||||
}
|
||||
|
||||
// TestHandleSourceDetail_EntrypointLastEventSurvivesRetention checks
|
||||
// that once retention has removed every event that arrived on an
|
||||
// entrypoint's URL, the entrypoint still shows when the last one
|
||||
// arrived rather than "never".
|
||||
func TestHandleSourceDetail_EntrypointLastEventSurvivesRetention(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
dbMgr *database.WebhookDBManager
|
||||
log *logger.Logger
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
wh := &database.Webhook{
|
||||
UserID: deleteTestUserID, Name: "swept", RetentionDays: 1,
|
||||
}
|
||||
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
||||
|
||||
ep := seedEntrypoint(t, db, wh.ID)
|
||||
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, ep.Path, 1)
|
||||
arrived := events[0].CreatedAt
|
||||
|
||||
statsAge(t, webhookDB, events[0].ID, time.Now().Add(-50*time.Hour))
|
||||
statsPrune(t, db, dbMgr, log, webhookDB)
|
||||
require.Empty(t, listEvents(t, webhookDB))
|
||||
|
||||
row := entrypointRow(t, renderSourceDetailPage(t, h, sess, wh.ID), ep.ID)
|
||||
assert.Regexp(t, lastEventShown(arrived), row)
|
||||
assert.Regexp(t, eventsShown(0), row)
|
||||
}
|
||||
|
||||
// TestHandleSourceDetail_ResubmitLeavesEntrypointFigures checks that a
|
||||
// resubmitted copy, which did not arrive on the entrypoint's URL,
|
||||
// changes neither the entrypoint's last event nor its count.
|
||||
func TestHandleSourceDetail_ResubmitLeavesEntrypointFigures(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
dbMgr *database.WebhookDBManager
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
wh := seedWebhook(t, db)
|
||||
ep := seedEntrypoint(t, db, wh.ID)
|
||||
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, ep.Path, 1)
|
||||
arrived := events[0].CreatedAt
|
||||
|
||||
require.Equal(t, http.StatusSeeOther,
|
||||
postResubmit(t, h, sess, wh.ID, events[0].ID).Code)
|
||||
require.Len(t, listEvents(t, webhookDB), 2)
|
||||
|
||||
var totals database.EntrypointTotals
|
||||
|
||||
require.NoError(t, webhookDB.Take(&totals).Error)
|
||||
assert.True(t, arrived.Equal(totals.LastEventAt))
|
||||
|
||||
row := entrypointRow(t, renderSourceDetailPage(t, h, sess, wh.ID), ep.ID)
|
||||
assert.Regexp(t, lastEventShown(arrived), row)
|
||||
assert.Regexp(t, eventsShown(1), row)
|
||||
}
|
||||
@@ -15,19 +15,16 @@ 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 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.
|
||||
// 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.
|
||||
const eventBodyQuery = "SELECT cast(body as blob) " +
|
||||
"FROM events WHERE id = ? AND webhook_id = ? AND deleted_at IS NULL"
|
||||
|
||||
// HandleEventBodyDownload serves one event's stored body byte
|
||||
// for byte, which the pages do not: they show it as escaped
|
||||
// text, cut at maxRenderedBodyBytes in the lists of events, and
|
||||
// leave a binary one out.
|
||||
// HandleEventBodyDownload serves one event's stored body in
|
||||
// full, which the event log page cannot: it caps each rendered
|
||||
// body at maxRenderedBodyBytes.
|
||||
//
|
||||
// The bytes are attacker-supplied — anyone who can reach the
|
||||
// public receiver chooses them — and this route hands them back
|
||||
|
||||
@@ -405,11 +405,10 @@ 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. 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.
|
||||
// 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.
|
||||
func TestHandleEventBodyDownload_ReapedEvent404s(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// maxRenderedBodyBytes is the most of one event's body that the
|
||||
// recent events on a webhook's page and the event log show; a larger
|
||||
// body is cut there and shown whole only on the event's own page.
|
||||
// Bodies come from the unauthenticated receiver under its 1 MB cap,
|
||||
// and renderTemplate buffers a whole page before writing it, so a list
|
||||
// of events cannot show every body whole.
|
||||
const maxRenderedBodyBytes = 32 << 10
|
||||
|
||||
// maxInlineBodyLines is the most lines a body is shown at its full
|
||||
// height with. A body with more lines, or larger than
|
||||
// maxRenderedBodyBytes, is shown in a box of fixed height that
|
||||
// scrolls, so that it does not make the page huge.
|
||||
const maxInlineBodyLines = 200
|
||||
|
||||
// maxIndentDepth is how deeply a JSON body's objects and arrays may
|
||||
// nest for it to be indented at all; a deeper one is shown as received.
|
||||
// Each level indents every line inside it two more spaces, so 10 KB of
|
||||
// nested brackets would indent to some 50 MB; within this depth a body
|
||||
// grows at most 35 times.
|
||||
const maxIndentDepth = 16
|
||||
|
||||
// A JSON body is shown pretty-printed only when that makes it at most
|
||||
// maxIndentGrowth times its size plus indentAllowance bytes, and
|
||||
// otherwise as received, so that indenting does not undo
|
||||
// maxRenderedBodyBytes. The allowance keeps a small nested body
|
||||
// pretty-printed.
|
||||
const (
|
||||
maxIndentGrowth = 4
|
||||
indentAllowance = 1 << 10
|
||||
)
|
||||
|
||||
// jsonIndent is the indent of a pretty-printed JSON body.
|
||||
const jsonIndent = " "
|
||||
|
||||
// BodyView is an event's body as the pages show it. newBodyView
|
||||
// decides it and templates/event_body.html shows it, the same way in
|
||||
// the recent events on a webhook's page, in the event log and on the
|
||||
// event's own page.
|
||||
type BodyView struct {
|
||||
// EventURL is the event's own page. The stored body downloads
|
||||
// from EventURL/body.
|
||||
EventURL string
|
||||
|
||||
// Text is the body as shown, pretty-printed when it is JSON.
|
||||
Text string
|
||||
|
||||
// Size is the stored body's size in bytes, and ShownBytes how
|
||||
// many of them Text holds when Cut.
|
||||
Size int64
|
||||
ShownBytes int
|
||||
|
||||
// Cut reports that Text is only the start of the body.
|
||||
Cut bool
|
||||
|
||||
// Binary reports a body that is not text. It is not shown.
|
||||
Binary bool
|
||||
|
||||
// Scroll reports a body to show in a box that scrolls.
|
||||
Scroll bool
|
||||
}
|
||||
|
||||
// newBodyView decides how to show an event's body. body is the
|
||||
// stored body, or its first maxRenderedBodyBytes when only those were
|
||||
// read, and size is the stored body's size.
|
||||
func newBodyView(eventURL string, body []byte, size int64) BodyView {
|
||||
v := BodyView{EventURL: eventURL, Size: size}
|
||||
|
||||
if size > int64(len(body)) {
|
||||
v.Cut = true
|
||||
body = trimPartialRune(body)
|
||||
v.ShownBytes = len(body)
|
||||
}
|
||||
|
||||
// html/template shows invalid UTF-8 as replacement characters,
|
||||
// and a browser shows a control character other than tab, line
|
||||
// feed and carriage return as a box or not at all, so a body
|
||||
// holding either is not text.
|
||||
isControl := func(r rune) bool {
|
||||
return unicode.IsControl(r) && r != '\t' && r != '\n' && r != '\r'
|
||||
}
|
||||
|
||||
if !utf8.Valid(body) || bytes.IndexFunc(body, isControl) >= 0 {
|
||||
v.Binary = true
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
// A cut JSON document is no longer valid JSON.
|
||||
if !v.Cut {
|
||||
body = indentJSON(body)
|
||||
}
|
||||
|
||||
// The page shows a carriage return, a line feed, or the two
|
||||
// together as one line break. A final one ends the last line
|
||||
// rather than starting another.
|
||||
text := bytes.TrimSuffix(body, []byte("\n"))
|
||||
text = bytes.TrimSuffix(text, []byte("\r"))
|
||||
breaks := bytes.Count(text, []byte("\n")) + bytes.Count(text, []byte("\r")) -
|
||||
bytes.Count(text, []byte("\r\n"))
|
||||
lines := breaks + 1
|
||||
|
||||
v.Text = string(body)
|
||||
v.Scroll = lines > maxInlineBodyLines || size > maxRenderedBodyBytes
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
// indentJSON returns body pretty-printed when it is a JSON document,
|
||||
// and unchanged when it is not, nests deeper than maxIndentDepth, or
|
||||
// would grow past maxIndentGrowth times its size plus indentAllowance
|
||||
// bytes.
|
||||
func indentJSON(body []byte) []byte {
|
||||
if !json.Valid(body) || !indentFits(body) {
|
||||
return body
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
err := json.Indent(&out, body, "", jsonIndent)
|
||||
if err != nil || out.Len() > maxIndentGrowth*len(body)+indentAllowance {
|
||||
return body
|
||||
}
|
||||
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
// indentFits reports whether the objects and arrays of the JSON
|
||||
// document body nest at most maxIndentDepth deep.
|
||||
func indentFits(body []byte) bool {
|
||||
depth := 0
|
||||
|
||||
dec := json.NewDecoder(bytes.NewReader(body))
|
||||
|
||||
// A number too large for a float64 is still valid JSON.
|
||||
dec.UseNumber()
|
||||
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return true
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
switch tok {
|
||||
case json.Delim('{'), json.Delim('['):
|
||||
depth++
|
||||
if depth > maxIndentDepth {
|
||||
return false
|
||||
}
|
||||
case json.Delim('}'), json.Delim(']'):
|
||||
depth--
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
)
|
||||
|
||||
// bodyView is how the pages would show body, stored whole.
|
||||
func bodyView(body string) handlers.BodyView {
|
||||
return handlers.NewBodyViewForTest([]byte(body), int64(len(body)))
|
||||
}
|
||||
|
||||
// lines is n lines of text, without a newline after the last.
|
||||
func lines(n int) string {
|
||||
return strings.TrimSuffix(strings.Repeat("line\n", n), "\n")
|
||||
}
|
||||
|
||||
// TestNewBodyView_FormatsValidJSON proves a JSON body is shown
|
||||
// pretty-printed, whatever its content type, with its keys in
|
||||
// the order they arrived.
|
||||
func TestNewBodyView_FormatsValidJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
v := bodyView(`{"b":1,"a":[true,null,"x"],"c":{}}`)
|
||||
|
||||
assert.Equal(t, []string{
|
||||
`{`,
|
||||
` "b": 1,`,
|
||||
` "a": [`,
|
||||
` true,`,
|
||||
` null,`,
|
||||
` "x"`,
|
||||
` ],`,
|
||||
` "c": {}`,
|
||||
`}`,
|
||||
}, strings.Split(v.Text, "\n"))
|
||||
assert.False(t, v.Scroll)
|
||||
}
|
||||
|
||||
// TestNewBodyView_FormatsNestedJSON proves a small document with a
|
||||
// few levels of nesting is pretty-printed.
|
||||
func TestNewBodyView_FormatsNestedJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
v := bodyView(`{"data":[[1,2,3],[4,5,6]]}`)
|
||||
|
||||
assert.Equal(t, []string{
|
||||
`{`,
|
||||
` "data": [`,
|
||||
` [`,
|
||||
` 1,`,
|
||||
` 2,`,
|
||||
` 3`,
|
||||
` ],`,
|
||||
` [`,
|
||||
` 4,`,
|
||||
` 5,`,
|
||||
` 6`,
|
||||
` ]`,
|
||||
` ]`,
|
||||
`}`,
|
||||
}, strings.Split(v.Text, "\n"))
|
||||
}
|
||||
|
||||
// TestNewBodyView_InvalidJSONAsReceived proves a body that is not
|
||||
// a JSON document is shown exactly as it arrived.
|
||||
func TestNewBodyView_InvalidJSONAsReceived(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, body := range []string{
|
||||
`{"a":1,`,
|
||||
`{"a":1} {"b":2}`,
|
||||
"plain text\n indented",
|
||||
} {
|
||||
assert.Equal(t, body, bodyView(body).Text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewBodyView_DeepJSONAsReceived proves a JSON body nested
|
||||
// more than 16 levels deep is shown as it arrived. 10 KB of nested
|
||||
// arrays would indent to some 50 MB.
|
||||
func TestNewBodyView_DeepJSONAsReceived(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
nested := func(depth int) string {
|
||||
return strings.Repeat("[", depth) + "1" + strings.Repeat("]", depth)
|
||||
}
|
||||
|
||||
assert.NotEqual(t, nested(16), bodyView(nested(16)).Text)
|
||||
assert.Equal(t, nested(17), bodyView(nested(17)).Text)
|
||||
|
||||
body := strings.Repeat("[", 5000) + strings.Repeat("]", 5000)
|
||||
|
||||
assert.Equal(t, body, bodyView(body).Text)
|
||||
}
|
||||
|
||||
// TestNewBodyView_GrowingJSONAsReceived proves a JSON body that
|
||||
// pretty-printing would make more than four times its size plus 1 KiB
|
||||
// is shown as it arrived, however shallow: each short element eight
|
||||
// levels deep gets a line indented sixteen spaces.
|
||||
func TestNewBodyView_GrowingJSONAsReceived(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
numbers := func(n int) string {
|
||||
return strings.Repeat("[", 8) +
|
||||
strings.TrimSuffix(strings.Repeat("1,", n), ",") +
|
||||
strings.Repeat("]", 8)
|
||||
}
|
||||
|
||||
assert.NotEqual(t, numbers(10), bodyView(numbers(10)).Text)
|
||||
assert.Equal(t, numbers(1000), bodyView(numbers(1000)).Text)
|
||||
}
|
||||
|
||||
// TestNewBodyView_ScrollsPast200Lines proves a body is shown at
|
||||
// its full height up to 200 lines and in the scrolling box past
|
||||
// them, counting the lines after formatting.
|
||||
func TestNewBodyView_ScrollsPast200Lines(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.False(t, bodyView(lines(200)).Scroll)
|
||||
assert.True(t, bodyView(lines(201)).Scroll)
|
||||
|
||||
// A final newline ends the last line rather than starting another.
|
||||
assert.False(t, bodyView(lines(200)+"\n").Scroll)
|
||||
assert.True(t, bodyView(lines(201)+"\n").Scroll)
|
||||
|
||||
// The page shows a carriage return, a line feed, or the two
|
||||
// together as one line break.
|
||||
assert.True(t, bodyView(strings.Repeat("line\r", 400)).Scroll)
|
||||
assert.False(t, bodyView(strings.Repeat("line\r\n", 200)).Scroll)
|
||||
|
||||
// One line as received, 201 once formatted: the brackets and
|
||||
// 199 elements.
|
||||
numbers := "[" + strings.TrimSuffix(strings.Repeat("1,", 199), ",") + "]"
|
||||
|
||||
assert.NotContains(t, numbers, "\n")
|
||||
assert.True(t, bodyView(numbers).Scroll)
|
||||
}
|
||||
|
||||
// TestNewBodyView_LargeBodyScrolls proves a body larger than the
|
||||
// cap of the lists of events is shown in the scrolling box
|
||||
// however few lines it has, on the event's own page as in the
|
||||
// lists.
|
||||
func TestNewBodyView_LargeBodyScrolls(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.False(t, bodyView(strings.Repeat("x", bodyCap)).Scroll)
|
||||
assert.True(t, bodyView(strings.Repeat("x", bodyCap+1)).Scroll)
|
||||
}
|
||||
|
||||
// TestNewBodyView_BinaryNotShown proves a body that is not text
|
||||
// is never shown: one that is not valid UTF-8, or that holds a
|
||||
// control character other than tab, line feed and carriage return.
|
||||
func TestNewBodyView_BinaryNotShown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, body := range []string{
|
||||
"\xff\xfe\xfd",
|
||||
"a\x00b",
|
||||
// A small protobuf message: valid UTF-8, but control bytes.
|
||||
"\x08\x01\x12\x03abc",
|
||||
"\x1b[31mred\x1b[0m",
|
||||
"a\x7fb",
|
||||
} {
|
||||
v := bodyView(body)
|
||||
|
||||
assert.True(t, v.Binary, "%q", body)
|
||||
assert.Empty(t, v.Text)
|
||||
}
|
||||
|
||||
assert.False(t, bodyView("snow "+snowman).Binary)
|
||||
assert.False(t, bodyView("a\tb\r\nc\n").Binary)
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// HandleEventDetail shows one event on its own page: its details,
|
||||
// its whole body and every delivery of it. The page reads the
|
||||
// event's body whole, which the receiver caps at 1 MB.
|
||||
func (h *Handlers) HandleEventDetail() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
webhook, ok := h.ownedWebhook(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if !h.dbMgr.DBExists(webhook.ID) {
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||
if err != nil {
|
||||
h.serverError(w, r, "failed to get webhook database", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var rows []eventLogRow
|
||||
|
||||
err = webhookDB.Model(&database.Event{}).
|
||||
Select(eventColumns).
|
||||
Where(
|
||||
"id = ? AND webhook_id = ?",
|
||||
chi.URLParam(r, "eventID"), webhook.ID,
|
||||
).
|
||||
Limit(1).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
h.serverError(w, r, "failed to load event", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
targets, err := h.loadTargetMap(webhook.ID)
|
||||
if err != nil {
|
||||
h.serverError(w, r, "failed to load targets", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
views, ok := h.eventLogViews(
|
||||
w, r, webhookDB, webhook.ID, rows, targets,
|
||||
)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
h.renderTemplate(w, r, "event_detail.html", map[string]any{
|
||||
tmplKeyWebhook: &webhook,
|
||||
"Event": views[0],
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm/clause"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
// serveEventPage runs the real event page handler as the test user
|
||||
// for the given webhook and event ids.
|
||||
func serveEventPage(
|
||||
t *testing.T,
|
||||
h *handlers.Handlers,
|
||||
sess *session.Session,
|
||||
webhookID, eventID string,
|
||||
) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet,
|
||||
"/hook/"+webhookID+"/events/"+eventID,
|
||||
nil,
|
||||
)
|
||||
|
||||
for _, c := range authenticatedCookies(
|
||||
t, sess, deleteTestUserID, deleteTestUsername,
|
||||
) {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add(paramSourceID, webhookID)
|
||||
rctx.URLParams.Add(paramEventID, eventID)
|
||||
|
||||
req = req.WithContext(
|
||||
context.WithValue(req.Context(), chi.RouteCtxKey, rctx),
|
||||
)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleEventDetail().ServeHTTP(w, req)
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
// TestHandleEventDetail_ShowsEventWholeWithDeliveries proves the
|
||||
// event's page shows its details, its whole body even past the cap
|
||||
// of the lists of events, pretty-printed and in the scrolling box,
|
||||
// and each delivery with its status and attempts.
|
||||
func TestHandleEventDetail_ShowsEventWholeWithDeliveries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRecentEventsFixture(t)
|
||||
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
|
||||
|
||||
const sentinel = "TAIL-SENTINEL-5b2e"
|
||||
|
||||
body := `{"pad":"` + strings.Repeat("x", 2*bodyCap) +
|
||||
`","tail":"` + sentinel + `"}`
|
||||
event := f.event(t, contentTypeJSON, body, time.Now())
|
||||
f.attempt(t, f.delivery(
|
||||
t, event, target.ID, database.DeliveryStatusFailed,
|
||||
), http.StatusBadGateway, time.Second)
|
||||
|
||||
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
page := w.Body.String()
|
||||
|
||||
assert.Contains(t, page, event.ID)
|
||||
assert.Contains(t, page, contentTypeJSON)
|
||||
assert.Contains(t, page, strconv.Itoa(len(body))+" bytes")
|
||||
assert.Contains(t, page, "{\n "pad": "xxx")
|
||||
assert.Contains(t, page, ""tail": ""+sentinel+""\n}")
|
||||
assert.Contains(t, page, `style="max-height: 32rem; overflow-y: auto"`)
|
||||
assert.NotContains(t, page, "Showing the first")
|
||||
assert.Contains(t, page, target.Name)
|
||||
assert.Contains(t, page, ">failed</span>")
|
||||
assert.Contains(t, page, "Status: 502")
|
||||
}
|
||||
|
||||
// TestHandleEventDetail_ResubmitLinks proves a resubmitted copy's
|
||||
// page links to its original's page, and the original's page says
|
||||
// it was resubmitted.
|
||||
func TestHandleEventDetail_ResubmitLinks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRecentEventsFixture(t)
|
||||
original := f.event(t, contentTypeJSON, "{}", time.Now())
|
||||
|
||||
cp := &database.Event{
|
||||
WebhookID: f.webhook.ID,
|
||||
Method: http.MethodPost,
|
||||
Body: "{}",
|
||||
ContentType: contentTypeJSON,
|
||||
ResubmittedFromID: &original.ID,
|
||||
}
|
||||
require.NoError(t, f.webhookDB.Omit(clause.Associations).Create(cp).Error)
|
||||
|
||||
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, cp.ID)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(
|
||||
t, w.Body.String(),
|
||||
`href="/hook/`+f.webhook.ID+`/events/`+original.ID+`"`,
|
||||
)
|
||||
|
||||
w = serveEventPage(t, f.h, f.sess, f.webhook.ID, original.ID)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "as 1 new event<")
|
||||
}
|
||||
|
||||
// TestHandleEventDetail_UnknownEventNotFound proves the page is a
|
||||
// 404 for an event that does not exist and for one that belongs to
|
||||
// another webhook.
|
||||
func TestHandleEventDetail_UnknownEventNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
dbMgr *database.WebhookDBManager
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
mine := seedWebhook(t, db)
|
||||
theirs := seedWebhook(t, db)
|
||||
|
||||
seedEventWithBody(t, dbMgr, mine.ID, "{}")
|
||||
elsewhere := seedEventWithBody(t, dbMgr, theirs.ID, "{}")
|
||||
|
||||
for _, id := range []string{"no-such-event", elsewhere.ID} {
|
||||
w := serveEventPage(t, h, sess, mine.ID, id)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code, id)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,14 @@ import (
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// maxRenderedBodyBytes caps how many bytes of a stored event
|
||||
// body reach the event log page. Bodies come from the
|
||||
// unauthenticated receiver under the 1 MB ingest cap and
|
||||
// renderTemplate buffers a whole page before writing it, so
|
||||
// an uncapped page of paginationPerPage events is tens of
|
||||
// megabytes of resident memory per concurrent viewer.
|
||||
const maxRenderedBodyBytes = 8192
|
||||
|
||||
// eventLogColumns is the event log's projection. The casts to
|
||||
// blob are load-bearing: they make substr and length count
|
||||
// bytes rather than characters, so the cap bounds the page in
|
||||
@@ -16,23 +24,27 @@ const eventLogColumns = "id, created_at, method, content_type, " +
|
||||
"substr(cast(body as blob), 1, ?) AS body, " +
|
||||
"length(cast(body as blob)) AS body_bytes"
|
||||
|
||||
// eventColumns is eventLogColumns for the event's own page, which
|
||||
// shows the whole body.
|
||||
const eventColumns = "id, created_at, method, content_type, " +
|
||||
"resubmitted_from_id, " +
|
||||
"cast(body as blob) AS body, " +
|
||||
"length(cast(body as blob)) AS body_bytes"
|
||||
|
||||
// EventLogView is the display-safe projection of an event for
|
||||
// the event log page and the event's own page, alongside
|
||||
// DeliveryView and TargetView.
|
||||
// the event log page, alongside DeliveryView and TargetView.
|
||||
// It carries a capped body plus the true stored size, so the
|
||||
// page can mark a body as truncated without ever holding the
|
||||
// whole thing.
|
||||
type EventLogView struct {
|
||||
ID string
|
||||
CreatedAt time.Time
|
||||
Method string
|
||||
ContentType string
|
||||
|
||||
Body BodyView
|
||||
// Body holds at most maxRenderedBodyBytes bytes of the
|
||||
// stored body.
|
||||
Body string
|
||||
|
||||
// BodyBytes is the true size of the stored body.
|
||||
BodyBytes int64
|
||||
|
||||
// BodyTruncated reports that the stored body was larger
|
||||
// than the cap, so the page owes the reader a marker.
|
||||
BodyTruncated bool
|
||||
|
||||
// ResubmittedFromID names the event this one was copied
|
||||
// from, empty for an event that arrived on the receiver.
|
||||
@@ -53,10 +65,16 @@ func (v EventLogView) ResubmittedFrom() bool {
|
||||
return v.ResubmittedFromID != ""
|
||||
}
|
||||
|
||||
// eventLogRow is one row of the event log projection, or of
|
||||
// eventColumns. In the event log its body column arrives
|
||||
// already cut to the cap by SQLite, with the true size beside
|
||||
// it.
|
||||
// BodyShownBytes is how many body bytes the page is actually
|
||||
// rendering, which the truncation marker reports beside the
|
||||
// true size.
|
||||
func (v EventLogView) BodyShownBytes() int {
|
||||
return len(v.Body)
|
||||
}
|
||||
|
||||
// eventLogRow is one row of the event log projection. Its
|
||||
// body column arrives already cut to the cap by SQLite, with
|
||||
// the true size beside it.
|
||||
type eventLogRow struct {
|
||||
ID string
|
||||
CreatedAt time.Time
|
||||
@@ -67,22 +85,31 @@ type eventLogRow struct {
|
||||
BodyBytes int64
|
||||
}
|
||||
|
||||
// view projects a loaded row of the webhook's events for
|
||||
// rendering.
|
||||
func (r *eventLogRow) view(webhookID string) EventLogView {
|
||||
// view projects a loaded row for rendering.
|
||||
func (r *eventLogRow) view() EventLogView {
|
||||
body := r.Body
|
||||
truncated := r.BodyBytes > int64(len(body))
|
||||
|
||||
// Only a cut body can have been left mid-sequence by
|
||||
// this query. A whole body is passed through exactly as
|
||||
// stored, however malformed.
|
||||
if truncated {
|
||||
body = trimPartialRune(body)
|
||||
}
|
||||
|
||||
var from string
|
||||
if r.ResubmittedFromID != nil {
|
||||
from = *r.ResubmittedFromID
|
||||
}
|
||||
|
||||
return EventLogView{
|
||||
ID: r.ID,
|
||||
CreatedAt: r.CreatedAt,
|
||||
Method: r.Method,
|
||||
ContentType: r.ContentType,
|
||||
Body: newBodyView(
|
||||
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
|
||||
),
|
||||
ID: r.ID,
|
||||
CreatedAt: r.CreatedAt,
|
||||
Method: r.Method,
|
||||
ContentType: r.ContentType,
|
||||
Body: string(body),
|
||||
BodyBytes: r.BodyBytes,
|
||||
BodyTruncated: truncated,
|
||||
ResubmittedFromID: from,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
// bodyCap is the number of body bytes the lists of events are
|
||||
// bodyCap is the number of body bytes the event log page is
|
||||
// allowed to render for one event.
|
||||
const bodyCap = handlers.MaxRenderedBodyBytesForTest
|
||||
|
||||
@@ -85,7 +85,7 @@ func seedAndProject(
|
||||
|
||||
// TestHandleSourceLogs_BoundsOversizeBody proves the rendered
|
||||
// page is bounded by the cap rather than by the stored payload:
|
||||
// the body here is 16 times the cap, and the ingest path would
|
||||
// the body here is 64 times the cap, and the ingest path would
|
||||
// accept twice as much again.
|
||||
func TestHandleSourceLogs_BoundsOversizeBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -123,7 +123,7 @@ func TestHandleSourceLogs_BoundsOversizeBody(t *testing.T) {
|
||||
// The marker states the true stored size, not the cut one.
|
||||
assert.Contains(
|
||||
t, page,
|
||||
"Showing the first "+strconv.Itoa(bodyCap)+
|
||||
"showing "+strconv.Itoa(bodyCap)+
|
||||
" of "+strconv.Itoa(storedBytes)+" bytes",
|
||||
)
|
||||
}
|
||||
@@ -152,35 +152,34 @@ func TestHandleSourceLogs_SmallBodyRendersWhole(t *testing.T) {
|
||||
page := renderSourceLogsPage(t, h, sess, wh.ID)
|
||||
|
||||
assert.Contains(t, page, ""kept"")
|
||||
assert.NotContains(t, page, "Showing the first")
|
||||
assert.NotContains(t, page, "Body truncated for display")
|
||||
}
|
||||
|
||||
// TestEventLogView_CutMidRune proves a multi-byte rune severed
|
||||
// by the byte-wise cut is dropped rather than surfaced as a
|
||||
// mojibake tail, which would also make the text look binary.
|
||||
// mojibake tail.
|
||||
func TestEventLogView_CutMidRune(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := strings.Repeat(snowman, bodyCap)
|
||||
body := strings.Repeat(snowman, 4096)
|
||||
view := seedAndProject(t, body)
|
||||
|
||||
// bodyCap bytes hold bodyCap/3 whole snowmen and two bytes
|
||||
// of the next one; those two are dropped.
|
||||
whole := bodyCap / len(snowman)
|
||||
|
||||
assert.True(t, view.Body.Cut)
|
||||
assert.False(t, view.Body.Binary)
|
||||
assert.Equal(t, int64(len(body)), view.Body.Size)
|
||||
assert.Equal(t, strings.Repeat(snowman, whole), view.Body.Text)
|
||||
assert.True(t, utf8.ValidString(view.Body.Text))
|
||||
assert.Equal(t, len(view.Body.Text), view.Body.ShownBytes)
|
||||
assert.LessOrEqual(t, view.Body.ShownBytes, bodyCap)
|
||||
assert.True(t, view.BodyTruncated)
|
||||
assert.Equal(t, int64(len(body)), view.BodyBytes)
|
||||
assert.Equal(t, strings.Repeat(snowman, whole), view.Body)
|
||||
assert.True(t, utf8.ValidString(view.Body))
|
||||
assert.LessOrEqual(t, len(view.Body), bodyCap)
|
||||
}
|
||||
|
||||
// TestEventLogView_BinaryBodyNotShown proves a body that is not
|
||||
// text is left out rather than shown as replacement characters,
|
||||
// whether it is cut or not.
|
||||
func TestEventLogView_BinaryBodyNotShown(t *testing.T) {
|
||||
// TestEventLogView_BinaryBodyLeftAsStored proves a binary
|
||||
// payload is passed through byte for byte. Its tail is invalid
|
||||
// UTF-8 however the cut falls, so repairing it would misreport
|
||||
// what the sender delivered.
|
||||
func TestEventLogView_BinaryBodyLeftAsStored(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := make([]byte, bodyCap+808)
|
||||
@@ -189,21 +188,12 @@ func TestEventLogView_BinaryBodyNotShown(t *testing.T) {
|
||||
raw[i] = 0x80 | byte(i%0x40)
|
||||
}
|
||||
|
||||
for name, body := range map[string][]byte{
|
||||
"cut": raw,
|
||||
"whole": raw[:2048],
|
||||
"NUL": []byte("text\x00text"),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
view := seedAndProject(t, string(raw))
|
||||
|
||||
view := seedAndProject(t, string(body))
|
||||
|
||||
assert.True(t, view.Body.Binary)
|
||||
assert.Empty(t, view.Body.Text)
|
||||
assert.Equal(t, int64(len(body)), view.Body.Size)
|
||||
})
|
||||
}
|
||||
assert.True(t, view.BodyTruncated)
|
||||
assert.Equal(t, int64(len(raw)), view.BodyBytes)
|
||||
assert.Equal(t, string(raw[:bodyCap]), view.Body)
|
||||
assert.False(t, utf8.ValidString(view.Body))
|
||||
}
|
||||
|
||||
// TestTrimPartialRune covers the distinction the cut repair
|
||||
|
||||
@@ -19,16 +19,10 @@ func (s *Handlers) SetLogForTest(log *slog.Logger) {
|
||||
s.log = log
|
||||
}
|
||||
|
||||
// MaxRenderedBodyBytesForTest exposes the body cap of the lists
|
||||
// of events to the handlers_test package.
|
||||
// MaxRenderedBodyBytesForTest exposes the event log's body cap
|
||||
// to the handlers_test package.
|
||||
const MaxRenderedBodyBytesForTest = maxRenderedBodyBytes
|
||||
|
||||
// NewBodyViewForTest exposes newBodyView for use in the
|
||||
// handlers_test package.
|
||||
func NewBodyViewForTest(body []byte, size int64) BodyView {
|
||||
return newBodyView("/hook/w/events/e", body, size)
|
||||
}
|
||||
|
||||
// MaxRenderedResponseBytesForTest exposes the event log's
|
||||
// delivery response cap to the handlers_test package.
|
||||
const MaxRenderedResponseBytesForTest = maxRenderedResponseBytes
|
||||
@@ -143,20 +137,22 @@ func (s *Handlers) RenderTemplateForTest(
|
||||
// BuildSlackTargetConfigForTest exposes
|
||||
// buildSlackTargetConfig for use in the handlers_test package.
|
||||
func (s *Handlers) BuildSlackTargetConfigForTest(
|
||||
ctx context.Context,
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
targetURL string,
|
||||
) (string, string, error) {
|
||||
return s.buildSlackTargetConfig(ctx, targetURL)
|
||||
) (string, error) {
|
||||
return s.buildSlackTargetConfig(w, r, 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(
|
||||
ctx context.Context,
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
targetURL, headers, timeout string,
|
||||
) (string, string, error) {
|
||||
return s.buildHTTPTargetConfig(ctx, targetFormInput{
|
||||
) (string, error) {
|
||||
return s.buildHTTPTargetConfig(w, r, targetFormInput{
|
||||
URL: targetURL,
|
||||
Headers: headers,
|
||||
Timeout: timeout,
|
||||
@@ -166,8 +162,9 @@ func (s *Handlers) BuildHTTPTargetConfigForTest(
|
||||
// BuildDatabaseTargetConfigForTest exposes
|
||||
// buildDatabaseTargetConfig for use in the handlers_test
|
||||
// package.
|
||||
func BuildDatabaseTargetConfigForTest(
|
||||
func (s *Handlers) BuildDatabaseTargetConfigForTest(
|
||||
w http.ResponseWriter,
|
||||
expiry string,
|
||||
) (string, string, error) {
|
||||
return buildDatabaseTargetConfig(expiry)
|
||||
) (string, error) {
|
||||
return s.buildDatabaseTargetConfig(w, newRequestForTest(), expiry)
|
||||
}
|
||||
|
||||
@@ -97,8 +97,7 @@ 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. An archive download holds it while
|
||||
// it reads the stored names and opens the file they give.
|
||||
// stored names from the other.
|
||||
renameMu sync.Mutex
|
||||
|
||||
// dummyVerifications counts the equivalent-cost verifications
|
||||
@@ -110,25 +109,22 @@ type Handlers struct {
|
||||
// parsePageTemplate parses a page-specific template set from the
|
||||
// embedded FS. Each page template is combined with the shared
|
||||
// base, htmlheader, navbar and notice templates, and with any further
|
||||
// files the page includes. The set is named after the page file, so
|
||||
// the page's root action ({{template "base" .}}) is its entry point.
|
||||
//
|
||||
// The page file is parsed last because a later definition of a name
|
||||
// replaces an earlier one: the page's {{define "title"}} must replace
|
||||
// the {{block "title"}} fallback in htmlheader.html.
|
||||
// files the page includes. The page file must be listed first so that
|
||||
// its root action ({{template "base" .}}) becomes the template set's
|
||||
// entry point.
|
||||
func parsePageTemplate(
|
||||
pageFile string, included ...string,
|
||||
) *template.Template {
|
||||
files := append([]string{
|
||||
pageFile,
|
||||
"base.html",
|
||||
"htmlheader.html",
|
||||
"navbar.html",
|
||||
"notice.html",
|
||||
}, included...)
|
||||
files = append(files, pageFile)
|
||||
|
||||
return template.Must(
|
||||
template.New(pageFile).ParseFS(templates.Templates, files...),
|
||||
template.ParseFS(templates.Templates, files...),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -153,23 +149,16 @@ func New(
|
||||
|
||||
// Parse all page templates once at startup
|
||||
s.templates = map[string]*template.Template{
|
||||
"login.html": parsePageTemplate("login.html"),
|
||||
"profile.html": parsePageTemplate("profile.html"),
|
||||
"settings.html": parsePageTemplate("settings.html"),
|
||||
"sources_list.html": parsePageTemplate("sources_list.html"),
|
||||
"sources_new.html": parsePageTemplate("sources_new.html"),
|
||||
"source_detail.html": parsePageTemplate(
|
||||
"source_detail.html", "webhook_stats.html", "event_body.html",
|
||||
),
|
||||
"source_edit.html": parsePageTemplate("source_edit.html"),
|
||||
"source_logs.html": parsePageTemplate(
|
||||
"source_logs.html", "event_body.html", "delivery_attempts.html",
|
||||
),
|
||||
"event_detail.html": parsePageTemplate(
|
||||
"event_detail.html", "event_body.html", "delivery_attempts.html",
|
||||
),
|
||||
"target_edit.html": parsePageTemplate("target_edit.html"),
|
||||
"error.html": parsePageTemplate("error.html"),
|
||||
"login.html": parsePageTemplate("login.html"),
|
||||
"profile.html": parsePageTemplate("profile.html"),
|
||||
"settings.html": parsePageTemplate("settings.html"),
|
||||
"sources_list.html": parsePageTemplate("sources_list.html"),
|
||||
"sources_new.html": parsePageTemplate("sources_new.html"),
|
||||
"source_detail.html": parsePageTemplate("source_detail.html", "webhook_stats.html"),
|
||||
"source_edit.html": parsePageTemplate("source_edit.html"),
|
||||
"source_logs.html": parsePageTemplate("source_logs.html"),
|
||||
"target_edit.html": parsePageTemplate("target_edit.html"),
|
||||
"error.html": parsePageTemplate("error.html"),
|
||||
}
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
@@ -396,7 +385,7 @@ func (s *Handlers) pageData(
|
||||
// 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 lists of events cap each stored body at
|
||||
// owes it a bound: the event log caps each stored body at
|
||||
// maxRenderedBodyBytes for exactly this reason.
|
||||
func (s *Handlers) executeTemplate(
|
||||
w http.ResponseWriter,
|
||||
|
||||
@@ -314,12 +314,16 @@ func TestBuildSlackTargetConfig_AcceptsPublicURL(t *testing.T) {
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
cfg, errMsg, err := h.BuildSlackTargetConfigForTest(
|
||||
t.Context(), "http://93.184.216.34/services/T00/B00/xxx",
|
||||
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",
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, errMsg)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, cfg, "webhookUrl")
|
||||
}
|
||||
|
||||
@@ -333,13 +337,17 @@ func TestBuildSlackTargetConfig_RejectsReservedURL(t *testing.T) {
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
cfg, errMsg, err := h.BuildSlackTargetConfigForTest(
|
||||
t.Context(), "http://169.254.169.254/latest/meta-data/",
|
||||
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/",
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, errMsg, "Invalid target URL")
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, cfg)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestRenderTemplate(t *testing.T) {
|
||||
@@ -436,22 +444,29 @@ 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.
|
||||
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("")
|
||||
w := httptest.NewRecorder()
|
||||
cfg, err := h.BuildDatabaseTargetConfigForTest(w, "")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, errMsg)
|
||||
assert.Empty(t, cfg)
|
||||
|
||||
// Explicit never is stored as config.
|
||||
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("never")
|
||||
w = httptest.NewRecorder()
|
||||
cfg, err = h.BuildDatabaseTargetConfigForTest(w, "never")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, errMsg)
|
||||
assert.JSONEq(t, `{"expiry":"never"}`, cfg)
|
||||
|
||||
// A positive duration is stored as config.
|
||||
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("720h")
|
||||
w = httptest.NewRecorder()
|
||||
cfg, err = h.BuildDatabaseTargetConfigForTest(w, "720h")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, errMsg)
|
||||
assert.JSONEq(t, `{"expiry":"720h"}`, cfg)
|
||||
}
|
||||
|
||||
@@ -460,14 +475,22 @@ func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
for _, bad := range []string{"nonsense", "7d", "-5h"} {
|
||||
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest(bad)
|
||||
var h *handlers.Handlers
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Contains(
|
||||
t, errMsg, "Invalid archive expiry",
|
||||
"expiry %q should be refused", bad,
|
||||
)
|
||||
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)
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ const (
|
||||
webhookSaved noticeCode = "webhook-saved"
|
||||
webhookDeleted noticeCode = "webhook-deleted"
|
||||
entrypointAdded noticeCode = "entrypoint-added"
|
||||
entrypointSaved noticeCode = "entrypoint-saved"
|
||||
entrypointDeleted noticeCode = "entrypoint-deleted"
|
||||
entrypointActivated noticeCode = "entrypoint-activated"
|
||||
entrypointDeactivated noticeCode = "entrypoint-deactivated"
|
||||
@@ -49,7 +48,6 @@ func noticeFor(r *http.Request) *notice {
|
||||
webhookSaved: {Text: "Webhook saved."},
|
||||
webhookDeleted: {Text: "Webhook deleted."},
|
||||
entrypointAdded: {Text: "Entrypoint added."},
|
||||
entrypointSaved: {Text: "Entrypoint description saved."},
|
||||
entrypointDeleted: {Text: "Entrypoint deleted."},
|
||||
entrypointActivated: {Text: "Entrypoint activated."},
|
||||
entrypointDeactivated: {Text: "Entrypoint deactivated."},
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
"sneak.berlin/go/webhooker/templates"
|
||||
)
|
||||
|
||||
// TestEveryPageRendersItsOwnTitle renders each page template and checks
|
||||
// the browser tab title is the one the page declares, not the
|
||||
// "Webhooker" fallback in htmlheader.html. A page that fails to render
|
||||
// shows the error page's title instead, and fails here too.
|
||||
func TestEveryPageRendersItsOwnTitle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var h *handlers.Handlers
|
||||
|
||||
var sess *session.Session
|
||||
|
||||
app := newTestApp(t, &h, &sess)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
// A pointer, as in the handlers: some pages call
|
||||
// Webhook.RetentionLabel, a pointer method.
|
||||
webhook := &database.Webhook{Name: "orders", RetentionDays: 14}
|
||||
webhook.ID = testWebhookID
|
||||
|
||||
pages := []struct {
|
||||
page string
|
||||
data map[string]any
|
||||
title string
|
||||
}{
|
||||
{"login.html", map[string]any{}, "Login - Webhooker"},
|
||||
{"profile.html", map[string]any{}, "Profile - Webhooker"},
|
||||
{"settings.html", map[string]any{}, "Settings - Webhooker"},
|
||||
{"sources_list.html", map[string]any{}, "Webhooks - Webhooker"},
|
||||
{"sources_new.html", map[string]any{}, "New Webhook - Webhooker"},
|
||||
{
|
||||
"source_detail.html",
|
||||
map[string]any{dataKeyWebhook: webhook},
|
||||
"orders - Webhooker",
|
||||
},
|
||||
{
|
||||
"source_edit.html",
|
||||
map[string]any{dataKeyWebhook: webhook},
|
||||
"Edit orders - Webhooker",
|
||||
},
|
||||
{
|
||||
"source_logs.html",
|
||||
map[string]any{dataKeyWebhook: webhook, "TotalEvents": int64(0)},
|
||||
"Full Event Log - orders - Webhooker",
|
||||
},
|
||||
{
|
||||
"event_detail.html",
|
||||
map[string]any{dataKeyWebhook: webhook},
|
||||
"Event - orders - Webhooker",
|
||||
},
|
||||
{
|
||||
"target_edit.html",
|
||||
map[string]any{
|
||||
dataKeyWebhook: webhook,
|
||||
"Target": map[string]any{"Name": "alerts", "Type": "slack"},
|
||||
},
|
||||
"Edit alerts - Webhooker",
|
||||
},
|
||||
{
|
||||
"error.html",
|
||||
map[string]any{"StatusText": http.StatusText(http.StatusNotFound)},
|
||||
"Not Found - Webhooker",
|
||||
},
|
||||
}
|
||||
|
||||
for _, p := range pages {
|
||||
body := renderPage(t, h, sess, p.page, p.data)
|
||||
|
||||
_, afterOpen, _ := strings.Cut(body, "<title>")
|
||||
title, _, _ := strings.Cut(afterOpen, "</title>")
|
||||
|
||||
assert.Equal(t, p.title, title, p.page)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTitleFallbackIsWebhooker checks the title htmlheader.html gives a
|
||||
// page that declares none. Every page declares one, so it is checked on
|
||||
// htmlheader.html alone.
|
||||
func TestTitleFallbackIsWebhooker(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
header := template.Must(
|
||||
template.ParseFS(templates.Templates, "htmlheader.html"),
|
||||
)
|
||||
|
||||
var buf strings.Builder
|
||||
|
||||
require.NoError(t, header.ExecuteTemplate(&buf, "htmlheader", nil))
|
||||
assert.Contains(t, buf.String(), "<title>Webhooker</title>")
|
||||
}
|
||||
@@ -12,12 +12,11 @@ import (
|
||||
)
|
||||
|
||||
// recentEventColumns is the recent events list's projection. It
|
||||
// reads the body cut to maxRenderedBodyBytes, as eventLogColumns
|
||||
// does, and its size from body_bytes, recorded when the event was
|
||||
// leaves out the body, for the reason maxRenderedBodyBytes gives,
|
||||
// and reads its size from body_bytes, recorded when the event was
|
||||
// stored.
|
||||
const recentEventColumns = "id, created_at, method, content_type, " +
|
||||
"resubmitted_from_id, body_bytes, " +
|
||||
"substr(cast(body as blob), 1, ?) AS body"
|
||||
"resubmitted_from_id, body_bytes"
|
||||
|
||||
// recentAttemptColumns is the part of a recorded attempt the list
|
||||
// uses. The event log's deliveryResultColumns also reads response
|
||||
@@ -51,9 +50,6 @@ type RecentEventView struct {
|
||||
// unless the webhook has exactly one HTTP target.
|
||||
Status string
|
||||
StatusClass string
|
||||
|
||||
// Body is what the row shows when it is expanded.
|
||||
Body BodyView
|
||||
}
|
||||
|
||||
// recentEventRow is one row of recentEventColumns.
|
||||
@@ -64,7 +60,6 @@ type recentEventRow struct {
|
||||
ContentType string
|
||||
ResubmittedFromID *string
|
||||
BodyBytes uint64
|
||||
Body []byte
|
||||
}
|
||||
|
||||
// recentAttemptRow is one row of recentAttemptColumns. CreatedAt is
|
||||
@@ -105,7 +100,7 @@ func loadRecentEvents(
|
||||
var rows []recentEventRow
|
||||
|
||||
err := webhookDB.Model(&database.Event{}).
|
||||
Select(recentEventColumns, maxRenderedBodyBytes).
|
||||
Select(recentEventColumns).
|
||||
Where("webhook_id = ?", webhookID).
|
||||
Order("created_at DESC").
|
||||
Limit(recentEventLimit).
|
||||
@@ -150,7 +145,7 @@ func loadRecentEvents(
|
||||
views := make([]RecentEventView, len(rows))
|
||||
for i := range rows {
|
||||
views[i] = rows[i].view(
|
||||
webhookID, byEvent[rows[i].ID], attempts, statusTargetID,
|
||||
byEvent[rows[i].ID], attempts, statusTargetID,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -187,20 +182,14 @@ func loadRecentAttempts(
|
||||
return byDelivery, nil
|
||||
}
|
||||
|
||||
// view projects a loaded row of the webhook's events for
|
||||
// rendering. deliveries is the event's deliveries, oldest first,
|
||||
// and attempts their recorded attempts keyed by delivery ID.
|
||||
// view projects a loaded row for rendering. deliveries is the
|
||||
// event's deliveries, oldest first, and attempts their recorded
|
||||
// attempts keyed by delivery ID.
|
||||
func (r *recentEventRow) view(
|
||||
webhookID string,
|
||||
deliveries []database.Delivery,
|
||||
attempts map[string][]recentAttemptRow,
|
||||
statusTargetID string,
|
||||
) RecentEventView {
|
||||
//nolint:gosec // body_bytes is at most the receiver's 1 MB cap
|
||||
body := newBodyView(
|
||||
"/hook/"+webhookID+"/events/"+r.ID, r.Body, int64(r.BodyBytes),
|
||||
)
|
||||
|
||||
v := RecentEventView{
|
||||
Method: r.Method,
|
||||
ContentType: r.ContentType,
|
||||
@@ -208,7 +197,6 @@ func (r *recentEventRow) view(
|
||||
ReceivedUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
|
||||
Size: humanize.Bytes(r.BodyBytes),
|
||||
ProcessingTime: processingTime(deliveries, attempts),
|
||||
Body: body,
|
||||
}
|
||||
|
||||
if r.ResubmittedFromID != nil {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -302,73 +301,6 @@ func TestHandleSourceDetail_NoStatusWithoutSingleHTTPTarget(
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleSourceDetail_RecentEventsLinkAndExpand proves each row
|
||||
// links to its event's own page and expands to show its body, and
|
||||
// that only the newest row starts expanded.
|
||||
func TestHandleSourceDetail_RecentEventsLinkAndExpand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRecentEventsFixture(t)
|
||||
now := time.Now()
|
||||
|
||||
older := f.event(
|
||||
t, contentTypeJSON, `{"which":"older"}`, now.Add(-time.Minute),
|
||||
)
|
||||
newer := f.event(t, contentTypeJSON, `{"which":"newer"}`, now)
|
||||
|
||||
body := f.render(t)
|
||||
|
||||
for _, e := range []*database.Event{older, newer} {
|
||||
assert.Contains(
|
||||
t, body, `href="/hook/`+f.webhook.ID+`/events/`+e.ID+`"`,
|
||||
)
|
||||
}
|
||||
|
||||
assert.Equal(t, 2, strings.Count(body, `<div x-show="open" x-cloak class="mt-3">`))
|
||||
assert.Equal(t, 1, strings.Count(body, " data-open>"))
|
||||
|
||||
open := strings.Index(body, " data-open>")
|
||||
newerBody := strings.Index(body, ""which": "newer"")
|
||||
olderBody := strings.Index(body, ""which": "older"")
|
||||
|
||||
assert.Less(t, open, newerBody, "the newest row is not the open one")
|
||||
assert.Less(t, newerBody, olderBody)
|
||||
}
|
||||
|
||||
// TestHandleSourceDetail_RecentEventBodyCut proves a body up to
|
||||
// the cap is shown whole and pretty-printed, and a larger one only
|
||||
// its first bodyCap bytes, as received, with links to the whole
|
||||
// body on the event's page and to the download.
|
||||
func TestHandleSourceDetail_RecentEventBodyCut(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRecentEventsFixture(t)
|
||||
now := time.Now()
|
||||
|
||||
// A JSON document of n bytes.
|
||||
document := func(n int) string {
|
||||
return `{"pad":"` + strings.Repeat("x", n-len(`{"pad":""}`)) + `"}`
|
||||
}
|
||||
|
||||
whole := f.event(
|
||||
t, contentTypeJSON, document(bodyCap), now.Add(-time.Minute),
|
||||
)
|
||||
cut := f.event(t, contentTypeJSON, document(bodyCap+1), now)
|
||||
|
||||
body := f.render(t)
|
||||
eventURL := `href="/hook/` + f.webhook.ID + `/events/`
|
||||
|
||||
assert.Equal(t, 1, strings.Count(body, "{\n "pad": "))
|
||||
assert.Contains(t, body, "{"pad":"xxx")
|
||||
assert.Contains(
|
||||
t, body,
|
||||
"Showing the first "+strconv.Itoa(bodyCap)+" of "+
|
||||
strconv.Itoa(bodyCap+1)+" bytes, unformatted.",
|
||||
)
|
||||
assert.Contains(t, body, eventURL+cut.ID+`/body"`)
|
||||
assert.NotContains(t, body, eventURL+whole.ID+`/body"`)
|
||||
}
|
||||
|
||||
// TestHandleWebhook_RecordsBodySize proves the receiver records the
|
||||
// body's size in bytes, not characters, with the event it stores.
|
||||
func TestHandleWebhook_RecordsBodySize(t *testing.T) {
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"html"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// submitCreateForm posts the new webhook form and returns the
|
||||
// recorder.
|
||||
func submitCreateForm(
|
||||
env *sourceTestEnv, form url.Values,
|
||||
) *httptest.ResponseRecorder {
|
||||
req := formRequest("/hooks/new", env.cookies, form, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
env.handlers.HandleSourceCreateSubmit().ServeHTTP(w, req)
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
// assertNothingCreated checks that the main database holds no webhook,
|
||||
// entrypoint or target.
|
||||
func assertNothingCreated(t *testing.T, db *database.Database) {
|
||||
t.Helper()
|
||||
|
||||
for _, model := range []any{
|
||||
&database.Webhook{}, &database.Entrypoint{}, &database.Target{},
|
||||
} {
|
||||
var count int64
|
||||
|
||||
require.NoError(t, db.DB().Model(model).Count(&count).Error)
|
||||
assert.Zerof(t, count, "%T rows were created", model)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleSourceCreateSubmit_CreatesRequestedTargets submits the new
|
||||
// webhook form with the HTTP target URL filled in or empty, and with
|
||||
// the archive checkbox off or on with each pruning choice. The webhook
|
||||
// gets an HTTP target only for a URL and a database target only for a
|
||||
// checked archive. The pruning choice is always submitted, as the
|
||||
// browser submits it while it is hidden, and is ignored when archive
|
||||
// is off.
|
||||
func TestHandleSourceCreateSubmit_CreatesRequestedTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
|
||||
// Each value the archive pruning choice submits, after an empty
|
||||
// one that stands for the archive checkbox left off.
|
||||
expiries := []string{
|
||||
"", "never", "1h", "12h", "24h", "720h", "2160h", "8760h",
|
||||
}
|
||||
|
||||
for _, httpURL := range []string{"", editOriginalURL} {
|
||||
for _, expiry := range expiries {
|
||||
name := "url=" + httpURL + " archive=" + expiry
|
||||
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("name", name)
|
||||
form.Set("http_url", httpURL)
|
||||
form.Set("archive_expiry", "720h")
|
||||
|
||||
if expiry != "" {
|
||||
form.Set("archive", "on")
|
||||
form.Set("archive_expiry", expiry)
|
||||
}
|
||||
|
||||
w := submitCreateForm(env, form)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||
|
||||
var webhook database.Webhook
|
||||
|
||||
require.NoError(t, env.db.DB().
|
||||
Where("name = ?", name).First(&webhook).Error)
|
||||
|
||||
byType := map[database.TargetType]database.Target{}
|
||||
for _, target := range targetsForWebhook(t, env.db, webhook.ID) {
|
||||
byType[target.Type] = target
|
||||
}
|
||||
|
||||
wantCount := 0
|
||||
|
||||
if httpURL != "" {
|
||||
wantCount++
|
||||
|
||||
assert.Equal(t, "HTTP", byType[database.TargetTypeHTTP].Name)
|
||||
assert.JSONEq(t, `{"url":"`+httpURL+`"}`,
|
||||
byType[database.TargetTypeHTTP].Config)
|
||||
}
|
||||
|
||||
if expiry != "" {
|
||||
wantCount++
|
||||
|
||||
assert.Equal(t, "Archive",
|
||||
byType[database.TargetTypeDatabase].Name)
|
||||
assert.JSONEq(t, `{"expiry":"`+expiry+`"}`,
|
||||
byType[database.TargetTypeDatabase].Config)
|
||||
}
|
||||
|
||||
assert.Len(t, byType, wantCount)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleSourceCreateSubmit_RefusedFormKeepsEveryValue refuses the
|
||||
// new webhook form for an invalid HTTP target URL and for an invalid
|
||||
// retention, each with archive on. Nothing is created, and the form
|
||||
// comes back with the reason and every value entered: name,
|
||||
// description, retention, URL, the checked archive box and the pruning
|
||||
// choice.
|
||||
func TestHandleSourceCreateSubmit_RefusedFormKeepsEveryValue(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
const badURL = "Invalid target URL"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
retention string
|
||||
httpURL string
|
||||
reason string
|
||||
}{
|
||||
{"blocked url", "7", editBlockedURL, badURL},
|
||||
{"unsupported scheme", "7", "ftp://93.184.216.34/hook", badURL},
|
||||
{"bad retention", "-5", editOriginalURL, "Retention must be"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("name", "kept name")
|
||||
form.Set("description", "kept description")
|
||||
form.Set("retention_days", tc.retention)
|
||||
form.Set("http_url", tc.httpURL)
|
||||
form.Set("archive", "on")
|
||||
form.Set("archive_expiry", "2160h")
|
||||
|
||||
w := submitCreateForm(env, form)
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
page := w.Body.String()
|
||||
assert.Contains(t, page, tc.reason)
|
||||
assert.Contains(t, page, `value="kept name"`)
|
||||
assert.Contains(t, page, `>kept description</textarea>`)
|
||||
assert.Contains(t, page, `value="`+tc.retention+`"`)
|
||||
assert.Contains(t, page,
|
||||
`value="`+html.EscapeString(tc.httpURL)+`"`)
|
||||
assert.Contains(t, page, `name="archive" value="on" checked`)
|
||||
assert.Contains(t, page, `x-data="collapsible" data-open`)
|
||||
assert.Contains(t, page, `<option value="2160h" selected>`)
|
||||
|
||||
assertNothingCreated(t, env.db)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// errInjectedTargetCreate is the failure a test makes the insert of a
|
||||
// target report.
|
||||
var errInjectedTargetCreate = errors.New("injected target create failure")
|
||||
|
||||
// TestHandleSourceCreateSubmit_FailedTargetInsertCreatesNothing makes
|
||||
// inserting a target fail after the webhook and its entrypoint were
|
||||
// inserted, and checks that neither is left behind.
|
||||
func TestHandleSourceCreateSubmit_FailedTargetInsertCreatesNothing(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
|
||||
require.NoError(t, env.db.DB().Callback().Create().
|
||||
Before("gorm:create").
|
||||
Register("test:fail_target_create", func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == "targets" {
|
||||
_ = tx.AddError(errInjectedTargetCreate)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("name", "rolled back")
|
||||
form.Set("archive", "on")
|
||||
form.Set("archive_expiry", "never")
|
||||
|
||||
w := submitCreateForm(env, form)
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
|
||||
assertNothingCreated(t, env.db)
|
||||
}
|
||||
@@ -86,13 +86,12 @@ var errInjectedDelete = errors.New("injected delete failure")
|
||||
// save of an existing row.
|
||||
var errInjectedSave = errors.New("injected save failure")
|
||||
|
||||
// seedEntrypoint inserts an active entrypoint for a webhook and
|
||||
// returns it.
|
||||
// seedEntrypoint inserts an entrypoint for a webhook.
|
||||
func seedEntrypoint(
|
||||
t *testing.T,
|
||||
db *database.Database,
|
||||
webhookID string,
|
||||
) *database.Entrypoint {
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
ep := &database.Entrypoint{
|
||||
@@ -105,8 +104,6 @@ func seedEntrypoint(
|
||||
t,
|
||||
db.DB().Omit(clause.Associations).Create(ep).Error,
|
||||
)
|
||||
|
||||
return ep
|
||||
}
|
||||
|
||||
// countRows counts the live (not soft-deleted) rows of a model
|
||||
|
||||
@@ -234,7 +234,7 @@ func TestHandleSourceDetail_RendersNamedTargetFields(
|
||||
assert.NotContains(t, body, "sekrit")
|
||||
|
||||
assert.Contains(t, body, "Archive Expiry")
|
||||
assert.Contains(t, body, "30 days")
|
||||
assert.Contains(t, body, "720h")
|
||||
|
||||
// An unknown type gets the neutral placeholder, never the
|
||||
// stored blob.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -191,7 +191,6 @@ func storedRetentionDays(
|
||||
type sourceTestEnv struct {
|
||||
handlers *handlers.Handlers
|
||||
db *database.Database
|
||||
dbMgr *database.WebhookDBManager
|
||||
archives *recordingArchives
|
||||
cookies []*http.Cookie
|
||||
}
|
||||
@@ -205,11 +204,9 @@ func setupSourceTest(t *testing.T) *sourceTestEnv {
|
||||
|
||||
var db *database.Database
|
||||
|
||||
var dbMgr *database.WebhookDBManager
|
||||
|
||||
var archives *recordingArchives
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db, &dbMgr, &archives)
|
||||
app := newTestApp(t, &h, &sess, &db, &archives)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
@@ -217,7 +214,6 @@ func setupSourceTest(t *testing.T) *sourceTestEnv {
|
||||
return &sourceTestEnv{
|
||||
handlers: h,
|
||||
db: db,
|
||||
dbMgr: dbMgr,
|
||||
archives: archives,
|
||||
cookies: authenticatedCookies(
|
||||
t, sess, sourceTestUserID, "sourceuser",
|
||||
@@ -509,51 +505,6 @@ func TestHandleSourceEditSubmit_InvalidRetentionIsRejected(
|
||||
)
|
||||
}
|
||||
|
||||
// TestHandleSourceEditSubmit_RefusedFormComesBack refuses an edit for
|
||||
// each reason the form can give and checks that the form comes back
|
||||
// with the reason and the name, description and retention submitted,
|
||||
// that the page still reports the stored retention, and that nothing
|
||||
// is saved.
|
||||
func TestHandleSourceEditSubmit_RefusedFormComesBack(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
|
||||
refused := func(name, retention, reason string) {
|
||||
t.Helper()
|
||||
|
||||
wh := seedWebhookWithRetention(t, env.db, 30)
|
||||
|
||||
submitted := wh
|
||||
submitted.Name = name
|
||||
submitted.Description = "a description worth keeping"
|
||||
|
||||
w := submitEdit(t, env, submitted, retention)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
page := w.Body.String()
|
||||
assert.Contains(t, page, `class="alert-error">`+reason)
|
||||
assert.Contains(t, page, `name="name" value="`+name+`"`)
|
||||
assert.Contains(t, page, ">a description worth keeping</textarea>")
|
||||
assert.Contains(
|
||||
t, page, `name="retention_days" value="`+retention+`"`,
|
||||
)
|
||||
assert.Contains(t, page, "Currently 30 days.")
|
||||
|
||||
var stored database.Webhook
|
||||
|
||||
require.NoError(
|
||||
t, env.db.DB().First(&stored, "id = ?", wh.ID).Error,
|
||||
)
|
||||
assert.Equal(t, wh.Name, stored.Name)
|
||||
assert.Empty(t, stored.Description)
|
||||
assert.Equal(t, 30, stored.RetentionDays)
|
||||
}
|
||||
|
||||
refused("", "45", "Name is required")
|
||||
refused("kept-name", "nonsense", "Retention must be")
|
||||
}
|
||||
|
||||
func TestHandleSourceEditSubmit_EmptyRetentionLeavesValueUnchanged(
|
||||
t *testing.T,
|
||||
) {
|
||||
@@ -854,10 +805,6 @@ func TestHandleSourceEditSubmit_ArchiveNameTaken(t *testing.T) {
|
||||
w := submitEdit(t, env, wh, "")
|
||||
require.Equal(t, http.StatusConflict, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "archive-taken.db")
|
||||
assert.Contains(
|
||||
t, w.Body.String(), `name="name" value="`+renamedWebhookName+`"`,
|
||||
"the form comes back with the name submitted",
|
||||
)
|
||||
|
||||
var stored database.Webhook
|
||||
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,379 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package handlers
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
@@ -14,13 +13,10 @@ import (
|
||||
const targetEditTemplate = "target_edit.html"
|
||||
|
||||
// tmplKeyTarget is the template data key for the target being
|
||||
// edited, tmplKeyTargetForm for the values its form shows, and
|
||||
// tmplKeyMaxTimeout for the timeout ceiling the form tells the user
|
||||
// about. The add target form on the webhook page takes its values
|
||||
// under the same key as the edit form.
|
||||
// edited, and tmplKeyMaxTimeout for the timeout ceiling the form
|
||||
// tells the user about.
|
||||
const (
|
||||
tmplKeyTarget = "Target"
|
||||
tmplKeyTargetForm = "TargetForm"
|
||||
tmplKeyMaxTimeout = "MaxTimeout"
|
||||
)
|
||||
|
||||
@@ -32,19 +28,20 @@ const configUnreadableMessage = "The stored configuration for this " +
|
||||
"target could not be read. Enter the values below; saving " +
|
||||
"replaces the stored configuration."
|
||||
|
||||
// targetEditView is the display model for the target edit page: the
|
||||
// target's row fields as stored. The values the form shows, the
|
||||
// UNMASKED configuration among them, come separately, as a
|
||||
// targetFormInput.
|
||||
// targetEditView is the display model for the target edit page.
|
||||
//
|
||||
// It deliberately omits database.Target's raw Config blob: the form
|
||||
// renders named fields, and giving the template the blob as well
|
||||
// would put an unreviewed second path to the credential on the page.
|
||||
// It carries the target's row fields alongside its UNMASKED
|
||||
// configuration, and deliberately omits database.Target's raw
|
||||
// Config blob: the form renders named fields, and giving the
|
||||
// template the blob as well would put an unreviewed second path to
|
||||
// the credential on the page.
|
||||
type targetEditView struct {
|
||||
ID string
|
||||
Name string
|
||||
Type database.TargetType
|
||||
Active bool
|
||||
ID string
|
||||
Name string
|
||||
Type database.TargetType
|
||||
Active bool
|
||||
MaxRetries int
|
||||
Config delivery.TargetConfigForm
|
||||
}
|
||||
|
||||
// HandleTargetEdit shows the form to edit a target.
|
||||
@@ -76,18 +73,7 @@ func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
|
||||
msg = configUnreadableMessage
|
||||
}
|
||||
|
||||
form := targetFormInput{
|
||||
Name: target.Name,
|
||||
URL: cfg.URL,
|
||||
Headers: cfg.Headers,
|
||||
Timeout: cfg.Timeout,
|
||||
MaxRetries: strconv.Itoa(target.MaxRetries),
|
||||
Expiry: cfg.Expiry,
|
||||
}
|
||||
|
||||
h.renderTargetEdit(
|
||||
w, r, webhook, target, form, msg, http.StatusOK,
|
||||
)
|
||||
h.renderTargetEdit(w, r, webhook, target, cfg, msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,12 +101,11 @@ func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// applyTargetEdit validates and saves target edits. A refused save
|
||||
// shows the edit form again with the values submitted and the reason.
|
||||
// applyTargetEdit validates and saves target edits.
|
||||
//
|
||||
// The submission goes through setTargetFromForm, as a new target
|
||||
// does, so an edited destination is SSRF-validated exactly as a new
|
||||
// one is.
|
||||
// The submitted configuration goes through buildTargetConfig, the
|
||||
// same builder the create path uses, so an edited destination is
|
||||
// SSRF-validated exactly as a new one is.
|
||||
//
|
||||
// The target's type is not editable. Each type stores a different
|
||||
// configuration shape and its delivery history is recorded against
|
||||
@@ -133,40 +118,57 @@ func (h *Handlers) applyTargetEdit(
|
||||
webhook database.Webhook,
|
||||
target *database.Target,
|
||||
) {
|
||||
in := targetFormInputFrom(r)
|
||||
|
||||
// edited is the target as the submission leaves it; target stays
|
||||
// as stored, for the page shown again when the save is refused.
|
||||
edited := *target
|
||||
|
||||
errMsg, err := h.setTargetFromForm(r.Context(), &edited, in)
|
||||
if err != nil {
|
||||
h.serverError(w, r, "failed to encode target config", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if errMsg != "" {
|
||||
h.renderTargetEdit(
|
||||
w, r, webhook, target, in, errMsg, http.StatusBadRequest,
|
||||
name := r.PostFormValue("name")
|
||||
if name == "" {
|
||||
http.Error(
|
||||
w, "Name is required", http.StatusBadRequest,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
configJSON, err := h.buildTargetConfig(
|
||||
w, r, target.Type, targetFormInputFrom(r),
|
||||
)
|
||||
if err != nil {
|
||||
// buildTargetConfig has already written the response.
|
||||
return
|
||||
}
|
||||
|
||||
// Retries are offered only by the forms for target types that
|
||||
// retry, so an absent field means "this form does not edit
|
||||
// retries" rather than "set them to zero". Reading it
|
||||
// unconditionally would silently disable retries on any target
|
||||
// saved from a form that does not render the input.
|
||||
//
|
||||
// A field that IS submitted but does not parse is a 400, through
|
||||
// the same validator the create path uses. It is rejected before
|
||||
// anything is written, so a typo cannot destroy the retry count
|
||||
// the target is already delivering with.
|
||||
if r.PostForm.Has("max_retries") {
|
||||
retries, ok := targetMaxRetries(w, r, target.MaxRetries)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
target.MaxRetries = retries
|
||||
}
|
||||
|
||||
oldName := target.Name
|
||||
target.Name = name
|
||||
target.Config = configJSON
|
||||
|
||||
// A new name renames the archive file before it is saved (see
|
||||
// delivery.Engine.Rename). If either step fails, it goes back to
|
||||
// the name that is still stored.
|
||||
err = h.renameTargetArchive(
|
||||
target, webhook.Name, target.Name, edited.Name,
|
||||
)
|
||||
err = h.renameTargetArchive(target, webhook.Name, oldName, name)
|
||||
if err == nil {
|
||||
err = h.db.DB().Save(&edited).Error
|
||||
err = h.db.DB().Save(target).Error
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
restoreErr := h.renameTargetArchive(
|
||||
target, webhook.Name, edited.Name, target.Name,
|
||||
target, webhook.Name, name, oldName,
|
||||
)
|
||||
if restoreErr != nil {
|
||||
h.log.Error(
|
||||
@@ -177,8 +179,8 @@ func (h *Handlers) applyTargetEdit(
|
||||
}
|
||||
|
||||
if errors.Is(err, delivery.ErrArchiveNameTaken) {
|
||||
h.renderTargetEdit(
|
||||
w, r, webhook, target, in,
|
||||
http.Error(
|
||||
w,
|
||||
"Not saved: "+err.Error()+
|
||||
". Move that archive out of the data directory, "+
|
||||
"its .db together with any -wal and -shm beside "+
|
||||
@@ -215,17 +217,15 @@ func (h *Handlers) renameTargetArchive(
|
||||
return h.archives.Rename(target.ID, webhookName, newName)
|
||||
}
|
||||
|
||||
// renderTargetEdit renders the target edit page for the target as
|
||||
// stored, its form showing form's values, with an optional error
|
||||
// message above it.
|
||||
// renderTargetEdit renders the target edit page with an optional
|
||||
// error message.
|
||||
func (h *Handlers) renderTargetEdit(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
webhook database.Webhook,
|
||||
target *database.Target,
|
||||
form targetFormInput,
|
||||
cfg delivery.TargetConfigForm,
|
||||
errMsg string,
|
||||
status int,
|
||||
) {
|
||||
// The template calls Webhook methods, which take pointer
|
||||
// receivers; html/template cannot address a value stored in a
|
||||
@@ -233,18 +233,18 @@ func (h *Handlers) renderTargetEdit(
|
||||
data := map[string]any{
|
||||
tmplKeyWebhook: &webhook,
|
||||
tmplKeyTarget: targetEditView{
|
||||
ID: target.ID,
|
||||
Name: target.Name,
|
||||
Type: target.Type,
|
||||
Active: target.Active,
|
||||
ID: target.ID,
|
||||
Name: target.Name,
|
||||
Type: target.Type,
|
||||
Active: target.Active,
|
||||
MaxRetries: target.MaxRetries,
|
||||
Config: cfg,
|
||||
},
|
||||
tmplKeyTargetForm: form,
|
||||
tmplKeyMaxTimeout: delivery.MaxTargetTimeoutSeconds,
|
||||
tmplKeyError: errMsg,
|
||||
tmplKeyArchiveExpiryChoices: archiveExpiryOptions(form.Expiry),
|
||||
tmplKeyMaxTimeout: delivery.MaxTargetTimeoutSeconds,
|
||||
tmplKeyError: errMsg,
|
||||
}
|
||||
|
||||
h.renderTemplateStatus(w, r, targetEditTemplate, data, status)
|
||||
h.renderTemplate(w, r, targetEditTemplate, data)
|
||||
}
|
||||
|
||||
// ownedTarget resolves the request's sourceID and targetID
|
||||
|
||||
@@ -37,18 +37,14 @@ const (
|
||||
editAuthHeader = "Authorization: Bearer " + editBearerSecret
|
||||
)
|
||||
|
||||
// targetRouter mounts the target create, edit and download routes on
|
||||
// a chi router so the handlers see the URL parameters they read.
|
||||
// targetRouter mounts the target create and edit 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(),
|
||||
@@ -565,77 +561,6 @@ func assertEditRejectsTimeout(
|
||||
)
|
||||
}
|
||||
|
||||
// TestHandleTargetEditSubmit_RefusedFormComesBack refuses an edit of
|
||||
// a target of each type and checks that the edit form comes back with
|
||||
// the reason and every value submitted, and that nothing is saved.
|
||||
func TestHandleTargetEditSubmit_RefusedFormComesBack(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
|
||||
// fields is what the operator submitted, as a query string.
|
||||
cases := []struct {
|
||||
targetType database.TargetType
|
||||
fields string
|
||||
reason string
|
||||
}{
|
||||
{
|
||||
database.TargetTypeHTTP,
|
||||
"name=edited&url=" + editBlockedURL +
|
||||
"&headers=X-Edited:+kept&timeout=12&max_retries=3",
|
||||
"Invalid target URL",
|
||||
},
|
||||
{
|
||||
database.TargetTypeSlack,
|
||||
"name=edited&url=" + editOriginalURL + "&max_retries=25",
|
||||
"Invalid max retries",
|
||||
},
|
||||
{
|
||||
database.TargetTypeDatabase, "name=edited&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)
|
||||
target := seedTarget(t, env.db, webhook.ID, tc.targetType)
|
||||
|
||||
form, err := url.ParseQuery(tc.fields)
|
||||
require.NoError(t, err)
|
||||
|
||||
w := submitTargetEdit(env, webhook.ID, target.ID, form)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
page := w.Body.String()
|
||||
assert.Contains(t, page, `class="alert-error">`+tc.reason)
|
||||
|
||||
// headers is the form's one textarea and expiry its one
|
||||
// select; every other field is an input.
|
||||
for field := range form {
|
||||
shown := `name="` + field + `" value="` + form.Get(field) + `"`
|
||||
|
||||
switch field {
|
||||
case "headers":
|
||||
shown = ">" + form.Get(field) + "</textarea>"
|
||||
case "expiry":
|
||||
shown = `<option value="` + form.Get(field) + `" selected>`
|
||||
}
|
||||
|
||||
assert.Contains(t, page, shown)
|
||||
}
|
||||
|
||||
assert.Equal(
|
||||
t, target.Name, storedTarget(t, env, target.ID).Name,
|
||||
"a refused edit must save nothing",
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleTargetEdit_Scoping keeps the edit routes scoped the way
|
||||
// the delete and toggle routes are: ownership is decided by the
|
||||
// webhook, and the target is then scoped to it.
|
||||
@@ -771,10 +696,6 @@ func TestHandleTargetEditSubmit_RenamesArchive(t *testing.T) {
|
||||
w = submitTargetEdit(env, wh.ID, archive.ID, again)
|
||||
require.Equal(t, http.StatusConflict, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "archive-taken.db")
|
||||
assert.Contains(
|
||||
t, w.Body.String(), `name="name" value="Again"`,
|
||||
"the form comes back with the name submitted",
|
||||
)
|
||||
assert.Equal(
|
||||
t, renamedTargetName, storedTarget(t, env, archive.ID).Name,
|
||||
)
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
)
|
||||
|
||||
// TargetRowView is one row of the target list on a webhook's page.
|
||||
type TargetRowView struct {
|
||||
delivery.TargetView
|
||||
|
||||
// Deliveries counts the target's delivered and failed deliveries,
|
||||
// and is nil when the webhook's event database could not be read.
|
||||
Deliveries *TargetDeliveries
|
||||
|
||||
// Archive is a database target's archive file, and nil for a target
|
||||
// of any other type.
|
||||
Archive *ArchiveFileView
|
||||
}
|
||||
|
||||
// TargetDeliveries is how many of a target's deliveries became
|
||||
// delivered and how many failed: in total, which retention does not
|
||||
// reduce, and in the last 24 hours. Deliveries still pending or
|
||||
// retrying count in neither.
|
||||
type TargetDeliveries struct {
|
||||
Delivered int64
|
||||
Failed int64
|
||||
|
||||
DeliveredLast24Hours int64
|
||||
FailedLast24Hours int64
|
||||
}
|
||||
|
||||
// ArchiveFileView is what a database target's row shows about its
|
||||
// archive file.
|
||||
type ArchiveFileView struct {
|
||||
Name string
|
||||
|
||||
// Note stands in for the size and the last write when there are
|
||||
// none to show, and is empty when there are.
|
||||
Note string
|
||||
|
||||
// Size is the size on disk. Written is how long ago the file was
|
||||
// last written, and WrittenUTC the full time the page shows on
|
||||
// hover.
|
||||
Size string
|
||||
Written string
|
||||
WrittenUTC string
|
||||
}
|
||||
|
||||
// targetRows projects a webhook's targets for the target list on its
|
||||
// page.
|
||||
func (h *Handlers) targetRows(
|
||||
webhook *database.Webhook, targets []database.Target,
|
||||
) []TargetRowView {
|
||||
views := delivery.NewTargetViews(targets)
|
||||
rows := make([]TargetRowView, len(views))
|
||||
|
||||
deliveries, err := h.loadTargetDeliveries(webhook.ID)
|
||||
if err != nil {
|
||||
h.log.Error(
|
||||
"failed to read target delivery counts",
|
||||
"webhook_id", webhook.ID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
|
||||
// NewTargetViews returns one view per target, in order.
|
||||
for i := range views {
|
||||
rows[i].TargetView = views[i]
|
||||
|
||||
if err == nil {
|
||||
counts := deliveries[targets[i].ID]
|
||||
rows[i].Deliveries = &counts
|
||||
}
|
||||
|
||||
if targets[i].Type == database.TargetTypeDatabase {
|
||||
rows[i].Archive = h.archiveFileView(webhook, &targets[i])
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
// loadTargetDeliveries reads the delivery counts of a webhook's targets
|
||||
// from its event database, keyed by target. A target with no deliveries
|
||||
// is left out, and so is every target when the event database does not
|
||||
// exist yet, since opening it would create it.
|
||||
func (h *Handlers) loadTargetDeliveries(
|
||||
webhookID string,
|
||||
) (map[string]TargetDeliveries, error) {
|
||||
if !h.dbMgr.DBExists(webhookID) {
|
||||
return map[string]TargetDeliveries{}, nil
|
||||
}
|
||||
|
||||
webhookDB, err := h.dbMgr.GetDB(webhookID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return readTargetDeliveries(webhookDB, time.Now())
|
||||
}
|
||||
|
||||
// readTargetDeliveries counts each target's deliveries that became
|
||||
// delivered and those that failed: in total from the targets' running
|
||||
// totals, and in the 24 hours before now from the deliveries' status
|
||||
// index. Each is one query for all the targets, and neither reads every
|
||||
// stored delivery.
|
||||
func readTargetDeliveries(
|
||||
db *gorm.DB, now time.Time,
|
||||
) (map[string]TargetDeliveries, error) {
|
||||
var totals []database.TargetTotals
|
||||
|
||||
err := db.Find(&totals).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading target totals: %w", err)
|
||||
}
|
||||
|
||||
lastDay, err := finishedByTarget(db, now.Add(-longWindow))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
byTarget := make(map[string]TargetDeliveries, len(totals))
|
||||
|
||||
for _, total := range totals {
|
||||
byTarget[total.TargetID] = TargetDeliveries{
|
||||
Delivered: total.Delivered,
|
||||
Failed: total.Failed,
|
||||
}
|
||||
}
|
||||
|
||||
for _, finished := range lastDay {
|
||||
counts := byTarget[finished.TargetID]
|
||||
counts.DeliveredLast24Hours = finished.Delivered
|
||||
counts.FailedLast24Hours = finished.Failed
|
||||
byTarget[finished.TargetID] = counts
|
||||
}
|
||||
|
||||
return byTarget, nil
|
||||
}
|
||||
|
||||
// archiveFileView describes a database target's archive file from the
|
||||
// file's metadata alone; the archive is never opened. The file is found
|
||||
// by the name the archive writer uses, so it follows a rename of the
|
||||
// webhook or the target.
|
||||
func (h *Handlers) archiveFileView(
|
||||
webhook *database.Webhook, target *database.Target,
|
||||
) *ArchiveFileView {
|
||||
path := delivery.ArchivePath(h.dbMgr, webhook, target)
|
||||
view := &ArchiveFileView{Name: filepath.Base(path)}
|
||||
|
||||
file, err := delivery.StatArchive(path)
|
||||
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
view.Note = "not created yet"
|
||||
case err != nil:
|
||||
h.log.Error(
|
||||
"failed to read archive file metadata",
|
||||
"target_id", target.ID,
|
||||
"error", err,
|
||||
)
|
||||
|
||||
view.Note = "could not be read"
|
||||
default:
|
||||
view.Size = humanize.Bytes(uint64(file.Size)) //nolint:gosec // never negative
|
||||
view.Written = humanize.Time(file.Written)
|
||||
view.WrittenUTC = file.Written.UTC().Format(time.DateTime) + " UTC"
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
// TestHandleSourceDetail_ShowsArchiveFile proves a database target's
|
||||
// row names its archive file and says "not created yet" before the
|
||||
// first write, adds the file's size and last write once it has one row,
|
||||
// and says "not created yet" again once the file has been moved away.
|
||||
// A target of another type shows no archive file.
|
||||
func TestHandleSourceDetail_ShowsArchiveFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
dbMgr *database.WebhookDBManager
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
wh := seedWebhook(t, db)
|
||||
archive := seedTarget(t, db, wh.ID, database.TargetTypeDatabase)
|
||||
seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||
|
||||
path := delivery.ArchivePath(dbMgr, wh, archive)
|
||||
|
||||
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
||||
assert.Equal(t, 1, strings.Count(body, "Archive File:"))
|
||||
assert.Contains(t, body, filepath.Base(path))
|
||||
assert.Contains(t, body, "not created yet")
|
||||
assert.NotContains(t, body, "Archive Size:")
|
||||
|
||||
seedArchive(t, path, 1, 100)
|
||||
|
||||
file, err := os.Stat(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
body = renderSourceDetailPage(t, h, sess, wh.ID)
|
||||
assert.Contains(t, body, filepath.Base(path))
|
||||
assert.NotContains(t, body, "not created yet")
|
||||
assert.Regexp(t,
|
||||
`Archive Size:</span>\s*<span>[1-9][0-9.]* [kM]?B</span>`, body,
|
||||
)
|
||||
assert.Contains(t, body,
|
||||
`title="`+file.ModTime().UTC().Format(time.DateTime)+` UTC"`,
|
||||
)
|
||||
|
||||
require.NoError(t, os.Rename(path, filepath.Join(t.TempDir(), "moved.db")))
|
||||
|
||||
body = renderSourceDetailPage(t, h, sess, wh.ID)
|
||||
assert.Contains(t, body, filepath.Base(path))
|
||||
assert.Contains(t, body, "not created yet")
|
||||
assert.NotContains(t, body, "Archive Size:")
|
||||
}
|
||||
|
||||
// targetList returns the text of the targets section in a rendered
|
||||
// webhook page, from its heading to the next heading, with the markup
|
||||
// taken out and each run of space made one space. Each target's row
|
||||
// then reads as its name, type, state and buttons, followed by the
|
||||
// lines below them.
|
||||
func targetList(t *testing.T, page string) string {
|
||||
t.Helper()
|
||||
|
||||
_, list, found := strings.Cut(page, ">Targets</h2>")
|
||||
require.True(t, found, "the page has no targets section")
|
||||
|
||||
list, _, _ = strings.Cut(list, "<h2")
|
||||
list = regexp.MustCompile(`<[^>]*>`).ReplaceAllString(list, " ")
|
||||
|
||||
return strings.Join(strings.Fields(list), " ")
|
||||
}
|
||||
|
||||
// TestHandleSourceDetail_ShowsTargetDeliveries checks each target row's
|
||||
// delivered and failed deliveries, in total and in the last 24 hours,
|
||||
// for the history seedStatsHistory builds, before and after the real
|
||||
// retention reaper removes the oldest event. The http target has one
|
||||
// delivered, one of them in the last 24 hours, and three failed, one of
|
||||
// them in the last 24 hours and one of them the oldest event's, which
|
||||
// retention removes without changing the total. The active log target
|
||||
// has two failed, both in the last 24 hours, and its pending and
|
||||
// retrying deliveries count in neither. The four inactive log targets
|
||||
// have none.
|
||||
func TestHandleSourceDetail_ShowsTargetDeliveries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
dbMgr *database.WebhookDBManager
|
||||
log *logger.Logger
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
hist := seedStatsHistory(t, h, sess, db, dbMgr)
|
||||
|
||||
const (
|
||||
httpRow = "Delivered: 1 in total, 1 in the last 24 hours " +
|
||||
"Failed: 3 in total, 1 in the last 24 hours"
|
||||
activeLogRow = "t-log log Active Edit Deactivate Delete " +
|
||||
"Delivered: 0 in total, 0 in the last 24 hours " +
|
||||
"Failed: 2 in total, 2 in the last 24 hours"
|
||||
inactiveLogRow = "t-log log Inactive Edit Activate Delete " +
|
||||
"Delivered: 0 in total, 0 in the last 24 hours " +
|
||||
"Failed: 0 in total, 0 in the last 24 hours"
|
||||
)
|
||||
|
||||
list := targetList(t, renderSourceDetailPage(t, h, sess, hist.webhook.ID))
|
||||
assert.Equal(t, 1, strings.Count(list, httpRow))
|
||||
assert.Equal(t, 1, strings.Count(list, activeLogRow))
|
||||
assert.Equal(t, 4, strings.Count(list, inactiveLogRow))
|
||||
|
||||
statsPrune(t, db, dbMgr, log, hist.webhookDB)
|
||||
|
||||
list = targetList(t, renderSourceDetailPage(t, h, sess, hist.webhook.ID))
|
||||
assert.Equal(t, 1, strings.Count(list, httpRow))
|
||||
assert.Equal(t, 1, strings.Count(list, activeLogRow))
|
||||
assert.Equal(t, 4, strings.Count(list, inactiveLogRow))
|
||||
}
|
||||
|
||||
// TestHandleSourceDetail_TargetDeliveriesUnreadable checks that when the
|
||||
// webhook's event database cannot be read, each target's row says so
|
||||
// instead of showing zeros.
|
||||
func TestHandleSourceDetail_TargetDeliveriesUnreadable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
dbMgr *database.WebhookDBManager
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
wh := seedWebhook(t, db)
|
||||
seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||
|
||||
webhookDB, err := dbMgr.GetDB(wh.ID)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t,
|
||||
webhookDB.Migrator().DropTable(&database.TargetTotals{}))
|
||||
|
||||
list := targetList(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
||||
assert.Contains(t, list, "t-log log Active Edit Deactivate Delete "+
|
||||
"The delivery counts could not be read.")
|
||||
assert.NotContains(t, list, "Delivered:")
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"html"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
@@ -44,16 +43,12 @@ func TestTargetRefusal_PrivateDestinationSaysHowToAllowIt(
|
||||
form.Set("type", string(targetType))
|
||||
form.Set("url", editBlockedURL)
|
||||
|
||||
// A refused add shows the webhook page again, and a
|
||||
// refused edit the edit page, where the hint is
|
||||
// HTML-escaped.
|
||||
added := serveTarget(
|
||||
env, http.MethodPost, targetsPath, form,
|
||||
)
|
||||
assert.Equal(t, http.StatusBadRequest, added.Code)
|
||||
assert.Contains(
|
||||
t, added.Body.String(),
|
||||
html.EscapeString(privateRefusalHint),
|
||||
t, added.Body.String(), privateRefusalHint,
|
||||
)
|
||||
|
||||
form.Set("url", editOriginalURL)
|
||||
@@ -76,8 +71,7 @@ func TestTargetRefusal_PrivateDestinationSaysHowToAllowIt(
|
||||
)
|
||||
assert.Equal(t, http.StatusBadRequest, edited.Code)
|
||||
assert.Contains(
|
||||
t, edited.Body.String(),
|
||||
html.EscapeString(privateRefusalHint),
|
||||
t, edited.Body.String(), privateRefusalHint,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -88,3 +89,31 @@ func retriesErrorMessage(err error) string {
|
||||
return errRetriesInvalid.Error() +
|
||||
", or 0 for fire-and-forget"
|
||||
}
|
||||
|
||||
// targetMaxRetries reads and validates max_retries from a target form
|
||||
// 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.
|
||||
func targetMaxRetries(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
fallback int,
|
||||
) (int, bool) {
|
||||
retries, err := parseMaxRetries(
|
||||
r.PostFormValue("max_retries"), fallback,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(
|
||||
w,
|
||||
"Invalid max retries: "+retriesErrorMessage(err),
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return retries, true
|
||||
}
|
||||
|
||||
@@ -196,6 +196,8 @@ func TestCreateFormRetentionCopyMatchesBehaviour(t *testing.T) {
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
body := renderPage(t, h, sess, "sources_new.html", map[string]any{
|
||||
"Name": "",
|
||||
"Description": "",
|
||||
"DefaultRetentionDays": database.DefaultRetentionDays,
|
||||
dataKeyError: "",
|
||||
})
|
||||
@@ -396,21 +398,21 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
|
||||
)
|
||||
|
||||
// A slack target exercises the same max_retries field while needing
|
||||
// only a URL from the edit template, so the test data stays
|
||||
// minimal. The Target and TargetForm keys mirror the field names
|
||||
// the template reads off the handler's values.
|
||||
// only Config.URL from the edit template, so the test data stays
|
||||
// minimal. The Target key mirrors the field names the template reads
|
||||
// off the handler's view value.
|
||||
editBody := renderPage(
|
||||
t, h, sess, "target_edit.html", map[string]any{
|
||||
dataKeyWebhook: webhook,
|
||||
"Target": map[string]any{
|
||||
"ID": "tg-1",
|
||||
"Name": "t",
|
||||
"Type": "slack",
|
||||
"Active": true,
|
||||
},
|
||||
"TargetForm": map[string]any{
|
||||
"URL": "https://hooks.slack.com/services/x",
|
||||
"MaxRetries": "3",
|
||||
"ID": "tg-1",
|
||||
"Name": "t",
|
||||
"Type": "slack",
|
||||
"Active": true,
|
||||
"MaxRetries": 3,
|
||||
"Config": map[string]any{
|
||||
"URL": "https://hooks.slack.com/services/x",
|
||||
},
|
||||
},
|
||||
dataKeyError: "",
|
||||
},
|
||||
|
||||
@@ -326,19 +326,6 @@ func (h *Handlers) createAndFanOut(
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// A resubmitted copy did not arrive on its entrypoint's URL, so it
|
||||
// leaves the entrypoint's last event as it is.
|
||||
if src.ResubmittedFromID == nil {
|
||||
err = database.AddEntrypointTotals(tx, database.EntrypointTotals{
|
||||
EntrypointID: event.EntrypointID, LastEventAt: event.CreatedAt,
|
||||
})
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf(
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
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"])
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,69 +0,0 @@
|
||||
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")
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
@@ -39,14 +37,6 @@ 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.
|
||||
|
||||
@@ -73,7 +73,7 @@ func (s *Server) setupGlobalMiddleware() {
|
||||
}
|
||||
|
||||
s.router.Use(s.mw.CORS())
|
||||
s.router.Use(s.mw.Timeout(requestTimeout))
|
||||
s.router.Use(middleware.Timeout(requestTimeout))
|
||||
|
||||
// Panic recovery, deliberately here rather than first. It has to
|
||||
// run inside every middleware that observes the response, so the
|
||||
@@ -252,12 +252,11 @@ func (s *Server) setupSourceRoutes() {
|
||||
r.Post("/edit", s.h.HandleSourceEditSubmit())
|
||||
r.Post("/delete", s.h.HandleSourceDelete())
|
||||
r.Get("/events", s.h.HandleSourceLogs())
|
||||
r.Get("/events/{eventID}", s.h.HandleEventDetail())
|
||||
// The pages show a body as escaped text and leave a
|
||||
// binary one out, so this is the only route that serves
|
||||
// the stored bytes. It belongs to this group for its
|
||||
// RequireAuth and NoCache; see HandleEventBodyDownload for
|
||||
// the headers that keep the bytes it returns inert.
|
||||
// The log page renders each body only up to its cap, so
|
||||
// this is the only route that serves a whole one. It
|
||||
// belongs to this group for its RequireAuth and
|
||||
// NoCache; see HandleEventBodyDownload for the headers
|
||||
// that keep the bytes it returns inert.
|
||||
r.Get(
|
||||
"/events/{eventID}/body",
|
||||
s.h.HandleEventBodyDownload(),
|
||||
@@ -290,10 +289,6 @@ func (s *Server) setupSourceRoutes() {
|
||||
"/entrypoints",
|
||||
s.h.HandleEntrypointCreate(),
|
||||
)
|
||||
r.Post(
|
||||
"/entrypoints/{entrypointID}/edit",
|
||||
s.h.HandleEntrypointEdit(),
|
||||
)
|
||||
r.Post(
|
||||
"/entrypoints/{entrypointID}/delete",
|
||||
s.h.HandleEntrypointDelete(),
|
||||
@@ -317,10 +312,6 @@ 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(),
|
||||
|
||||
@@ -11,9 +11,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/fx"
|
||||
@@ -366,7 +364,6 @@ func (e *testEnv) seedEvent(
|
||||
WebhookID: webhookID,
|
||||
Method: http.MethodPost,
|
||||
Body: body,
|
||||
BodyBytes: int64(len(body)),
|
||||
ContentType: "application/octet-stream",
|
||||
}
|
||||
|
||||
@@ -401,47 +398,8 @@ func (e *testEnv) seedTarget(
|
||||
return tgt
|
||||
}
|
||||
|
||||
// seedEntrypoint creates an active entrypoint for a webhook.
|
||||
func (e *testEnv) seedEntrypoint(
|
||||
t *testing.T,
|
||||
webhookID string,
|
||||
) *database.Entrypoint {
|
||||
t.Helper()
|
||||
|
||||
ep := &database.Entrypoint{
|
||||
WebhookID: webhookID,
|
||||
Path: uuid.New().String(),
|
||||
Description: "Default entrypoint",
|
||||
Active: true,
|
||||
}
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
e.db.DB().Omit(clause.Associations).Create(ep).Error,
|
||||
)
|
||||
|
||||
return ep
|
||||
}
|
||||
|
||||
// storedEntrypoint reloads an entrypoint row.
|
||||
func (e *testEnv) storedEntrypoint(
|
||||
t *testing.T,
|
||||
entrypointID string,
|
||||
) database.Entrypoint {
|
||||
t.Helper()
|
||||
|
||||
var ep database.Entrypoint
|
||||
|
||||
require.NoError(
|
||||
t, e.db.DB().First(&ep, "id = ?", entrypointID).Error,
|
||||
)
|
||||
|
||||
return ep
|
||||
}
|
||||
|
||||
// seedFailedDelivery records a terminally failed delivery of an event
|
||||
// to a target in the webhook's own database, as the delivery engine
|
||||
// leaves one: finished now, and counted in its target's totals.
|
||||
// to a target in the webhook's own database.
|
||||
func (e *testEnv) seedFailedDelivery(
|
||||
t *testing.T,
|
||||
webhookID, eventID, targetID string,
|
||||
@@ -451,21 +409,16 @@ func (e *testEnv) seedFailedDelivery(
|
||||
webhookDB, err := e.dbMgr.GetDB(webhookID)
|
||||
require.NoError(t, err)
|
||||
|
||||
finishedAt := time.Now()
|
||||
dlv := &database.Delivery{
|
||||
EventID: eventID,
|
||||
TargetID: targetID,
|
||||
Status: database.DeliveryStatusFailed,
|
||||
FinishedAt: &finishedAt,
|
||||
EventID: eventID,
|
||||
TargetID: targetID,
|
||||
Status: database.DeliveryStatusFailed,
|
||||
}
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
webhookDB.Omit(clause.Associations).Create(dlv).Error,
|
||||
)
|
||||
require.NoError(t, database.AddTargetTotals(webhookDB,
|
||||
database.TargetTotals{TargetID: targetID, Deliveries: 1, Failed: 1},
|
||||
))
|
||||
|
||||
return dlv
|
||||
}
|
||||
@@ -1134,119 +1087,6 @@ func TestHook_EntrypointActions(t *testing.T) {
|
||||
assert.Zero(t, left, "the delete should remove the entrypoint")
|
||||
}
|
||||
|
||||
// TestHook_EntrypointEdit changes an entrypoint's description with the
|
||||
// edit form on the webhook page, then empties it. The entrypoint keeps
|
||||
// its URL, and with no description it shows as "Entrypoint". Without
|
||||
// the CSRF token, or without a session, the edit is refused.
|
||||
func TestHook_EntrypointEdit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
userID, _ := env.seedUser(t, "epeditor", "somepassword")
|
||||
cookies := env.authCookies(t, userID, "epeditor")
|
||||
wh := env.seedWebhook(t, userID)
|
||||
ep := env.seedEntrypoint(t, wh.ID)
|
||||
page := "/hook/" + wh.ID
|
||||
|
||||
token, cookies := env.csrfFrom(t, page, cookies)
|
||||
action := env.urlFrom(
|
||||
t, page, `action="(/hook/[^/"]+/entrypoints/[^/"]+/edit)"`,
|
||||
cookies,
|
||||
)
|
||||
|
||||
assert.Equal(
|
||||
t, http.StatusForbidden,
|
||||
env.post(action, entrypointEditForm("", "no token"), cookies).Code,
|
||||
"an edit without a CSRF token must be refused",
|
||||
)
|
||||
|
||||
// The request without a session carries a valid CSRF token from
|
||||
// the login page, so only the session check can refuse it.
|
||||
anonToken, anon := env.csrfFrom(t, "/pages/login", nil)
|
||||
refused := env.post(
|
||||
action, entrypointEditForm(anonToken, "no session"), anon,
|
||||
)
|
||||
assert.Equal(t, http.StatusSeeOther, refused.Code)
|
||||
assert.Equal(
|
||||
t, "/pages/login", refused.Header().Get("Location"),
|
||||
"an edit without a session must be refused",
|
||||
)
|
||||
|
||||
assert.Equal(
|
||||
t, ep.Description, env.storedEntrypoint(t, ep.ID).Description,
|
||||
"a refused edit must not change the description",
|
||||
)
|
||||
|
||||
// edit submits the form with description and requires the
|
||||
// redirect back to the webhook page with the notice.
|
||||
edit := func(description string) {
|
||||
t.Helper()
|
||||
|
||||
w := env.post(
|
||||
action, entrypointEditForm(token, description), cookies,
|
||||
)
|
||||
env.requireNotice(t, w, page, "entrypoint-saved",
|
||||
"Entrypoint description saved.", cookies)
|
||||
}
|
||||
|
||||
edit("Billing sender")
|
||||
|
||||
stored := env.storedEntrypoint(t, ep.ID)
|
||||
assert.Equal(t, "Billing sender", stored.Description)
|
||||
assert.Equal(t, ep.Path, stored.Path,
|
||||
"the edit must keep the entrypoint's URL")
|
||||
|
||||
body := env.get(page, cookies).Body.String()
|
||||
assert.Contains(t, body, ">Billing sender</span>")
|
||||
assert.Contains(t, body, "/h/"+ep.Path+"</code>")
|
||||
|
||||
edit("")
|
||||
|
||||
assert.Empty(t, env.storedEntrypoint(t, ep.ID).Description)
|
||||
assert.Contains(t, env.get(page, cookies).Body.String(),
|
||||
">Entrypoint</span>", "no description shows as Entrypoint")
|
||||
}
|
||||
|
||||
// TestHook_EntrypointEdit_OtherUser404s has another logged-in user, with
|
||||
// a CSRF token of their own, try to edit an entrypoint: through the
|
||||
// owner's webhook, and through a webhook of their own. Both are 404s
|
||||
// and the description stays as it was.
|
||||
func TestHook_EntrypointEdit_OtherUser404s(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
ownerID, _ := env.seedUser(t, "epowner", "somepassword")
|
||||
wh := env.seedWebhook(t, ownerID)
|
||||
ep := env.seedEntrypoint(t, wh.ID)
|
||||
|
||||
otherID, _ := env.seedUser(t, "epother", "somepassword")
|
||||
other := env.authCookies(t, otherID, "epother")
|
||||
theirs := env.seedWebhook(t, otherID)
|
||||
token, other := env.csrfFrom(t, "/hook/"+theirs.ID, other)
|
||||
|
||||
for _, webhookID := range []string{wh.ID, theirs.ID} {
|
||||
path := "/hook/" + webhookID + "/entrypoints/" + ep.ID + "/edit"
|
||||
|
||||
w := env.post(path, entrypointEditForm(token, "not theirs"), other)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code, path)
|
||||
}
|
||||
|
||||
assert.Equal(
|
||||
t, ep.Description, env.storedEntrypoint(t, ep.ID).Description,
|
||||
"another user must not change the description",
|
||||
)
|
||||
}
|
||||
|
||||
// entrypointEditForm fills in the webhook page's entrypoint edit form.
|
||||
func entrypointEditForm(token, description string) url.Values {
|
||||
return url.Values{
|
||||
"csrf_token": {token},
|
||||
"description": {description},
|
||||
}
|
||||
}
|
||||
|
||||
// TestHook_TargetActions adds a target with the form on the webhook
|
||||
// page, follows its Edit link to the target edit form and submits
|
||||
// it, then deactivates, activates and deletes it, every URL and token
|
||||
@@ -1424,48 +1264,6 @@ func TestHook_LinksBetweenPages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestEventPage_OpenedFromRecentEvents follows the Open link of a
|
||||
// row in the recent events on the webhook page through the
|
||||
// production router to the event's own page, which shows the body
|
||||
// and links back. Another user gets a 404 at the same URL, and a
|
||||
// logged-out request is sent to log in.
|
||||
func TestEventPage_OpenedFromRecentEvents(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
ownerID, _ := env.seedUser(t, "owner", "somepassword")
|
||||
cookies := env.authCookies(t, ownerID, "owner")
|
||||
wh := env.seedWebhook(t, ownerID)
|
||||
evt := env.seedEvent(t, wh.ID, "OWNERS-PAYLOAD-3e9d")
|
||||
|
||||
page := "/hook/" + wh.ID
|
||||
path := env.urlFrom(t, page, `href="([^"]+)"[^>]*>Open<`, cookies)
|
||||
|
||||
require.Equal(t, page+"/events/"+evt.ID, path)
|
||||
|
||||
w := env.get(path, cookies)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "OWNERS-PAYLOAD-3e9d")
|
||||
assert.Equal(
|
||||
t, page,
|
||||
env.urlFrom(t, path, `href="([^"]+)"[^>]*>← Back to `, cookies),
|
||||
)
|
||||
|
||||
intruderID, _ := env.seedUser(t, "intruder", "somepassword")
|
||||
|
||||
w = env.get(path, env.authCookies(t, intruderID, "intruder"))
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.NotContains(t, w.Body.String(), "OWNERS-PAYLOAD-3e9d")
|
||||
|
||||
anon := env.get(path, nil)
|
||||
assert.Equal(t, http.StatusSeeOther, anon.Code)
|
||||
assert.Equal(
|
||||
t, "/pages/login?next="+url.QueryEscape(path),
|
||||
anon.Header().Get("Location"),
|
||||
)
|
||||
}
|
||||
|
||||
// TestSourceLogs_TruncationLinkDownloadsTheBody walks the whole
|
||||
// feature the way a user does: render the event log page through
|
||||
// the production router, take the download URL out of the markup
|
||||
|
||||
@@ -39,12 +39,6 @@ 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
|
||||
@@ -65,16 +59,6 @@ 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,
|
||||
@@ -277,17 +261,10 @@ 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) {
|
||||
drain := ShutdownTimeout
|
||||
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
drain = DrainBudget(time.Until(deadline))
|
||||
}
|
||||
|
||||
ctxShutdown, shutdownCancel := context.WithTimeout(ctx, drain)
|
||||
ctxShutdown, shutdownCancel := context.WithTimeout(
|
||||
ctx, ShutdownTimeout,
|
||||
)
|
||||
defer shutdownCancel()
|
||||
|
||||
err := s.httpServer.Shutdown(ctxShutdown)
|
||||
|
||||
@@ -1,148 +1,13 @@
|
||||
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
|
||||
|
||||
+2
-2
@@ -60,9 +60,9 @@ main() {
|
||||
if missing go; then pkg_install go golang go go; fi
|
||||
|
||||
# Not installed here: docker is platform-specific and out of scope for a
|
||||
# package-manager bootstrap, but script/lint and script/css need it.
|
||||
# package-manager bootstrap, but script/lint needs it.
|
||||
if missing docker; then
|
||||
echo "bootstrap: docker not found; script/lint and script/css require it" >&2
|
||||
echo "bootstrap: docker not found; script/lint requires it" >&2
|
||||
fi
|
||||
|
||||
go mod download
|
||||
|
||||
+2
-3
@@ -1,8 +1,8 @@
|
||||
#!/bin/sh
|
||||
# script/check: run all checks (test, lint, fmt-check, css-check). Our own
|
||||
# script/check: run all checks (test, lint, fmt-check). Our own
|
||||
# extension to scripts-to-rule-them-all.
|
||||
# Writes only the ignored static/js/alpine.min.js, through script/test.
|
||||
# Generic, apart from css-check.
|
||||
# Generic: usually needs no adaptation.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
@@ -11,7 +11,6 @@ main() {
|
||||
"$SCRIPT_DIR/test"
|
||||
"$SCRIPT_DIR/lint"
|
||||
"$SCRIPT_DIR/fmt-check"
|
||||
"$SCRIPT_DIR/css-check"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/css: regenerate static/css/tailwind.css (writes). tailwindcss is
|
||||
# never installed locally: it runs in docker, at the version and sha256
|
||||
# pinned in the Dockerfile's stylesheet stages, which also say what the
|
||||
# stylesheet is generated from.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
docker build --target css-output --output type=local,dest=static/css .
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/css-check: fail when static/css/tailwind.css differs from what
|
||||
# script/css would generate (read-only). The comparison is the Dockerfile's
|
||||
# css-check stage, which the image build runs too.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
docker build --target css-check --output type=cacheonly .
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,10 +1,7 @@
|
||||
/* Classes are taken only from the files named below. A class written in
|
||||
any other file is not generated: name that file here too. */
|
||||
@import "tailwindcss" source(none);
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Source the templates */
|
||||
@source "../../templates/**/*.html";
|
||||
@source "../js/app.js";
|
||||
/* targetStatus picks a target's status colour class */
|
||||
@source "../../internal/handlers/recent_events.go";
|
||||
|
||||
/* Material Design inspired theme customization */
|
||||
@theme {
|
||||
@@ -56,6 +53,10 @@
|
||||
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-error-500 text-white hover:bg-error-700 active:bg-red-800 focus:ring-red-500 shadow-elevation-1 hover:shadow-elevation-2;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed text-primary-600 hover:bg-primary-50 active:bg-primary-100;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-elevation-1 overflow-hidden;
|
||||
|
||||
File diff suppressed because one or more lines are too long
+21
-86
@@ -70,43 +70,13 @@ document.addEventListener("alpine:init", function () {
|
||||
"use strict";
|
||||
|
||||
// Something a click shows and hides: the mobile menu, an add form,
|
||||
// an entrypoint's edit form, an event in the event log or in the
|
||||
// recent events, a delivery's attempts, the new webhook page's
|
||||
// archive pruning choice. It starts hidden, or shown when its
|
||||
// element has the data-open attribute.
|
||||
// an event in the event log, a delivery's attempts.
|
||||
window.Alpine.data("collapsible", function () {
|
||||
return {
|
||||
open: false,
|
||||
// The timer of the toggle a single click is waiting to make.
|
||||
pendingToggle: null,
|
||||
init() {
|
||||
this.open = this.$root.hasAttribute("data-open");
|
||||
},
|
||||
toggle() {
|
||||
this.open = !this.open;
|
||||
},
|
||||
// Toggles on a click, except one that selects text, such as
|
||||
// selecting an event's ID to copy it. A single click toggles
|
||||
// only after 500 ms, the usual double-click interval, and the
|
||||
// second press of a double- or triple-click cancels that (see
|
||||
// cancelPendingToggle), so nothing moves under the pointer
|
||||
// while it selects text.
|
||||
toggleUnlessSelecting(event) {
|
||||
if (
|
||||
event.detail === 1 &&
|
||||
window.getSelection().toString() === ""
|
||||
) {
|
||||
this.pendingToggle = setTimeout(() => this.toggle(), 500);
|
||||
}
|
||||
},
|
||||
// Runs when the mouse button goes down, so the second press
|
||||
// of a double- or triple-click cancels the toggle its first
|
||||
// click is waiting to make, however long that press lasts.
|
||||
cancelPendingToggle(event) {
|
||||
if (event.detail > 1) {
|
||||
clearTimeout(this.pendingToggle);
|
||||
}
|
||||
},
|
||||
get closed() {
|
||||
return !this.open;
|
||||
},
|
||||
@@ -117,65 +87,24 @@ document.addEventListener("alpine:init", function () {
|
||||
};
|
||||
});
|
||||
|
||||
// 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 add target form. Only the chosen type's fields show, and the
|
||||
// others are disabled so that the form does not submit them.
|
||||
//
|
||||
// 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.
|
||||
// The type is read from the type select when Alpine starts, when the
|
||||
// select changes, and on pageshow. Going back to the page, the
|
||||
// browser restores the type chosen before without a change event,
|
||||
// in some browsers only after Alpine has started, but always before
|
||||
// pageshow.
|
||||
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;
|
||||
this.readType();
|
||||
},
|
||||
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;
|
||||
readType() {
|
||||
this.targetType = this.$root.querySelector(
|
||||
'select[name="type"]'
|
||||
).value;
|
||||
},
|
||||
get isHttp() {
|
||||
return this.targetType === "http";
|
||||
@@ -186,8 +115,14 @@ document.addEventListener("alpine:init", function () {
|
||||
get isDatabase() {
|
||||
return this.targetType === "database";
|
||||
},
|
||||
get isLog() {
|
||||
return this.targetType === "log";
|
||||
get notHttp() {
|
||||
return !this.isHttp;
|
||||
},
|
||||
get notSlack() {
|
||||
return !this.isSlack;
|
||||
},
|
||||
get notDatabase() {
|
||||
return !this.isDatabase;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
+2
-5
@@ -5,10 +5,7 @@ import (
|
||||
"embed"
|
||||
)
|
||||
|
||||
// Static holds the CSS and JavaScript files the web UI's pages load. They
|
||||
// are named one by one so that a missing js/alpine.min.js, which make
|
||||
// assets extracts and git does not track, fails the build instead of
|
||||
// leaving the pages without Alpine.js.
|
||||
// Static holds the embedded CSS and JavaScript files for the web UI.
|
||||
//
|
||||
//go:embed css/tailwind.css css/style.css js/app.js js/alpine.min.js
|
||||
//go:embed css js
|
||||
var Static embed.FS
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
{{define "delivery_attempts"}}
|
||||
<!-- A delivery's recorded attempts, as handlers.DeliveryView holds
|
||||
them: in the event log and on an event's own page. -->
|
||||
{{if .AttemptsOmitted}}
|
||||
<p class="text-xs text-gray-500">{{.AttemptsOmitted}} attempt{{if ne .AttemptsOmitted 1}}s{{end}} omitted between the first and last shown.</p>
|
||||
{{end}}
|
||||
{{range .Results}}
|
||||
<div class="rounded-md bg-white border border-gray-200 p-2">
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs">
|
||||
<span class="text-gray-500">Attempt {{.AttemptNum}}</span>
|
||||
<span class="{{if .Success}}text-green-600{{else}}text-red-600{{end}}">{{if .Success}}success{{else}}failure{{end}}</span>
|
||||
<span class="text-gray-500">Status: {{if .HasStatusCode}}{{.StatusCode}}{{else}}— (no response){{end}}</span>
|
||||
<span class="text-gray-500">Duration: {{.DurationMS}} ms</span>
|
||||
</div>
|
||||
{{if .Error}}
|
||||
<p class="mt-2 text-xs text-red-700 break-all">Error: {{.Error}}</p>
|
||||
{{end}}
|
||||
{{if .ResponseBody}}
|
||||
<pre class="mt-2 text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.ResponseBody}}</pre>
|
||||
{{end}}
|
||||
{{if .ResponseTruncated}}
|
||||
{{if .ResponseSizeKnown}}
|
||||
<p class="mt-1 text-xs text-gray-500">Response truncated for display: showing {{.ResponseShownBytes}} of {{.ResponseBytes}} bytes.</p>
|
||||
{{else}}
|
||||
<p class="mt-1 text-xs text-gray-500">Showing {{.ResponseShownBytes}} of the {{.ResponseBytes}} recorded bytes. The response reached the recording limit, so the remote may have sent more that was never stored.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-xs text-gray-500">No attempts recorded yet.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,15 +0,0 @@
|
||||
{{define "event_body"}}
|
||||
<!-- An event's body, as handlers.BodyView describes it: the same in
|
||||
the recent events on the webhook page, the event log and the
|
||||
event's own page. -->
|
||||
{{if .Binary}}
|
||||
<p class="text-xs text-gray-500">This body is binary ({{.Size}} bytes) and is not shown. <a href="{{.EventURL}}/body" class="btn-small">Download the body</a></p>
|
||||
{{else if .Text}}
|
||||
<pre class="rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all"{{if .Scroll}} style="max-height: 32rem; overflow-y: auto"{{end}}>{{.Text}}</pre>
|
||||
{{if .Cut}}
|
||||
<p class="mt-2 text-xs text-gray-500">Showing the first {{.ShownBytes}} of {{.Size}} bytes, unformatted. <a href="{{.EventURL}}" class="btn-small">Show the whole body</a> <a href="{{.EventURL}}/body" class="btn-small">Download the body</a></p>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="text-xs text-gray-500">No body.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,87 +0,0 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "title"}}Event - {{.Webhook.Name}} - Webhooker{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="max-w-6xl mx-auto px-6 py-8">
|
||||
<div class="mb-6">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/hook/{{.Webhook.ID}}" class="btn-small">← Back to {{.Webhook.Name}}</a>
|
||||
<a href="/hook/{{.Webhook.ID}}/events" class="btn-small">Full Event Log</a>
|
||||
</div>
|
||||
<h1 class="text-2xl font-medium text-gray-900 mt-2">Event</h1>
|
||||
</div>
|
||||
|
||||
{{with .Event}}
|
||||
<div class="card p-4">
|
||||
<dl class="space-y-2 text-sm">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<dt class="w-32 flex-shrink-0 text-gray-500">ID</dt>
|
||||
<dd class="font-mono text-gray-900 break-all">{{.ID}}</dd>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<dt class="w-32 flex-shrink-0 text-gray-500">Received</dt>
|
||||
<dd class="text-gray-900">{{.CreatedAt.UTC.Format "2006-01-02 15:04:05"}} UTC</dd>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<dt class="w-32 flex-shrink-0 text-gray-500">Method</dt>
|
||||
<dd><span class="badge-info">{{.Method}}</span></dd>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<dt class="w-32 flex-shrink-0 text-gray-500">Content type</dt>
|
||||
<dd class="text-gray-900 break-all">{{.ContentType}}</dd>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<dt class="w-32 flex-shrink-0 text-gray-500">Body size</dt>
|
||||
<dd class="text-gray-900">{{.Body.Size}} bytes</dd>
|
||||
</div>
|
||||
{{if .ResubmittedFrom}}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<dt class="w-32 flex-shrink-0 text-gray-500">Resubmitted from</dt>
|
||||
<dd><a href="/hook/{{$.Webhook.ID}}/events/{{.ResubmittedFromID}}" class="btn-small font-mono">{{.ResubmittedFromID}}</a></dd>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .ResubmitCount}}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<dt class="w-32 flex-shrink-0 text-gray-500">Resubmitted</dt>
|
||||
<dd class="text-gray-900">as {{.ResubmitCount}} new event{{if ne .ResubmitCount 1}}s{{end}}</dd>
|
||||
</div>
|
||||
{{end}}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="card mt-6">
|
||||
<div class="p-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-medium text-gray-900">Body</h2>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
{{template "event_body" .Body}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-6">
|
||||
<div class="p-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-medium text-gray-900">Deliveries</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
{{range .Deliveries}}
|
||||
<div class="p-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<span class="text-sm text-gray-700">{{.Target.DisplayName}}</span>
|
||||
<span class="flex flex-wrap items-center gap-3">
|
||||
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{.Status}}</span>
|
||||
<span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 space-y-2">
|
||||
{{template "delivery_attempts" .}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="p-4 text-sm text-gray-500">No deliveries.</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
+65
-190
@@ -54,29 +54,15 @@
|
||||
|
||||
<div class="divide-y divide-gray-100">
|
||||
{{range .Entrypoints}}
|
||||
<div class="p-4" x-data="collapsible">
|
||||
<div class="p-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-1">
|
||||
<span x-show="closed" class="text-sm font-medium text-gray-900">{{if .Description}}{{.Description}}{{else}}Entrypoint{{end}}</span>
|
||||
<!-- Edit shows this form in place of the
|
||||
description and hides until it closes, and
|
||||
Cancel resets what was typed. With
|
||||
autocomplete="off", going back to the page
|
||||
does not put unsaved text back either, so
|
||||
the form always opens on the saved
|
||||
description. -->
|
||||
<form x-show="open" x-cloak method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/edit" class="flex w-full gap-2">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<input type="text" name="description" value="{{.Description}}" autocomplete="off" placeholder="Description (optional)" class="input text-sm flex-1">
|
||||
<button type="submit" class="btn-primary text-sm">Save</button>
|
||||
<button type="reset" @click="toggle" class="btn-secondary text-sm">Cancel</button>
|
||||
</form>
|
||||
<span class="text-sm font-medium text-gray-900">{{if .Description}}{{.Description}}{{else}}Entrypoint{{end}}</span>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{{if .Active}}
|
||||
<span class="badge-success">Active</span>
|
||||
{{else}}
|
||||
<span class="badge-error">Inactive</span>
|
||||
{{end}}
|
||||
<button type="button" x-show="closed" @click="toggle" class="btn-small" title="Edit">Edit</button>
|
||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/toggle" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<button type="submit" class="btn-small" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
|
||||
@@ -97,18 +83,6 @@
|
||||
</div>
|
||||
<!-- The URL above is the entrypoint's credential:
|
||||
anyone holding it can submit events. -->
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
<span class="font-medium text-gray-700">Last Event:</span>
|
||||
{{if .LastEvent}}
|
||||
<span title="{{.LastEventUTC}}">{{.LastEvent}}</span>
|
||||
{{else}}
|
||||
<span>never</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
<span class="font-medium text-gray-700">Events Within Retention:</span>
|
||||
<span>{{.Events}}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="p-4 text-sm text-gray-500">No entrypoints configured.</div>
|
||||
@@ -116,23 +90,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Targets. The data attributes carry a refused add target
|
||||
submission's type, reason and values back to the form. The
|
||||
URL is data-destination, not data-url: html/template treats
|
||||
an attribute named like a URL as a link and would rewrite
|
||||
a refused ftp: or javascript: value. -->
|
||||
<div class="card" x-data="targetForm"
|
||||
data-type="{{.TargetForm.Type}}"
|
||||
data-reason="{{.TargetError}}"
|
||||
data-name="{{.TargetForm.Name}}"
|
||||
data-destination="{{.TargetForm.URL}}"
|
||||
data-headers="{{.TargetForm.Headers}}"
|
||||
data-timeout="{{.TargetForm.Timeout}}"
|
||||
data-max-retries="{{.TargetForm.MaxRetries}}"
|
||||
data-expiry="{{.TargetForm.Expiry}}">
|
||||
<!-- Targets -->
|
||||
<div class="card" x-data="collapsible">
|
||||
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||
<h2 class="text-lg font-medium text-gray-900">Targets</h2>
|
||||
<button type="button" @click="add" x-show="closed" class="btn-small">
|
||||
<button type="button" @click="toggle" class="btn-small">
|
||||
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
@@ -140,92 +102,48 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add target form. Add shows the type choice; Next replaces
|
||||
it with the chosen type's fields. Each type's fields,
|
||||
and the hidden type field submitted with them, exist
|
||||
only while that type is chosen. A refused submission
|
||||
comes back open on its type, with the values entered;
|
||||
Cancel empties the form. The type choice's p-2, narrower
|
||||
than an input's own padding, keeps it, Next and Cancel on
|
||||
one row on a 360px-wide phone. -->
|
||||
<form method="POST" action="/hook/{{.Webhook.ID}}/targets" x-ref="form">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<div x-show="choosing" x-cloak class="p-4 bg-gray-50 border-b border-gray-200 flex flex-wrap gap-2">
|
||||
<select x-ref="type" aria-label="Target type" class="input text-sm p-2 flex-1">
|
||||
<option value="http">HTTP</option>
|
||||
<option value="slack">Slack</option>
|
||||
<option value="database">Database</option>
|
||||
<option value="log">Log</option>
|
||||
</select>
|
||||
<button type="button" @click="next" class="btn-primary text-sm">Next</button>
|
||||
<button type="button" @click="cancel" class="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
<div x-show="filling" x-cloak class="p-4 bg-gray-50 border-b border-gray-200 space-y-3">
|
||||
<div x-show="reason" x-text="reason" class="alert-error"></div>
|
||||
<input type="text" name="name" :value="name" placeholder="Target name" required class="input text-sm">
|
||||
<template x-if="isHttp">
|
||||
<div class="space-y-3">
|
||||
<input type="hidden" name="type" value="http">
|
||||
<input type="url" name="url" :value="url" placeholder="https://example.com/webhook" class="input text-sm">
|
||||
<div>
|
||||
<textarea name="headers" rows="3" :value="headers" placeholder="Authorization: Bearer ..." class="input text-sm"></textarea>
|
||||
<p class="text-xs text-gray-500 mt-1">Optional request headers, one <code>Name: value</code> per line, sent with every delivery.</p>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label>
|
||||
<input type="number" name="timeout" :value="timeout" min="0" max="300" class="input text-sm w-24">
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<label class="text-sm text-gray-700">Max retries:</label>
|
||||
<input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24">
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="isSlack">
|
||||
<div class="space-y-3">
|
||||
<input type="hidden" name="type" value="slack">
|
||||
<div>
|
||||
<input type="url" name="url" :value="url" placeholder="https://hooks.slack.com/services/..." class="input text-sm">
|
||||
<p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Payloads are pretty-printed in code blocks.</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<label class="text-sm text-gray-700">Max retries:</label>
|
||||
<input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24">
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="isDatabase">
|
||||
<div>
|
||||
<input type="hidden" name="type" value="database">
|
||||
<div class="flex gap-2 items-center">
|
||||
<label class="text-sm text-gray-700">Archive expiry:</label>
|
||||
<select name="expiry" :value="expiry" class="input text-sm w-24">
|
||||
{{range .ArchiveExpiryChoices}}
|
||||
<option value="{{.Value}}">{{.Label}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1">Archived events older than this are deleted from the archive; never keeps them all.</p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="isLog">
|
||||
<div>
|
||||
<input type="hidden" name="type" value="log">
|
||||
<p class="text-xs text-gray-500">A log target writes each event to the application log. It has no settings beyond its name.</p>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Add target form -->
|
||||
<div x-show="open" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
||||
<form method="POST" action="/hook/{{.Webhook.ID}}/targets" x-data="targetForm" @pageshow.window="readType" class="space-y-3">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<div class="flex gap-2">
|
||||
<button type="submit" class="btn-primary text-sm">Save</button>
|
||||
<button type="button" @click="cancel" class="btn-secondary text-sm">Cancel</button>
|
||||
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
|
||||
<select name="type" @change="readType" class="input text-sm w-32">
|
||||
<option value="http">HTTP</option>
|
||||
<option value="slack">Slack</option>
|
||||
<option value="database">Database</option>
|
||||
<option value="log">Log</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div x-show="isHttp">
|
||||
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="notHttp" class="input text-sm">
|
||||
</div>
|
||||
<div x-show="isHttp">
|
||||
<textarea name="headers" rows="3" placeholder="Authorization: Bearer ..." :disabled="notHttp" class="input text-sm"></textarea>
|
||||
<p class="text-xs text-gray-500 mt-1">Optional request headers, one <code>Name: value</code> per line, sent with every delivery.</p>
|
||||
</div>
|
||||
<div x-show="isHttp" class="flex gap-2 items-center">
|
||||
<label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label>
|
||||
<input type="number" name="timeout" min="0" max="300" :disabled="notHttp" class="input text-sm w-24">
|
||||
</div>
|
||||
<div x-show="isHttp">
|
||||
<div class="flex gap-2 items-center">
|
||||
<label class="text-sm text-gray-700">Max retries:</label>
|
||||
<input type="number" name="max_retries" value="0" min="0" max="20" class="input text-sm w-24">
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
|
||||
</div>
|
||||
<div x-show="isSlack">
|
||||
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="notSlack" class="input text-sm">
|
||||
<p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Payloads are pretty-printed in code blocks.</p>
|
||||
</div>
|
||||
<div x-show="isDatabase">
|
||||
<input type="text" name="expiry" placeholder="never" :disabled="notDatabase" class="input text-sm">
|
||||
<p class="text-xs text-gray-500 mt-1">Archive expiry: "never" (default) keeps rows forever, or a duration like "720h" prunes older rows.</p>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary text-sm">Add Target</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-gray-100">
|
||||
{{range .Targets}}
|
||||
@@ -239,9 +157,6 @@
|
||||
{{else}}
|
||||
<span class="badge-error">Inactive</span>
|
||||
{{end}}
|
||||
{{if eq .Type "database"}}
|
||||
<a href="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/download" class="btn-small" title="Download the archive as gzipped JSON">Download</a>
|
||||
{{end}}
|
||||
<a href="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/edit" class="btn-small" title="Edit">Edit</a>
|
||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/toggle" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
@@ -261,35 +176,6 @@
|
||||
<span>{{.Value}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{with .Archive}}
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
<span class="font-medium text-gray-700">Archive File:</span>
|
||||
<span class="break-all">{{.Name}}</span>
|
||||
{{with .Note}}<span>({{.}})</span>{{end}}
|
||||
</div>
|
||||
{{if .Size}}
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
<span class="font-medium text-gray-700">Archive Size:</span>
|
||||
<span>{{.Size}}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
<span class="font-medium text-gray-700">Last Written:</span>
|
||||
<span title="{{.WrittenUTC}}">{{.Written}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{with .Deliveries}}
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
<span class="font-medium text-gray-700">Delivered:</span>
|
||||
<span>{{.Delivered}} in total, {{.DeliveredLast24Hours}} in the last 24 hours</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
<span class="font-medium text-gray-700">Failed:</span>
|
||||
<span>{{.Failed}} in total, {{.FailedLast24Hours}} in the last 24 hours</span>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="text-xs text-gray-500 mt-1">The delivery counts could not be read.</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="p-4 text-sm text-gray-500">No targets configured.</div>
|
||||
@@ -305,37 +191,26 @@
|
||||
<a href="/hook/{{.Webhook.ID}}/events" class="btn-small">Full Event Log</a>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
<!-- Each row expands to show its body; only the newest
|
||||
starts expanded. -->
|
||||
{{range $i, $event := .Events}}
|
||||
<div class="p-4" x-data="collapsible"{{if eq $i 0}} data-open{{end}}>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button type="button" class="btn-small flex-1 flex-wrap justify-between gap-3 text-left" @click="toggle">
|
||||
<span class="flex flex-wrap items-center gap-3">
|
||||
<span class="badge-info">{{.Method}}</span>
|
||||
<span class="text-sm text-gray-500 break-all">{{.ContentType}}</span>
|
||||
{{if .ResubmittedFromID}}
|
||||
<span class="text-xs text-gray-500" title="This event is a copy of {{.ResubmittedFromID}}">resubmitted copy</span>
|
||||
{{end}}
|
||||
</span>
|
||||
<span class="flex flex-wrap items-center gap-3 text-xs text-gray-400">
|
||||
<span title="Body size">{{.Size}}</span>
|
||||
{{if .ProcessingTime}}
|
||||
<span title="Processing time: how long the slowest delivery took, from being queued to its last attempt">{{.ProcessingTime}}</span>
|
||||
{{end}}
|
||||
{{if .Status}}
|
||||
<span class="font-medium {{.StatusClass}}" title="HTTP status from the HTTP target">{{.Status}}</span>
|
||||
{{end}}
|
||||
<span title="{{.ReceivedUTC}}">{{.Received}}</span>
|
||||
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<a href="{{.Body.EventURL}}" class="btn-small">Open</a>
|
||||
</div>
|
||||
<div x-show="open" x-cloak class="mt-3">
|
||||
{{template "event_body" .Body}}
|
||||
{{range .Events}}
|
||||
<div class="p-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="badge-info">{{.Method}}</span>
|
||||
<span class="text-sm text-gray-500 break-all">{{.ContentType}}</span>
|
||||
{{if .ResubmittedFromID}}
|
||||
<span class="text-xs text-gray-500" title="This event is a copy of {{.ResubmittedFromID}}">resubmitted copy</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-gray-400">
|
||||
<span title="Body size">{{.Size}}</span>
|
||||
{{if .ProcessingTime}}
|
||||
<span title="Processing time: how long the slowest delivery took, from being queued to its last attempt">{{.ProcessingTime}}</span>
|
||||
{{end}}
|
||||
{{if .Status}}
|
||||
<span class="font-medium {{.StatusClass}}" title="HTTP status from the HTTP target">{{.Status}}</span>
|
||||
{{end}}
|
||||
<span title="{{.ReceivedUTC}}">{{.Received}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
|
||||
@@ -18,17 +18,17 @@
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<div class="form-group">
|
||||
<label for="name" class="label">Name</label>
|
||||
<input type="text" id="name" name="name" value="{{.Name}}" required class="input">
|
||||
<input type="text" id="name" name="name" value="{{.Webhook.Name}}" required class="input">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description" class="label">Description</label>
|
||||
<textarea id="description" name="description" rows="3" class="input">{{.Description}}</textarea>
|
||||
<textarea id="description" name="description" rows="3" class="input">{{.Webhook.Description}}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="retention_days" class="label">Retention (days)</label>
|
||||
<input type="number" id="retention_days" name="retention_days" value="{{.RetentionDays}}" min="0" class="input">
|
||||
<input type="number" id="retention_days" name="retention_days" value="{{.Webhook.RetentionDays}}" min="0" class="input">
|
||||
<p class="text-xs text-gray-500 mt-1">Currently {{.Webhook.RetentionLabel}}.{{if .Webhook.RetainsForever}} No events are deleted while retention is set to forever.{{else}} A periodic cleanup permanently deletes events older than this, along with their delivery records.{{end}} Enter 0 to retain events forever; leave blank to keep the current setting.</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
<div class="divide-y divide-gray-100">
|
||||
{{range .Events}}
|
||||
<div class="p-4" x-data="collapsible">
|
||||
<!-- Not a button element: browsers do not let a button's text be selected, and an event's ID must be. -->
|
||||
<div role="button" tabindex="0" class="btn-small w-full flex flex-wrap justify-between gap-2" :aria-expanded="open" @mousedown="cancelPendingToggle" @click="toggleUnlessSelecting" @keydown.enter.prevent="toggle" @keydown.space.prevent="toggle">
|
||||
<button type="button" class="btn-small w-full flex flex-wrap justify-between gap-2 text-left" @click="toggle">
|
||||
<span class="flex flex-wrap items-center gap-3">
|
||||
<span class="badge-info">{{.Method}}</span>
|
||||
<span class="text-sm font-mono text-gray-700">{{.ID}}</span>
|
||||
@@ -36,17 +35,16 @@
|
||||
</span>
|
||||
{{end}}
|
||||
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</span>
|
||||
<!-- The caret has no text to select, so a click on it toggles at once. -->
|
||||
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="caretClass" @click.stop="toggle" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div x-show="open" x-cloak class="mt-3 p-3 bg-gray-50 rounded-md">
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="text-xs text-gray-500">
|
||||
{{if .ResubmittedFrom}}Resubmitted from event <a href="/hook/{{$.Webhook.ID}}/events/{{.ResubmittedFromID}}" class="btn-small font-mono">{{.ResubmittedFromID}}</a>.{{end}}
|
||||
{{if .ResubmittedFrom}}Resubmitted from event <span class="font-mono">{{.ResubmittedFromID}}</span>.{{end}}
|
||||
{{if .ResubmitCount}}Resubmitted as {{.ResubmitCount}} new event{{if ne .ResubmitCount 1}}s{{end}}.{{end}}
|
||||
</div>
|
||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline">
|
||||
@@ -55,7 +53,10 @@
|
||||
<button type="submit" class="btn-small" title="Submit this event again as a new event, to every currently active target">Resubmit</button>
|
||||
</form>
|
||||
</div>
|
||||
{{template "event_body" .Body}}
|
||||
<pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Body}}</pre>
|
||||
{{if .BodyTruncated}}
|
||||
<p class="mt-2 text-xs text-gray-500">Body truncated for display: showing {{.BodyShownBytes}} of {{.BodyBytes}} bytes. The stored body is unchanged — <a href="/hook/{{$.Webhook.ID}}/events/{{.ID}}/body" class="btn-small">download the full body</a>.</p>
|
||||
{{end}}
|
||||
|
||||
{{if .Deliveries}}
|
||||
<div class="mt-4 border-t border-gray-200 pt-3">
|
||||
@@ -86,7 +87,34 @@
|
||||
</div>
|
||||
|
||||
<div x-show="open" x-cloak class="mt-2 space-y-2">
|
||||
{{template "delivery_attempts" .}}
|
||||
{{if .AttemptsOmitted}}
|
||||
<p class="text-xs text-gray-500">{{.AttemptsOmitted}} attempt{{if ne .AttemptsOmitted 1}}s{{end}} omitted between the first and last shown.</p>
|
||||
{{end}}
|
||||
{{range .Results}}
|
||||
<div class="rounded-md bg-white border border-gray-200 p-2">
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs">
|
||||
<span class="text-gray-500">Attempt {{.AttemptNum}}</span>
|
||||
<span class="{{if .Success}}text-green-600{{else}}text-red-600{{end}}">{{if .Success}}success{{else}}failure{{end}}</span>
|
||||
<span class="text-gray-500">Status: {{if .HasStatusCode}}{{.StatusCode}}{{else}}— (no response){{end}}</span>
|
||||
<span class="text-gray-500">Duration: {{.DurationMS}} ms</span>
|
||||
</div>
|
||||
{{if .Error}}
|
||||
<p class="mt-2 text-xs text-red-700 break-all">Error: {{.Error}}</p>
|
||||
{{end}}
|
||||
{{if .ResponseBody}}
|
||||
<pre class="mt-2 text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.ResponseBody}}</pre>
|
||||
{{end}}
|
||||
{{if .ResponseTruncated}}
|
||||
{{if .ResponseSizeKnown}}
|
||||
<p class="mt-1 text-xs text-gray-500">Response truncated for display: showing {{.ResponseShownBytes}} of {{.ResponseBytes}} bytes.</p>
|
||||
{{else}}
|
||||
<p class="mt-1 text-xs text-gray-500">Showing {{.ResponseShownBytes}} of the {{.ResponseBytes}} recorded bytes. The response reached the recording limit, so the remote may have sent more that was never stored.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-xs text-gray-500">No attempts recorded yet.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -18,46 +18,20 @@
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<div class="form-group">
|
||||
<label for="name" class="label">Name</label>
|
||||
<input type="text" id="name" name="name" value="{{.Form.Name}}" required autofocus placeholder="My Webhook" class="input">
|
||||
<input type="text" id="name" name="name" value="{{.Name}}" required autofocus placeholder="My Webhook" class="input">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description" class="label">Description</label>
|
||||
<textarea id="description" name="description" rows="3" placeholder="Optional description" class="input">{{.Form.Description}}</textarea>
|
||||
<textarea id="description" name="description" rows="3" placeholder="Optional description" class="input">{{.Description}}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="retention_days" class="label">Retention (days)</label>
|
||||
<input type="number" id="retention_days" name="retention_days" value="{{.Form.RetentionDays}}" min="0" class="input">
|
||||
<input type="number" id="retention_days" name="retention_days" value="{{.DefaultRetentionDays}}" min="0" class="input">
|
||||
<p class="text-xs text-gray-500 mt-1">A periodic cleanup permanently deletes events older than this, along with their delivery records. Enter 0 to retain events forever; leave blank to use the default of {{.DefaultRetentionDays}} days.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="http_url" class="label">HTTP target URL</label>
|
||||
<input type="url" id="http_url" name="http_url" value="{{.Form.HTTPURL}}" placeholder="https://example.com/webhook" class="input">
|
||||
<p class="text-xs text-gray-500 mt-1">Optional. When filled in, the webhook is created with an HTTP target that delivers each event to this URL.</p>
|
||||
</div>
|
||||
|
||||
<!-- The checkbox shows the pruning choice while checked. With
|
||||
autocomplete="off", going back to the page does not
|
||||
check the box again with the choice hidden. -->
|
||||
<div class="form-group" x-data="collapsible"{{if .Form.Archive}} data-open{{end}}>
|
||||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700">
|
||||
<input type="checkbox" name="archive" value="on"{{if .Form.Archive}} checked{{end}} autocomplete="off" @change="toggle" class="h-4 w-4">
|
||||
Archive
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">When checked, the webhook is created with a database target that keeps a copy of every event.</p>
|
||||
<div x-show="open" x-cloak class="mt-3">
|
||||
<label for="archive_expiry" class="label">Archive pruning</label>
|
||||
<select id="archive_expiry" name="archive_expiry" class="input">
|
||||
{{range .ArchiveExpiryChoices}}
|
||||
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1">Archived events older than this are deleted from the archive; never keeps them all.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="btn-primary">Create Webhook</button>
|
||||
<a href="/hooks" class="btn-secondary">Cancel</a>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
{{if or (eq .Target.Type "http") (eq .Target.Type "slack")}}
|
||||
<div class="mb-6 rounded-md bg-gray-50 p-4 text-sm text-gray-700">
|
||||
This form shows the target's destination in full, including any credential carried in its URL or headers. It is the only page that does; everywhere else the value is masked.
|
||||
This form shows the target's stored destination in full, including any credential carried in its URL or headers. It is the only page that does; everywhere else the value is masked.
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -26,25 +26,25 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name" class="label">Name</label>
|
||||
<input type="text" id="name" name="name" value="{{.TargetForm.Name}}" required class="input">
|
||||
<input type="text" id="name" name="name" value="{{.Target.Name}}" required class="input">
|
||||
</div>
|
||||
|
||||
{{if eq .Target.Type "http"}}
|
||||
<div class="form-group">
|
||||
<label for="url" class="label">Destination URL</label>
|
||||
<input type="url" id="url" name="url" value="{{.TargetForm.URL}}" required class="input">
|
||||
<input type="url" id="url" name="url" value="{{.Target.Config.URL}}" required class="input">
|
||||
<p class="text-xs text-gray-500 mt-1">Revalidated on save; destinations that resolve to private or link-local addresses are rejected.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="headers" class="label">Headers</label>
|
||||
<textarea id="headers" name="headers" rows="4" class="input" placeholder="Authorization: Bearer ...">{{.TargetForm.Headers}}</textarea>
|
||||
<textarea id="headers" name="headers" rows="4" class="input" placeholder="Authorization: Bearer ...">{{.Target.Config.Headers}}</textarea>
|
||||
<p class="text-xs text-gray-500 mt-1">One <code>Name: value</code> per line, sent with every delivery. Leave blank for none. <code>Host</code>, <code>Content-Length</code>, <code>Transfer-Encoding</code>, <code>Connection</code>, <code>Trailer</code> and <code>User-Agent</code> are set by the delivery engine and are rejected here rather than silently ignored. Headers set here are dropped if a redirect leaves the destination's own origin, so a credential cannot follow one to another host.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="timeout" class="label">Timeout (seconds)</label>
|
||||
<input type="number" id="timeout" name="timeout" value="{{.TargetForm.Timeout}}" min="0" max="{{.MaxTimeout}}" class="input">
|
||||
<input type="number" id="timeout" name="timeout" value="{{.Target.Config.Timeout}}" min="0" max="{{.MaxTimeout}}" class="input">
|
||||
<p class="text-xs text-gray-500 mt-1">Per-request timeout, at most {{.MaxTimeout}} seconds. Leave blank to use the default.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -52,7 +52,7 @@
|
||||
{{if eq .Target.Type "slack"}}
|
||||
<div class="form-group">
|
||||
<label for="url" class="label">Webhook URL</label>
|
||||
<input type="url" id="url" name="url" value="{{.TargetForm.URL}}" required class="input">
|
||||
<input type="url" id="url" name="url" value="{{.Target.Config.URL}}" required class="input">
|
||||
<p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Revalidated on save.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -60,19 +60,15 @@
|
||||
{{if eq .Target.Type "database"}}
|
||||
<div class="form-group">
|
||||
<label for="expiry" class="label">Archive Expiry</label>
|
||||
<select id="expiry" name="expiry" class="input">
|
||||
{{range .ArchiveExpiryChoices}}
|
||||
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1">Archived events older than this are deleted from the archive; never keeps them all.</p>
|
||||
<input type="text" id="expiry" name="expiry" value="{{.Target.Config.Expiry}}" placeholder="never" class="input">
|
||||
<p class="text-xs text-gray-500 mt-1">"never" (the default when blank) keeps archived rows forever, or a Go duration like "720h" prunes older rows.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if or (eq .Target.Type "http") (eq .Target.Type "slack")}}
|
||||
<div class="form-group">
|
||||
<label for="max_retries" class="label">Max retries</label>
|
||||
<input type="number" id="max_retries" name="max_retries" value="{{.TargetForm.MaxRetries}}" min="0" max="20" class="input">
|
||||
<input type="number" id="max_retries" name="max_retries" value="{{.Target.MaxRetries}}" min="0" max="20" class="input">
|
||||
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user