Compare commits
5
Commits
a0b315c912
...
56f963c800
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56f963c800 | ||
|
|
61371d388e | ||
|
|
19a6705c63 | ||
|
|
93911f28f9 | ||
|
|
9305af4f85 |
@@ -28,10 +28,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Fingerprint the build context
|
- name: Fingerprint the build context
|
||||||
# Writes the hash of the commit being checked into the context, which
|
# Writes the hash of the commit being checked into the context, which
|
||||||
# invalidates the `COPY . .` layer of both check stages: a commit
|
# invalidates the `COPY . .` layer of every check stage: a commit
|
||||||
# that was never linted, format-checked, tested and built cannot
|
# that was never linted, format-checked, stylesheet-checked, tested
|
||||||
# report success from cache.
|
# and built cannot report success from cache.
|
||||||
run: git rev-parse HEAD > .ci-fingerprint
|
run: git rev-parse HEAD > .ci-fingerprint
|
||||||
|
|
||||||
- name: Build Docker image (runs make fmt-check, golangci-lint, make test, make build)
|
- name: Build Docker image (runs make fmt-check, golangci-lint, the stylesheet check, make test, make build)
|
||||||
run: script/cibuild
|
run: script/cibuild
|
||||||
|
|||||||
+38
-1
@@ -29,14 +29,51 @@ RUN script/assets
|
|||||||
RUN --network=none golangci-lint config verify --config .golangci.yml
|
RUN --network=none golangci-lint config verify --config .golangci.yml
|
||||||
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
|
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
|
||||||
|
|
||||||
|
# Stylesheet stages. static/css/tailwind.css is generated, by this pinned
|
||||||
|
# tailwindcss, from static/css/input.css and the files its @source lines
|
||||||
|
# name. `make css` (script/css) writes it out from the css-output stage.
|
||||||
|
# The css-check stage fails when the committed file differs from what is
|
||||||
|
# generated; `make check` runs it, and so does the build stage below.
|
||||||
|
#
|
||||||
|
# tailwindcss v4.2.1 standalone CLI, released 2026-02-23: one binary per
|
||||||
|
# architecture, each pinned by its sha256 from the release's sha256sums.txt.
|
||||||
|
# debian:bookworm-slim, 2026-10-02: the binary needs glibc.
|
||||||
|
FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 AS tailwind-amd64
|
||||||
|
ADD --checksum=sha256:39e8d4e24b3c83b0a6e69e100a972fbc75d5fef8dce47b3ddac3cf92dea81fe3 --chmod=755 \
|
||||||
|
https://github.com/tailwindlabs/tailwindcss/releases/download/v4.2.1/tailwindcss-linux-x64 /usr/local/bin/tailwindcss
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 AS tailwind-arm64
|
||||||
|
ADD --checksum=sha256:d87e6486bb3f70b04ef1dcaacc4ee6548a5a15fbf521b31bc24d2c774f68a951 --chmod=755 \
|
||||||
|
https://github.com/tailwindlabs/tailwindcss/releases/download/v4.2.1/tailwindcss-linux-arm64 /usr/local/bin/tailwindcss
|
||||||
|
|
||||||
|
# TARGETARCH, set by docker, is the architecture being built for.
|
||||||
|
FROM tailwind-${TARGETARCH} AS css
|
||||||
|
WORKDIR /src
|
||||||
|
COPY . .
|
||||||
|
RUN tailwindcss -i static/css/input.css -o /out/tailwind.css --minify
|
||||||
|
|
||||||
|
FROM scratch AS css-output
|
||||||
|
COPY --from=css /out/tailwind.css /
|
||||||
|
|
||||||
|
# Both files are split after each "}", one rule per line, so that when they
|
||||||
|
# differ the diff shows the rules that differ.
|
||||||
|
FROM css AS css-check
|
||||||
|
RUN sed 's/}/}\n/g' static/css/tailwind.css > /tmp/committed.css \
|
||||||
|
&& sed 's/}/}\n/g' /out/tailwind.css > /tmp/generated.css \
|
||||||
|
&& diff -U0 /tmp/committed.css /tmp/generated.css || { \
|
||||||
|
echo "static/css/tailwind.css is not what make css generates; run make css" >&2; \
|
||||||
|
exit 1; \
|
||||||
|
}
|
||||||
|
|
||||||
# Build stage
|
# Build stage
|
||||||
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
||||||
# Using Debian-based image because gorm.io/driver/sqlite pulls in
|
# Using Debian-based image because gorm.io/driver/sqlite pulls in
|
||||||
# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl.
|
# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl.
|
||||||
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder
|
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder
|
||||||
|
|
||||||
# Depend on lint stage passing
|
# Depend on the lint and stylesheet check stages passing
|
||||||
COPY --from=lint /src/go.sum /dev/null
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
COPY --from=css-check /out/tailwind.css /dev/null
|
||||||
|
|
||||||
# jq is a runtime dependency of script/ci-mark-superseded, which the test
|
# jq is a runtime dependency of script/ci-mark-superseded, which the test
|
||||||
# suite executes. git is what script/version derives the version with.
|
# suite executes. git is what script/version derives the version with.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: bootstrap setup assets test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css version
|
.PHONY: bootstrap setup assets test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css css-check version
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
.DEFAULT_GOAL := check
|
.DEFAULT_GOAL := check
|
||||||
@@ -74,4 +74,7 @@ hooks:
|
|||||||
@script/install-precommit
|
@script/install-precommit
|
||||||
|
|
||||||
css:
|
css:
|
||||||
tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
|
@script/css
|
||||||
|
|
||||||
|
css-check:
|
||||||
|
@script/css-check
|
||||||
|
|||||||
@@ -19,12 +19,14 @@ before deploying one.
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Go 1.26.1+ (the version in `go.mod`)
|
- Go 1.26.1+ (the version in `go.mod`)
|
||||||
- Docker (for `make lint` and so for `make check`, for the browser test in
|
- Docker (for `make lint` and `make css`, and so for `make check`, for the
|
||||||
`make test-browser`, for the CI gate, and for containerized deployment)
|
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
|
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
|
host: `script/bootstrap` does not install it, and `make lint` runs the
|
||||||
digest-pinned linter image via `Dockerfile.lint`.
|
digest-pinned linter image via `Dockerfile.lint`. The same holds for
|
||||||
|
tailwindcss (see [Stylesheet](#stylesheet)).
|
||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
|
|
||||||
@@ -36,7 +38,7 @@ cd webhooker
|
|||||||
# Install the Go toolchain if missing, and the Go dependencies
|
# Install the Go toolchain if missing, and the Go dependencies
|
||||||
make bootstrap
|
make bootstrap
|
||||||
|
|
||||||
# Run all checks (test, lint, format check)
|
# Run all checks (test, lint, format check, stylesheet check)
|
||||||
make check
|
make check
|
||||||
|
|
||||||
# Run the server from the clone. DATA_DIR defaults to
|
# Run the server from the clone. DATA_DIR defaults to
|
||||||
@@ -59,7 +61,7 @@ make fmt-check # Fail if gofmt would change anything (writes nothing)
|
|||||||
make lint # Run golangci-lint in Docker (Dockerfile.lint)
|
make lint # Run golangci-lint in Docker (Dockerfile.lint)
|
||||||
make test # Run tests with race detection
|
make test # Run tests with race detection
|
||||||
make test-browser # Run the browser test in Docker (Dockerfile.browser)
|
make test-browser # Run the browser test in Docker (Dockerfile.browser)
|
||||||
make check # test + lint + fmt-check (CI gate)
|
make check # test + lint + fmt-check + css-check (CI gate)
|
||||||
make build # Build binary to bin/webhooker (version-stamped)
|
make build # Build binary to bin/webhooker (version-stamped)
|
||||||
make version # Print the version this checkout would stamp
|
make version # Print the version this checkout would stamp
|
||||||
make run # build, then run ./bin/webhooker
|
make run # build, then run ./bin/webhooker
|
||||||
@@ -67,7 +69,8 @@ make dev # go run ./cmd/webhooker
|
|||||||
make deps # go mod download + go mod tidy
|
make deps # go mod download + go mod tidy
|
||||||
make docker # Build Docker image
|
make docker # Build Docker image
|
||||||
make hooks # Install git pre-commit hook that runs script/precommit
|
make hooks # Install git pre-commit hook that runs script/precommit
|
||||||
make css # Regenerate static/css/tailwind.css (needs tailwindcss)
|
make css # Regenerate static/css/tailwind.css (tailwindcss in Docker)
|
||||||
|
make css-check # Fail if static/css/tailwind.css is stale (writes nothing)
|
||||||
make clean # Remove bin/
|
make clean # Remove bin/
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1292,11 +1295,11 @@ What that means for an operator:
|
|||||||
This repository adheres to the
|
This repository adheres to the
|
||||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
standard: normalized scripts in `script/` are the entrypoints for the
|
standard: normalized scripts in `script/` are the entrypoints for the
|
||||||
development workflow. Eleven of the Makefile's eighteen targets are thin
|
development workflow. Thirteen of the Makefile's nineteen targets are thin
|
||||||
shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and
|
shims that call them; `build`, `run`, `dev`, `deps`, `clean` and `version`
|
||||||
`version` are inline commands with no script behind them, though `build`,
|
are inline commands with no script behind them, though `build`, `run` and
|
||||||
`run` and `dev` first run `script/assets`, and `build` and `version` both
|
`dev` first run `script/assets`, and `build` and `version` both take their
|
||||||
take their value from `script/version`.
|
value from `script/version`.
|
||||||
|
|
||||||
`script/test`, `make build` and `make dev` each run `script/assets`
|
`script/test`, `make build` and `make dev` each run `script/assets`
|
||||||
first, which writes the ignored `static/js/alpine.min.js` (see
|
first, which writes the ignored `static/js/alpine.min.js` (see
|
||||||
@@ -1318,7 +1321,11 @@ We provide:
|
|||||||
- `script/lint` — run golangci-lint in Docker (see Linting below)
|
- `script/lint` — run golangci-lint in Docker (see Linting below)
|
||||||
- `script/fmt` — format all code (writes)
|
- `script/fmt` — format all code (writes)
|
||||||
- `script/fmt-check` — check formatting (read-only)
|
- `script/fmt-check` — check formatting (read-only)
|
||||||
- `script/check` — run test, lint, and fmt-check
|
- `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/version` — output the version to stamp into the binary (see
|
- `script/version` — output the version to stamp into the binary (see
|
||||||
[Version stamping](#version-stamping))
|
[Version stamping](#version-stamping))
|
||||||
- `script/docker` — build the Docker image tagged via
|
- `script/docker` — build the Docker image tagged via
|
||||||
@@ -1356,15 +1363,16 @@ 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
|
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
|
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
|
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, and so do a
|
event's own page; an event in the event log expands and collapses when its
|
||||||
delivery's attempts inside it; and at phone width the menu button opens and
|
row's caret or its ID is clicked, and from the keyboard, but not when its ID is
|
||||||
closes the mobile menu. It also fails if the browser reports a console warning
|
selected with the mouse, and a delivery's attempts inside it expand and
|
||||||
or error, an uncaught exception, or anything the policy refused. `make check`
|
collapse; and at phone width the menu button opens and closes the mobile menu.
|
||||||
and the image build lint it but do not run it, and `make test` leaves it out
|
It also fails if the browser reports a console warning or error, an uncaught
|
||||||
(its file is built only with the `browser` build tag). Run it with
|
exception, or anything the policy refused. `make check` and the image build lint
|
||||||
`make test-browser` after changing `templates/` or `static/js/`: that builds
|
it but do not run it, and `make test` leaves it out (its file is built only with
|
||||||
`Dockerfile.browser`, which runs the test in a digest-pinned headless browser
|
the `browser` build tag). Run it with `make test-browser` after changing
|
||||||
image, so the host needs no browser.
|
`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
|
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
|
byte as the npm registry publishes it. It is a dependency, not this repo's build
|
||||||
@@ -1388,6 +1396,23 @@ the `dist.integrity` hash listed at
|
|||||||
`3p/` with it as `alpinejs-csp-<version>.tgz`, update its file name in
|
`3p/` with it as `alpinejs-csp-<version>.tgz`, update its file name in
|
||||||
`script/assets`, and run `make check` and `make test-browser`.
|
`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
|
## Rationale
|
||||||
|
|
||||||
Webhook integrations between services are inherently fragile. The
|
Webhook integrations between services are inherently fragile. The
|
||||||
@@ -1580,6 +1605,13 @@ more entrypoints (receiver URLs) and one or more targets (delivery
|
|||||||
destinations) into a logical unit. A user creates a webhook to set up
|
destinations) into a logical unit. A user creates a webhook to set up
|
||||||
event routing.
|
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 |
|
| Field | Type | Description |
|
||||||
| ---------------- | ------- | ----------- |
|
| ---------------- | ------- | ----------- |
|
||||||
| `id` | UUID | Primary key |
|
| `id` | UUID | Primary key |
|
||||||
@@ -1913,6 +1945,12 @@ 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
|
with the number of webhooks and, for each, with the deliveries that
|
||||||
finished in the last 24 hours, never with the events stored.
|
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
|
#### Event-tier indexes
|
||||||
|
|
||||||
These indexes on the per-webhook event databases are declared in the model
|
These indexes on the per-webhook event databases are declared in the model
|
||||||
@@ -1920,7 +1958,7 @@ tags, so `AutoMigrate` creates them on a fresh database:
|
|||||||
|
|
||||||
| Table | Columns | Serves |
|
| 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 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 target list 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 |
|
| `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 |
|
| `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` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events |
|
||||||
@@ -3134,10 +3172,10 @@ webhooker/
|
|||||||
│ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed
|
│ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed
|
||||||
├── templates/ # Go HTML templates (base, login, sources, etc.)
|
├── templates/ # Go HTML templates (base, login, sources, etc.)
|
||||||
├── script/ # Scripts to Rule Them All entrypoints
|
├── script/ # Scripts to Rule Them All entrypoints
|
||||||
├── Dockerfile # Three stages: lint, test+build, Alpine runtime
|
├── Dockerfile # Stages: lint, stylesheet, test+build, Alpine runtime
|
||||||
├── Dockerfile.lint # Lint-only image built by script/lint
|
├── Dockerfile.lint # Lint-only image built by script/lint
|
||||||
├── Dockerfile.browser # Browser test image built by script/test-browser
|
├── Dockerfile.browser # Browser test image built by script/test-browser
|
||||||
├── Makefile # 11 of 18 targets shim script/; 7 are inline
|
├── Makefile # 13 of 19 targets shim script/; 6 are inline
|
||||||
├── go.mod / go.sum
|
├── go.mod / go.sum
|
||||||
└── .golangci.yml # Linter configuration
|
└── .golangci.yml # Linter configuration
|
||||||
```
|
```
|
||||||
@@ -3451,18 +3489,26 @@ Three properties are load-bearing:
|
|||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
The Dockerfile uses a three-stage build. Each stage is pinned by
|
The Dockerfile uses a multi-stage build. Each stage is pinned by
|
||||||
digest, and the two check stages are separate images so the linter's
|
digest, and the lint and builder stages are separate images so the
|
||||||
version is fixed independently of the compiler's:
|
linter's version is fixed independently of the compiler's:
|
||||||
|
|
||||||
1. **Lint stage** (`golangci/golangci-lint:v2.12.2`, Debian-based) —
|
1. **Lint stage** (`golangci/golangci-lint:v2.12.2`, Debian-based) —
|
||||||
installs `make`, downloads dependencies, copies the source, and runs
|
installs `make`, downloads dependencies, copies the source, and runs
|
||||||
`make fmt-check`, then `script/assets` to extract Alpine.js from
|
`make fmt-check`, then `script/assets` to extract Alpine.js from
|
||||||
`3p/`, then `golangci-lint config verify` and `golangci-lint run`,
|
`3p/`, then `golangci-lint config verify` and `golangci-lint run`,
|
||||||
both with `--network=none`.
|
both with `--network=none`.
|
||||||
2. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint
|
2. **Stylesheet stages** (`debian:bookworm-slim`, with the Tailwind
|
||||||
stage passing (it copies a file from it), runs `make test` and
|
standalone CLI pinned by version and sha256, one binary per
|
||||||
`make build` (both extract Alpine.js from `3p/` first), and finally
|
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
|
||||||
rebuilds the binary with `CGO_ENABLED=1` and static linking so it
|
rebuilds the binary with `CGO_ENABLED=1` and static linking so it
|
||||||
runs on musl. Both builds go through `make build`, the relink adding
|
runs on musl. Both builds go through `make build`, the relink adding
|
||||||
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that
|
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that
|
||||||
@@ -3470,7 +3516,7 @@ version is fixed independently of the compiler's:
|
|||||||
given, otherwise derived from the `.git` in the context, and the
|
given, otherwise derived from the `.git` in the context, and the
|
||||||
stage fails if a context with `.git` would stamp `unknown` (see
|
stage fails if a context with `.git` would stamp `unknown` (see
|
||||||
[Version stamping](#version-stamping)).
|
[Version stamping](#version-stamping)).
|
||||||
3. **Runtime stage** (`alpine:3.21`) — copies the static binary and
|
4. **Runtime stage** (`alpine:3.21`) — copies the static binary and
|
||||||
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker`
|
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker`
|
||||||
directory for all SQLite databases, exposes port 8080, and includes
|
directory for all SQLite databases, exposes port 8080, and includes
|
||||||
a health check against `/.well-known/healthcheck`. It sets no
|
a health check against `/.well-known/healthcheck`. It sets no
|
||||||
@@ -3482,18 +3528,18 @@ The lint stage invokes `golangci-lint` directly rather than `make lint`:
|
|||||||
it is already the pinned linter image, and `make lint` builds
|
it is already the pinned linter image, and `make lint` builds
|
||||||
`Dockerfile.lint`, which would need a docker daemon inside this build.
|
`Dockerfile.lint`, which would need a docker daemon inside this build.
|
||||||
|
|
||||||
Both check stages use Debian rather than Alpine because
|
The lint and builder stages use Debian rather than Alpine because
|
||||||
`gorm.io/driver/sqlite` pulls in `mattn/go-sqlite3`, which needs CGO
|
`gorm.io/driver/sqlite` pulls in `mattn/go-sqlite3`, which needs CGO
|
||||||
and does not compile against musl. Only the final binary is statically
|
and does not compile against musl. Only the final binary is statically
|
||||||
linked, which is what lets it run on the Alpine runtime image.
|
linked, which is what lets it run on the Alpine runtime image.
|
||||||
|
|
||||||
`script/cibuild` — `docker build .` — is the CI gate: the checks run
|
`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,
|
inside the image, so a build that succeeds is a repo that is formatted,
|
||||||
linted, tested and compiled. `script/lint` also uses Docker
|
linted, tested and compiled, with a current stylesheet. `script/lint`
|
||||||
(`Dockerfile.lint`, see Linting above), so `make lint` and `make check`
|
also uses Docker (`Dockerfile.lint`, see Linting above), so `make lint`
|
||||||
run the same pinned linter version the gate does; of the steps
|
and `make check` run the same pinned linter version the gate does; of
|
||||||
`make check` runs, only `script/test` and `script/fmt-check` run on the
|
the steps `make check` runs, only `script/test` and `script/fmt-check`
|
||||||
host.
|
run on the host.
|
||||||
|
|
||||||
#### CI gate honesty
|
#### CI gate honesty
|
||||||
|
|
||||||
@@ -3503,9 +3549,10 @@ check meaningless. The `check` workflow therefore writes
|
|||||||
`.ci-fingerprint` into the build context before building. Its value is
|
`.ci-fingerprint` into the build context before building. Its value is
|
||||||
the hash of the commit being checked, so every commit, docs-only ones
|
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,
|
and a squash merge whose tree matches an already-built branch included,
|
||||||
gets a new fingerprint, invalidates the `COPY . .` layer of both check
|
gets a new fingerprint, invalidates the `COPY . .` layer of every check
|
||||||
stages, and really runs `make fmt-check`, `golangci-lint`, `make test`,
|
stage, and really runs `make fmt-check`, `golangci-lint`, the stylesheet
|
||||||
and `make build`. A run that reports success ran them.
|
check, `make test`, and `make build`. A run that reports success ran
|
||||||
|
them.
|
||||||
|
|
||||||
The module download layer sits above `COPY . .` and stays cached.
|
The module download layer sits above `COPY . .` and stays cached.
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ func targetConfigFields(
|
|||||||
) []ConfigField {
|
) []ConfigField {
|
||||||
switch t.Type {
|
switch t.Type {
|
||||||
case database.TargetTypeSlack:
|
case database.TargetTypeSlack:
|
||||||
return slackConfigFields(t.Config)
|
return slackConfigFields(t)
|
||||||
case database.TargetTypeHTTP:
|
case database.TargetTypeHTTP:
|
||||||
return httpConfigFields(t)
|
return httpConfigFields(t)
|
||||||
case database.TargetTypeDatabase:
|
case database.TargetTypeDatabase:
|
||||||
@@ -119,10 +119,11 @@ func unavailableConfigFields() []ConfigField {
|
|||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// slackConfigFields describes a Slack target. Only the masked
|
// slackConfigFields describes a Slack target: its masked
|
||||||
// webhook URL is shown; the full URL is the credential.
|
// webhook URL and its retry count. Only the masked URL is
|
||||||
func slackConfigFields(configJSON string) []ConfigField {
|
// shown; the full URL is the credential.
|
||||||
cfg, err := parseSlackConfig(configJSON)
|
func slackConfigFields(t *database.Target) []ConfigField {
|
||||||
|
cfg, err := parseSlackConfig(t.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return unavailableConfigFields()
|
return unavailableConfigFields()
|
||||||
}
|
}
|
||||||
@@ -130,7 +131,7 @@ func slackConfigFields(configJSON string) []ConfigField {
|
|||||||
return []ConfigField{{
|
return []ConfigField{{
|
||||||
Label: "Webhook URL",
|
Label: "Webhook URL",
|
||||||
Value: cfg.MaskedWebhookURL(),
|
Value: cfg.MaskedWebhookURL(),
|
||||||
}}
|
}, maxRetriesField(t)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// httpConfigFields describes an HTTP target: its destination
|
// httpConfigFields describes an HTTP target: its destination
|
||||||
@@ -170,21 +171,7 @@ func httpConfigFields(t *database.Target) []ConfigField {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return append(fields, retryFields(t)...)
|
fields = append(fields, maxRetriesField(t))
|
||||||
}
|
|
||||||
|
|
||||||
// retryFields describes a target's retry settings, which live
|
|
||||||
// on the target row rather than in its configuration blob.
|
|
||||||
func retryFields(t *database.Target) []ConfigField {
|
|
||||||
retries := strconv.Itoa(t.MaxRetries)
|
|
||||||
if t.MaxRetries == 0 {
|
|
||||||
retries += " (fire-and-forget)"
|
|
||||||
}
|
|
||||||
|
|
||||||
fields := []ConfigField{{
|
|
||||||
Label: "Max Retries",
|
|
||||||
Value: retries,
|
|
||||||
}}
|
|
||||||
|
|
||||||
if t.MaxQueueSize > 0 {
|
if t.MaxQueueSize > 0 {
|
||||||
fields = append(fields, ConfigField{
|
fields = append(fields, ConfigField{
|
||||||
@@ -196,6 +183,20 @@ func retryFields(t *database.Target) []ConfigField {
|
|||||||
return fields
|
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. Its
|
// databaseConfigFields describes an archive target. Its
|
||||||
// configuration is optional, and an absent or empty expiry
|
// configuration is optional, and an absent or empty expiry
|
||||||
// means the archive is kept forever. An expiry that is set
|
// means the archive is kept forever. An expiry that is set
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const (
|
|||||||
viewMaskedOrigin = viewExampleOrigin + "/..."
|
viewMaskedOrigin = viewExampleOrigin + "/..."
|
||||||
viewUnavailable = "(unavailable)"
|
viewUnavailable = "(unavailable)"
|
||||||
viewExpiryNever = "never"
|
viewExpiryNever = "never"
|
||||||
|
viewMaxRetries = "Max Retries"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMaskedWebhookURL(t *testing.T) {
|
func TestMaskedWebhookURL(t *testing.T) {
|
||||||
@@ -157,9 +158,7 @@ func TestNewTargetViews_DeletedTarget(t *testing.T) {
|
|||||||
t, slackTargetName+" (deleted)", view.DisplayName(),
|
t, slackTargetName+" (deleted)", view.DisplayName(),
|
||||||
)
|
)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t, viewFor(t, slackTarget()).Config, view.Config,
|
||||||
map[string]string{"Webhook URL": slackMaskedURL},
|
|
||||||
fieldMap(view.Config),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +188,32 @@ func TestNewTargetViews_Slack(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
map[string]string{"Webhook URL": slackMaskedURL},
|
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",
|
||||||
|
},
|
||||||
fieldMap(view.Config),
|
fieldMap(view.Config),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -214,7 +238,7 @@ func TestNewTargetViews_HTTP(t *testing.T) {
|
|||||||
"Destination URL": viewMaskedOrigin,
|
"Destination URL": viewMaskedOrigin,
|
||||||
"Timeout": "30s",
|
"Timeout": "30s",
|
||||||
"Headers": "1 configured",
|
"Headers": "1 configured",
|
||||||
"Max Retries": "5",
|
viewMaxRetries: "5",
|
||||||
"Max Queue Size": "100",
|
"Max Queue Size": "100",
|
||||||
},
|
},
|
||||||
fields,
|
fields,
|
||||||
@@ -238,7 +262,7 @@ func TestNewTargetViews_HTTPFireAndForget(t *testing.T) {
|
|||||||
t,
|
t,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"Destination URL": viewMaskedOrigin,
|
"Destination URL": viewMaskedOrigin,
|
||||||
"Max Retries": "0 (fire-and-forget)",
|
viewMaxRetries: "0 (fire-and-forget)",
|
||||||
},
|
},
|
||||||
fieldMap(view.Config),
|
fieldMap(view.Config),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -276,27 +276,41 @@ func (h *Handlers) HandleSourceCreate() http.HandlerFunc {
|
|||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
h.renderTemplate(
|
h.renderTemplate(
|
||||||
w, r, "sources_new.html",
|
w, r, "sources_new.html",
|
||||||
newSourceFormData("", "", ""),
|
newSourceFormData("", sourceFormInput{
|
||||||
|
RetentionDays: strconv.Itoa(
|
||||||
|
database.DefaultRetentionDays,
|
||||||
|
),
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sourceFormInput carries the raw values of the new webhook form. A
|
||||||
|
// refused submission is shown again from it, so every value entered
|
||||||
|
// comes back, retention included.
|
||||||
|
type sourceFormInput struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
RetentionDays string
|
||||||
|
// HTTPURL, when not empty, asks for an HTTP target with this
|
||||||
|
// destination.
|
||||||
|
HTTPURL string
|
||||||
|
// Archive asks for a database (archive) target, whose rows expire
|
||||||
|
// after ArchiveExpiry.
|
||||||
|
Archive bool
|
||||||
|
ArchiveExpiry string
|
||||||
|
}
|
||||||
|
|
||||||
// newSourceFormData builds the template data for the webhook creation
|
// newSourceFormData builds the template data for the webhook creation
|
||||||
// form.
|
// form. It carries the retention default, which the form's help text
|
||||||
//
|
// names, from database.DefaultRetentionDays rather than a hardcoded
|
||||||
// It carries the retention default so the pre-filled value comes from
|
// copy of the same policy.
|
||||||
// database.DefaultRetentionDays rather than being a third hardcoded
|
|
||||||
// copy of the same policy, and it carries the submitted name and
|
|
||||||
// description so that re-rendering the form after a validation failure
|
|
||||||
// gives the user their input back instead of a blank form. The edit
|
|
||||||
// form already behaves that way; create now matches it.
|
|
||||||
func newSourceFormData(
|
func newSourceFormData(
|
||||||
errMsg, name, description string,
|
errMsg string, in sourceFormInput,
|
||||||
) map[string]any {
|
) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
tmplKeyError: errMsg,
|
tmplKeyError: errMsg,
|
||||||
"Name": name,
|
"Form": in,
|
||||||
"Description": description,
|
|
||||||
"DefaultRetentionDays": database.DefaultRetentionDays,
|
"DefaultRetentionDays": database.DefaultRetentionDays,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -323,57 +337,112 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
name := r.PostFormValue("name")
|
in := sourceFormInput{
|
||||||
description := r.PostFormValue("description")
|
Name: r.PostFormValue("name"),
|
||||||
retentionStr := r.PostFormValue("retention_days")
|
Description: r.PostFormValue("description"),
|
||||||
|
RetentionDays: r.PostFormValue("retention_days"),
|
||||||
|
HTTPURL: r.PostFormValue("http_url"),
|
||||||
|
Archive: r.PostFormValue("archive") != "",
|
||||||
|
ArchiveExpiry: r.PostFormValue("archive_expiry"),
|
||||||
|
}
|
||||||
|
|
||||||
if name == "" {
|
refuse := func(errMsg string) {
|
||||||
h.renderTemplateStatus(
|
h.renderTemplateStatus(
|
||||||
w, r, "sources_new.html",
|
w, r, "sources_new.html",
|
||||||
newSourceFormData(
|
newSourceFormData(errMsg, in),
|
||||||
"Name is required", name, description,
|
|
||||||
),
|
|
||||||
http.StatusBadRequest,
|
http.StatusBadRequest,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.Name == "" {
|
||||||
|
refuse("Name is required")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
retentionDays, errMsg := parseRetentionDays(
|
retentionDays, errMsg := parseRetentionDays(
|
||||||
retentionStr, database.DefaultRetentionDays,
|
in.RetentionDays, database.DefaultRetentionDays,
|
||||||
)
|
)
|
||||||
if errMsg != "" {
|
if errMsg != "" {
|
||||||
h.renderTemplateStatus(
|
refuse(errMsg)
|
||||||
w, r, "sources_new.html",
|
|
||||||
newSourceFormData(errMsg, name, description),
|
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.createWebhookWithEntrypoint(
|
targets, errMsg, err := h.newWebhookTargets(r.Context(), in)
|
||||||
w, r, userID, name, description, retentionDays,
|
if err != nil {
|
||||||
)
|
h.serverError(w, r, "failed to encode target config", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if errMsg != "" {
|
||||||
|
refuse(errMsg)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.createWebhookWithEntrypoint(w, r, &database.Webhook{
|
||||||
|
UserID: userID,
|
||||||
|
Name: in.Name,
|
||||||
|
Description: in.Description,
|
||||||
|
RetentionDays: retentionDays,
|
||||||
|
}, targets)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// createWebhookWithEntrypoint creates a webhook and its default
|
// newWebhookTargets validates the targets the new webhook form asks
|
||||||
// entrypoint in a transaction.
|
// for and returns the rows to create with the webhook, or the message
|
||||||
|
// the form shows for the first one it refuses. A filled-in HTTP URL
|
||||||
|
// asks for an HTTP target named "HTTP", and the archive checkbox for a
|
||||||
|
// database target named "Archive". Each goes through newTarget, as on
|
||||||
|
// the webhook page's add target form. The rows have no WebhookID yet:
|
||||||
|
// the webhook has no ID until it is created.
|
||||||
|
func (h *Handlers) newWebhookTargets(
|
||||||
|
ctx context.Context,
|
||||||
|
in sourceFormInput,
|
||||||
|
) ([]*database.Target, string, error) {
|
||||||
|
var requested []targetFormInput
|
||||||
|
|
||||||
|
if in.HTTPURL != "" {
|
||||||
|
requested = append(requested, targetFormInput{
|
||||||
|
Name: "HTTP",
|
||||||
|
Type: database.TargetTypeHTTP,
|
||||||
|
URL: in.HTTPURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.Archive {
|
||||||
|
requested = append(requested, targetFormInput{
|
||||||
|
Name: "Archive",
|
||||||
|
Type: database.TargetTypeDatabase,
|
||||||
|
Expiry: in.ArchiveExpiry,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
targets := make([]*database.Target, 0, len(requested))
|
||||||
|
|
||||||
|
for _, form := range requested {
|
||||||
|
target, errMsg, err := h.newTarget(ctx, "", form)
|
||||||
|
if err != nil || errMsg != "" {
|
||||||
|
return nil, errMsg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
targets = append(targets, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
return targets, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createWebhookWithEntrypoint creates a webhook, its default
|
||||||
|
// entrypoint and the given targets in a transaction.
|
||||||
func (h *Handlers) createWebhookWithEntrypoint(
|
func (h *Handlers) createWebhookWithEntrypoint(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
userID, name, description string,
|
webhook *database.Webhook,
|
||||||
retentionDays int,
|
targets []*database.Target,
|
||||||
) {
|
) {
|
||||||
webhook := &database.Webhook{
|
err := h.commitWebhook(webhook, targets)
|
||||||
UserID: userID,
|
|
||||||
Name: name,
|
|
||||||
Description: description,
|
|
||||||
RetentionDays: retentionDays,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := h.commitWebhook(webhook)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to create webhook", err)
|
h.serverError(w, r, "failed to create webhook", err)
|
||||||
|
|
||||||
@@ -390,7 +459,7 @@ func (h *Handlers) createWebhookWithEntrypoint(
|
|||||||
|
|
||||||
h.log.Info("webhook created",
|
h.log.Info("webhook created",
|
||||||
"webhook_id", webhook.ID,
|
"webhook_id", webhook.ID,
|
||||||
"name", name, "user_id", userID,
|
"name", webhook.Name, "user_id", webhook.UserID,
|
||||||
)
|
)
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
@@ -399,10 +468,12 @@ func (h *Handlers) createWebhookWithEntrypoint(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// commitWebhook creates a webhook and default entrypoint in
|
// commitWebhook creates a webhook, its default entrypoint and the
|
||||||
// a transaction. Returns an error on failure (rolls back).
|
// given targets in a transaction. Returns an error on failure (rolls
|
||||||
|
// back).
|
||||||
func (h *Handlers) commitWebhook(
|
func (h *Handlers) commitWebhook(
|
||||||
webhook *database.Webhook,
|
webhook *database.Webhook,
|
||||||
|
targets []*database.Target,
|
||||||
) error {
|
) error {
|
||||||
tx := h.db.DB().Begin()
|
tx := h.db.DB().Begin()
|
||||||
if tx.Error != nil {
|
if tx.Error != nil {
|
||||||
@@ -430,6 +501,17 @@ func (h *Handlers) commitWebhook(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, target := range targets {
|
||||||
|
target.WebhookID = webhook.ID
|
||||||
|
|
||||||
|
err = tx.Create(target).Error
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return tx.Commit().Error
|
return tx.Commit().Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/dustin/go-humanize"
|
||||||
|
"gorm.io/gorm"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
)
|
)
|
||||||
@@ -15,11 +17,27 @@ import (
|
|||||||
type TargetRowView struct {
|
type TargetRowView struct {
|
||||||
delivery.TargetView
|
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
|
// Archive is a database target's archive file, and nil for a target
|
||||||
// of any other type.
|
// of any other type.
|
||||||
Archive *ArchiveFileView
|
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
|
// ArchiveFileView is what a database target's row shows about its
|
||||||
// archive file.
|
// archive file.
|
||||||
type ArchiveFileView struct {
|
type ArchiveFileView struct {
|
||||||
@@ -45,10 +63,24 @@ func (h *Handlers) targetRows(
|
|||||||
views := delivery.NewTargetViews(targets)
|
views := delivery.NewTargetViews(targets)
|
||||||
rows := make([]TargetRowView, len(views))
|
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.
|
// NewTargetViews returns one view per target, in order.
|
||||||
for i := range views {
|
for i := range views {
|
||||||
rows[i].TargetView = views[i]
|
rows[i].TargetView = views[i]
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
counts := deliveries[targets[i].ID]
|
||||||
|
rows[i].Deliveries = &counts
|
||||||
|
}
|
||||||
|
|
||||||
if targets[i].Type == database.TargetTypeDatabase {
|
if targets[i].Type == database.TargetTypeDatabase {
|
||||||
rows[i].Archive = h.archiveFileView(webhook, &targets[i])
|
rows[i].Archive = h.archiveFileView(webhook, &targets[i])
|
||||||
}
|
}
|
||||||
@@ -57,6 +89,64 @@ func (h *Handlers) targetRows(
|
|||||||
return rows
|
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
|
// archiveFileView describes a database target's archive file from the
|
||||||
// file's metadata alone; the archive is never opened. The file is found
|
// 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
|
// by the name the archive writer uses, so it follows a rename of the
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handlers_test
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -69,3 +71,104 @@ func TestHandleSourceDetail_ShowsArchiveFile(t *testing.T) {
|
|||||||
assert.Contains(t, body, "not created yet")
|
assert.Contains(t, body, "not created yet")
|
||||||
assert.NotContains(t, body, "Archive Size:")
|
assert.NotContains(t, body, "Archive Size:")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// targetList returns the text of the targets section in a rendered
|
||||||
|
// webhook page, from its heading to the next heading, with the markup
|
||||||
|
// taken out and each run of space made one space. Each target's row
|
||||||
|
// then reads as its name, type, state and buttons, followed by the
|
||||||
|
// lines below them.
|
||||||
|
func targetList(t *testing.T, page string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, list, found := strings.Cut(page, ">Targets</h2>")
|
||||||
|
require.True(t, found, "the page has no targets section")
|
||||||
|
|
||||||
|
list, _, _ = strings.Cut(list, "<h2")
|
||||||
|
list = regexp.MustCompile(`<[^>]*>`).ReplaceAllString(list, " ")
|
||||||
|
|
||||||
|
return strings.Join(strings.Fields(list), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceDetail_ShowsTargetDeliveries checks each target row's
|
||||||
|
// delivered and failed deliveries, in total and in the last 24 hours,
|
||||||
|
// for the history seedStatsHistory builds, before and after the real
|
||||||
|
// retention reaper removes the oldest event. The http target has one
|
||||||
|
// delivered, one of them in the last 24 hours, and three failed, one of
|
||||||
|
// them in the last 24 hours and one of them the oldest event's, which
|
||||||
|
// retention removes without changing the total. The active log target
|
||||||
|
// has two failed, both in the last 24 hours, and its pending and
|
||||||
|
// retrying deliveries count in neither. The four inactive log targets
|
||||||
|
// have none.
|
||||||
|
func TestHandleSourceDetail_ShowsTargetDeliveries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
log *logger.Logger
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
hist := seedStatsHistory(t, h, sess, db, dbMgr)
|
||||||
|
|
||||||
|
const (
|
||||||
|
httpRow = "Delivered: 1 in total, 1 in the last 24 hours " +
|
||||||
|
"Failed: 3 in total, 1 in the last 24 hours"
|
||||||
|
activeLogRow = "t-log log Active Edit Deactivate Delete " +
|
||||||
|
"Delivered: 0 in total, 0 in the last 24 hours " +
|
||||||
|
"Failed: 2 in total, 2 in the last 24 hours"
|
||||||
|
inactiveLogRow = "t-log log Inactive Edit Activate Delete " +
|
||||||
|
"Delivered: 0 in total, 0 in the last 24 hours " +
|
||||||
|
"Failed: 0 in total, 0 in the last 24 hours"
|
||||||
|
)
|
||||||
|
|
||||||
|
list := targetList(t, renderSourceDetailPage(t, h, sess, hist.webhook.ID))
|
||||||
|
assert.Equal(t, 1, strings.Count(list, httpRow))
|
||||||
|
assert.Equal(t, 1, strings.Count(list, activeLogRow))
|
||||||
|
assert.Equal(t, 4, strings.Count(list, inactiveLogRow))
|
||||||
|
|
||||||
|
statsPrune(t, db, dbMgr, log, hist.webhookDB)
|
||||||
|
|
||||||
|
list = targetList(t, renderSourceDetailPage(t, h, sess, hist.webhook.ID))
|
||||||
|
assert.Equal(t, 1, strings.Count(list, httpRow))
|
||||||
|
assert.Equal(t, 1, strings.Count(list, activeLogRow))
|
||||||
|
assert.Equal(t, 4, strings.Count(list, inactiveLogRow))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceDetail_TargetDeliveriesUnreadable checks that when the
|
||||||
|
// webhook's event database cannot be read, each target's row says so
|
||||||
|
// instead of showing zeros.
|
||||||
|
func TestHandleSourceDetail_TargetDeliveriesUnreadable(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||||
|
|
||||||
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t,
|
||||||
|
webhookDB.Migrator().DropTable(&database.TargetTotals{}))
|
||||||
|
|
||||||
|
list := targetList(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
||||||
|
assert.Contains(t, list, "t-log log Active Edit Deactivate Delete "+
|
||||||
|
"The delivery counts could not be read.")
|
||||||
|
assert.NotContains(t, list, "Delivered:")
|
||||||
|
}
|
||||||
|
|||||||
@@ -196,8 +196,6 @@ func TestCreateFormRetentionCopyMatchesBehaviour(t *testing.T) {
|
|||||||
t.Cleanup(app.RequireStop)
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
body := renderPage(t, h, sess, "sources_new.html", map[string]any{
|
body := renderPage(t, h, sess, "sources_new.html", map[string]any{
|
||||||
"Name": "",
|
|
||||||
"Description": "",
|
|
||||||
"DefaultRetentionDays": database.DefaultRetentionDays,
|
"DefaultRetentionDays": database.DefaultRetentionDays,
|
||||||
dataKeyError: "",
|
dataKeyError: "",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,10 +18,13 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/chromedp/cdproto/browser"
|
"github.com/chromedp/cdproto/browser"
|
||||||
|
"github.com/chromedp/cdproto/dom"
|
||||||
|
"github.com/chromedp/cdproto/input"
|
||||||
"github.com/chromedp/cdproto/log"
|
"github.com/chromedp/cdproto/log"
|
||||||
"github.com/chromedp/cdproto/network"
|
"github.com/chromedp/cdproto/network"
|
||||||
"github.com/chromedp/cdproto/runtime"
|
"github.com/chromedp/cdproto/runtime"
|
||||||
"github.com/chromedp/chromedp"
|
"github.com/chromedp/chromedp"
|
||||||
|
"github.com/chromedp/chromedp/kb"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
@@ -40,6 +43,11 @@ const (
|
|||||||
phoneWidth = 390
|
phoneWidth = 390
|
||||||
phoneHeight = 844
|
phoneHeight = 844
|
||||||
|
|
||||||
|
// A window short enough that the event log scrolls with its last
|
||||||
|
// event expanded, and tall enough to show all of that event.
|
||||||
|
shortWidth = 1024
|
||||||
|
shortHeight = 450
|
||||||
|
|
||||||
// olderBody is the body of the event received before the newest.
|
// olderBody is the body of the event received before the newest.
|
||||||
olderBody = "the older event"
|
olderBody = "the older event"
|
||||||
)
|
)
|
||||||
@@ -47,7 +55,8 @@ const (
|
|||||||
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
|
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
|
||||||
// event log in a headless browser, served by the real router and so
|
// event log in a headless browser, served by the real router and so
|
||||||
// under the real Content-Security-Policy, and checks that the pages'
|
// under the real Content-Security-Policy, and checks that the pages'
|
||||||
// Alpine.js directives and the copy control work.
|
// Alpine.js directives and the copy control work, and that a target's
|
||||||
|
// row shows its delivery counts.
|
||||||
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -66,7 +75,7 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
|||||||
Active: true,
|
Active: true,
|
||||||
},
|
},
|
||||||
).Error)
|
).Error)
|
||||||
env.seedEvent(t, webhook.ID, olderBody)
|
older := env.seedEvent(t, webhook.ID, olderBody)
|
||||||
event := env.seedEvent(t, webhook.ID, `{"hello":"browser"}`)
|
event := env.seedEvent(t, webhook.ID, `{"hello":"browser"}`)
|
||||||
target := env.seedTarget(t, webhook.ID)
|
target := env.seedTarget(t, webhook.ID)
|
||||||
dlv := env.seedFailedDelivery(t, webhook.ID, event.ID, target.ID)
|
dlv := env.seedFailedDelivery(t, webhook.ID, event.ID, target.ID)
|
||||||
@@ -118,10 +127,16 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
checkRefusedTarget(ctx, t, page)
|
checkRefusedTarget(ctx, t, page)
|
||||||
|
checkTargetDeliveries(ctx, t, page, target.Name,
|
||||||
|
"0 in total, 0 in the last 24 hours",
|
||||||
|
"1 in total, 1 in the last 24 hours")
|
||||||
checkCopy(ctx, t, page)
|
checkCopy(ctx, t, page)
|
||||||
checkEntrypointEdit(ctx, t, page, page+"/events")
|
checkEntrypointEdit(ctx, t, page, page+"/events")
|
||||||
checkRecentEvents(ctx, t, page)
|
checkRecentEvents(ctx, t, page)
|
||||||
checkEventLog(ctx, t, page+"/events", event.ID, target.Name)
|
checkArchiveChoice(ctx, t, srv.URL+"/hooks/new", page)
|
||||||
|
checkNewWebhookTargets(ctx, t, env, srv.URL+"/hooks/new")
|
||||||
|
checkRefusedNewWebhook(ctx, t, srv.URL+"/hooks/new")
|
||||||
|
checkEventLog(ctx, t, page+"/events", event.ID, older.ID, target.Name)
|
||||||
checkMobileMenu(ctx, t, page)
|
checkMobileMenu(ctx, t, page)
|
||||||
|
|
||||||
assert.Empty(t, problems(), "the browser reported problems")
|
assert.Empty(t, problems(), "the browser reported problems")
|
||||||
@@ -452,6 +467,28 @@ func checkRefusedTarget(ctx context.Context, t *testing.T, url string) {
|
|||||||
assert.Empty(t, typed, "after Cancel, the next Add keeps the url entered")
|
assert.Empty(t, typed, "after Cancel, the next Add keeps the url entered")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkTargetDeliveries loads a webhook page and checks that the row of
|
||||||
|
// the target named name shows delivered and failed beside its
|
||||||
|
// "Delivered:" and "Failed:" labels.
|
||||||
|
func checkTargetDeliveries(
|
||||||
|
ctx context.Context, t *testing.T, url, name, delivered, failed string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
row := `//span[text()="` + name + `"]/ancestor::div[@class="p-4"][1]`
|
||||||
|
figure := func(label, value string) string {
|
||||||
|
return row + `//span[text()="` + label +
|
||||||
|
`"]/following-sibling::span[text()="` + value + `"]`
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
||||||
|
|
||||||
|
assert.Truef(t, shown(ctx, figure("Delivered:", delivered)),
|
||||||
|
"the row of %s does not show %q delivered", name, delivered)
|
||||||
|
assert.Truef(t, shown(ctx, figure("Failed:", failed)),
|
||||||
|
"the row of %s does not show %q failed", name, failed)
|
||||||
|
}
|
||||||
|
|
||||||
// checkCopy loads a webhook page and checks that the Copy control beside
|
// checkCopy loads a webhook page and checks that the Copy control beside
|
||||||
// its entrypoint's URL is a button, and that clicking it copies the URL
|
// its entrypoint's URL is a button, and that clicking it copies the URL
|
||||||
// and says so: the button reads "Copied" only once the copy succeeded.
|
// and says so: the button reads "Copied" only once the copy succeeded.
|
||||||
@@ -604,18 +641,30 @@ func checkRecentEvents(ctx context.Context, t *testing.T, url string) {
|
|||||||
"the event's own page does not show its body")
|
"the event's own page does not show its body")
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkEventLog loads the event log and checks that clicking an event's
|
// checkEventLog loads the event log and checks an event's row. Clicking
|
||||||
// row expands it, that in there clicking its delivery shows the
|
// its ID expands the event, and in there clicking its delivery shows the
|
||||||
// delivery's attempts and clicking again hides them, and that clicking
|
// delivery's attempts and clicking again hides them. Clicking the row's
|
||||||
// the event's row again collapses it.
|
// caret collapses the event, clicking it again expands it, and clicking
|
||||||
|
// the ID again collapses it. While the event is expanded the row says so
|
||||||
|
// and its caret is turned up, and while it is collapsed neither. It then
|
||||||
|
// runs checkEventSelection on the log's last event, lastEventID, and
|
||||||
|
// checkEventKeyboard on eventID.
|
||||||
func checkEventLog(
|
func checkEventLog(
|
||||||
ctx context.Context, t *testing.T, url, eventID, targetName string,
|
ctx context.Context,
|
||||||
|
t *testing.T,
|
||||||
|
url, eventID, lastEventID, targetName string,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
// The event's row shows its ID, and its Resubmit form is in the part
|
// The event's row shows its ID and ends with its caret, which turns
|
||||||
// that expands. The delivery's row there shows the target's name.
|
// up with Tailwind's rotate-180 class, and its Resubmit form is in
|
||||||
eventRow := `//span[text()="` + eventID + `"]`
|
// the part that expands. The delivery's row there shows the target's
|
||||||
|
// name.
|
||||||
|
id := `//span[text()="` + eventID + `"]`
|
||||||
|
row := id + `/ancestor::div[@role="button"]`
|
||||||
|
caret := row + `//*[local-name()="svg"]`
|
||||||
|
caretUp := caret + `[contains(@class, "rotate-180")]`
|
||||||
|
caretDown := caret + `[not(contains(@class, "rotate-180"))]`
|
||||||
expanded := `form[action$="/` + eventID + `/resubmit"]`
|
expanded := `form[action$="/` + eventID + `/resubmit"]`
|
||||||
deliveryRow := `//span[text()="` + targetName + `"]`
|
deliveryRow := `//span[text()="` + targetName + `"]`
|
||||||
attempt := `//span[text()="Attempt 1"]`
|
attempt := `//span[text()="Attempt 1"]`
|
||||||
@@ -624,8 +673,13 @@ func checkEventLog(
|
|||||||
|
|
||||||
assert.True(t, hidden(ctx, expanded), "the event starts expanded")
|
assert.True(t, hidden(ctx, expanded), "the event starts expanded")
|
||||||
|
|
||||||
click(ctx, t, eventRow)
|
click(ctx, t, id)
|
||||||
assert.True(t, shown(ctx, expanded), "clicking the event does not expand it")
|
assert.True(t, shown(ctx, expanded),
|
||||||
|
"clicking the event's ID does not expand it")
|
||||||
|
assert.True(t, shown(ctx, row+`[@aria-expanded="true"]`),
|
||||||
|
"the expanded event's row does not say it is expanded")
|
||||||
|
assert.True(t, shown(ctx, caretUp),
|
||||||
|
"the expanded event's caret does not turn up")
|
||||||
|
|
||||||
assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown")
|
assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown")
|
||||||
|
|
||||||
@@ -637,9 +691,362 @@ func checkEventLog(
|
|||||||
assert.True(t, hidden(ctx, attempt),
|
assert.True(t, hidden(ctx, attempt),
|
||||||
"clicking the delivery again does not hide its attempts")
|
"clicking the delivery again does not hide its attempts")
|
||||||
|
|
||||||
click(ctx, t, eventRow)
|
click(ctx, t, caret)
|
||||||
assert.True(t, hidden(ctx, expanded),
|
assert.True(t, hidden(ctx, expanded),
|
||||||
"clicking the event again does not collapse it")
|
"clicking the caret does not collapse the event")
|
||||||
|
assert.True(t, shown(ctx, row+`[@aria-expanded="false"]`),
|
||||||
|
"the collapsed event's row does not say it is collapsed")
|
||||||
|
assert.True(t, shown(ctx, caretDown),
|
||||||
|
"the collapsed event's caret stays turned up")
|
||||||
|
|
||||||
|
click(ctx, t, caret)
|
||||||
|
assert.True(t, shown(ctx, expanded),
|
||||||
|
"clicking the caret again does not expand the event")
|
||||||
|
|
||||||
|
click(ctx, t, id)
|
||||||
|
assert.True(t, hidden(ctx, expanded),
|
||||||
|
"clicking the event's ID again does not collapse it")
|
||||||
|
|
||||||
|
checkEventSelection(ctx, t, url, lastEventID)
|
||||||
|
checkEventKeyboard(ctx, t, url, eventID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkEventSelection loads the event log in a short window and checks
|
||||||
|
// that selecting the ID of its last event, eventID, with the mouse leaves
|
||||||
|
// the event as it was: dragging over the ID leaves it collapsed, the
|
||||||
|
// caret's next click still expands it, and with the page then scrolled to
|
||||||
|
// its end, a triple-click on the ID leaves it expanded and selects that
|
||||||
|
// ID. Had the triple-click's first click collapsed the event, the page
|
||||||
|
// would have got shorter and moved under the pointer.
|
||||||
|
func checkEventSelection(
|
||||||
|
ctx context.Context, t *testing.T, url, eventID string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
id := `//span[text()="` + eventID + `"]`
|
||||||
|
row := id + `/ancestor::div[@role="button"]`
|
||||||
|
caret := row + `//*[local-name()="svg"]`
|
||||||
|
expanded := `form[action$="/` + eventID + `/resubmit"]`
|
||||||
|
|
||||||
|
var (
|
||||||
|
selected, state string
|
||||||
|
hasState bool
|
||||||
|
scrolled float64
|
||||||
|
)
|
||||||
|
|
||||||
|
// A single click toggles the event half a second later, so this waits
|
||||||
|
// a second before reading the selection and whether it is expanded.
|
||||||
|
read := chromedp.Tasks{
|
||||||
|
chromedp.Sleep(time.Second),
|
||||||
|
chromedp.Evaluate(`window.getSelection().toString()`, &selected),
|
||||||
|
chromedp.AttributeValue(
|
||||||
|
row, "aria-expanded", &state, &hasState, chromedp.BySearch,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx, chromedp.EmulateViewport(shortWidth, shortHeight), loadPage(url),
|
||||||
|
))
|
||||||
|
|
||||||
|
selectText(ctx, t, id)
|
||||||
|
require.NoError(t, chromedp.Run(ctx, read))
|
||||||
|
assert.Equal(t, eventID, selected, "the event's ID cannot be selected")
|
||||||
|
require.True(t, hasState, "the event's row does not say if it is expanded")
|
||||||
|
assert.Equal(t, "false", state, "selecting the event's ID expands it")
|
||||||
|
|
||||||
|
// The caret's click also clears the selection, so the triple-click's
|
||||||
|
// first click finds nothing selected, as a person's would.
|
||||||
|
click(ctx, t, caret)
|
||||||
|
assert.True(t, shown(ctx, expanded),
|
||||||
|
"clicking the caret after selecting the ID does not expand the event")
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(ctx, chromedp.Evaluate(
|
||||||
|
`window.scrollTo(0, document.body.scrollHeight); window.scrollY`,
|
||||||
|
&scrolled,
|
||||||
|
)))
|
||||||
|
require.Positive(t, scrolled, "the event log does not scroll")
|
||||||
|
|
||||||
|
tripleClick(ctx, t, id)
|
||||||
|
require.NoError(t, chromedp.Run(ctx, read))
|
||||||
|
assert.Contains(t, selected, eventID,
|
||||||
|
"a triple-click does not select the event's ID")
|
||||||
|
assert.Equal(t, "true", state,
|
||||||
|
"a triple-click selecting the event's ID collapses it")
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkEventKeyboard loads the event log and checks that Tab from the
|
||||||
|
// page's Back link reaches the event's row, the first after it, and that
|
||||||
|
// Enter then expands the event and Space collapses it.
|
||||||
|
func checkEventKeyboard(
|
||||||
|
ctx context.Context, t *testing.T, url, eventID string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
back := `//a[contains(text(), "Back to")]`
|
||||||
|
expanded := `form[action$="/` + eventID + `/resubmit"]`
|
||||||
|
|
||||||
|
var focused string
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx,
|
||||||
|
loadPage(url),
|
||||||
|
chromedp.Focus(back, chromedp.BySearch),
|
||||||
|
chromedp.KeyEvent(kb.Tab),
|
||||||
|
chromedp.Evaluate(`document.activeElement.textContent`, &focused),
|
||||||
|
))
|
||||||
|
require.Contains(t, focused, eventID,
|
||||||
|
"Tab from the Back link does not reach the event's row")
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(kb.Enter)))
|
||||||
|
assert.True(t, shown(ctx, expanded), "Enter does not expand the event")
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(" ")))
|
||||||
|
assert.True(t, hidden(ctx, expanded), "Space does not collapse the event")
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectText selects the text of the element matching an XPath
|
||||||
|
// expression as a person does with the mouse: pressing the button at the
|
||||||
|
// text's start, moving to its end and releasing it there.
|
||||||
|
func selectText(ctx context.Context, t *testing.T, xpath string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var box *dom.BoxModel
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx, chromedp.Dimensions(xpath, &box, chromedp.BySearch),
|
||||||
|
))
|
||||||
|
|
||||||
|
// The content box's corners, clockwise from its top left.
|
||||||
|
left, right := box.Content[0]+1, box.Content[2]-1
|
||||||
|
middle := (box.Content[1] + box.Content[5]) / 2
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx,
|
||||||
|
input.DispatchMouseEvent(input.MousePressed, left, middle).
|
||||||
|
WithButton(input.Left).WithButtons(1).WithClickCount(1),
|
||||||
|
input.DispatchMouseEvent(input.MouseMoved, right, middle).
|
||||||
|
WithButton(input.Left).WithButtons(1),
|
||||||
|
input.DispatchMouseEvent(input.MouseReleased, right, middle).
|
||||||
|
WithButton(input.Left).WithClickCount(1),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// tripleClick clicks three times in a row in the middle of the element
|
||||||
|
// matching an XPath expression, as a person does to select a whole line
|
||||||
|
// of text. The browser selects a word on the second click and the whole
|
||||||
|
// paragraph on the third.
|
||||||
|
func tripleClick(ctx context.Context, t *testing.T, xpath string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var box *dom.BoxModel
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx, chromedp.Dimensions(xpath, &box, chromedp.BySearch),
|
||||||
|
))
|
||||||
|
|
||||||
|
// The content box's corners, clockwise from its top left.
|
||||||
|
x := (box.Content[0] + box.Content[2]) / 2
|
||||||
|
y := (box.Content[1] + box.Content[5]) / 2
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx,
|
||||||
|
chromedp.MouseClickXY(x, y, chromedp.ClickCount(1)),
|
||||||
|
chromedp.MouseClickXY(x, y, chromedp.ClickCount(2)),
|
||||||
|
chromedp.MouseClickXY(x, y, chromedp.ClickCount(3)),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The parts of the new webhook page the checks below find and click.
|
||||||
|
const (
|
||||||
|
archiveBox = `//input[@name="archive"]`
|
||||||
|
archiveIsOn = `document.querySelector('input[name="archive"]').checked`
|
||||||
|
pruningChoice = `//select[@name="archive_expiry"]`
|
||||||
|
createButton = `//button[text()="Create Webhook"]`
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkArchiveChoice loads the new webhook page and checks that the
|
||||||
|
// archive pruning choice stays hidden until the archive box is checked
|
||||||
|
// and hides again when it is unchecked; and that after checking it,
|
||||||
|
// opening the page at elsewhere and going back, the page again shows
|
||||||
|
// the box unchecked and the choice hidden.
|
||||||
|
func checkArchiveChoice(
|
||||||
|
ctx context.Context, t *testing.T, url, elsewhere string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
||||||
|
|
||||||
|
assert.True(t, hidden(ctx, pruningChoice),
|
||||||
|
"the pruning choice shows before archive is checked")
|
||||||
|
|
||||||
|
click(ctx, t, archiveBox)
|
||||||
|
assert.True(t, shown(ctx, pruningChoice),
|
||||||
|
"checking archive does not show the pruning choice")
|
||||||
|
|
||||||
|
click(ctx, t, archiveBox)
|
||||||
|
assert.True(t, hidden(ctx, pruningChoice),
|
||||||
|
"unchecking archive does not hide the pruning choice")
|
||||||
|
|
||||||
|
var (
|
||||||
|
loaded string
|
||||||
|
checked bool
|
||||||
|
)
|
||||||
|
|
||||||
|
click(ctx, t, archiveBox)
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx,
|
||||||
|
loadPage(elsewhere),
|
||||||
|
chromedp.NavigateBack(),
|
||||||
|
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
|
||||||
|
chromedp.Evaluate(
|
||||||
|
`performance.getEntriesByType("navigation")[0].type`, &loaded,
|
||||||
|
),
|
||||||
|
chromedp.Evaluate(archiveIsOn, &checked),
|
||||||
|
))
|
||||||
|
require.Equal(
|
||||||
|
t, "back_forward", loaded,
|
||||||
|
"going back, the browser did not load the page again",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.False(t, checked, "going back leaves archive checked")
|
||||||
|
assert.True(t, hidden(ctx, pruningChoice),
|
||||||
|
"going back shows the pruning choice")
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkNewWebhookTargets submits the new webhook page with the HTTP
|
||||||
|
// target URL filled in or empty, and with archive left off or checked
|
||||||
|
// with each pruning choice, and checks that each webhook is created
|
||||||
|
// with exactly the targets asked for.
|
||||||
|
func checkNewWebhookTargets(
|
||||||
|
ctx context.Context, t *testing.T, env *testEnv, url string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Each value the pruning choice submits, after an empty one that
|
||||||
|
// stands for archive left off.
|
||||||
|
expiries := []string{
|
||||||
|
"", "never", "1h", "12h", "24h", "720h", "2160h", "8760h",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, httpURL := range []string{"", publicTargetURL} {
|
||||||
|
for _, expiry := range expiries {
|
||||||
|
name := "url=" + httpURL + " archive=" + expiry
|
||||||
|
want := map[database.TargetType]string{}
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx,
|
||||||
|
loadPage(url),
|
||||||
|
chromedp.SetValue("#name", name, chromedp.ByQuery),
|
||||||
|
))
|
||||||
|
|
||||||
|
if httpURL != "" {
|
||||||
|
require.NoError(t, chromedp.Run(ctx, chromedp.SetValue(
|
||||||
|
"#http_url", httpURL, chromedp.ByQuery,
|
||||||
|
)))
|
||||||
|
|
||||||
|
want[database.TargetTypeHTTP] = `{"url":"` + httpURL + `"}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if expiry != "" {
|
||||||
|
// The choice showing moves Create down, so it is
|
||||||
|
// waited for before Create is clicked.
|
||||||
|
click(ctx, t, archiveBox)
|
||||||
|
require.Truef(t, shown(ctx, pruningChoice),
|
||||||
|
"%s: checking archive does not show the pruning choice",
|
||||||
|
name)
|
||||||
|
require.NoError(t, chromedp.Run(ctx, chromedp.SetValue(
|
||||||
|
pruningChoice, expiry, chromedp.BySearch,
|
||||||
|
)))
|
||||||
|
|
||||||
|
want[database.TargetTypeDatabase] = `{"expiry":"` + expiry + `"}`
|
||||||
|
}
|
||||||
|
|
||||||
|
click(ctx, t, createButton)
|
||||||
|
require.Truef(t, shown(ctx, `//h1[text()="`+name+`"]`),
|
||||||
|
"%s: the new webhook's page does not open", name)
|
||||||
|
|
||||||
|
assert.Equalf(t, want, targetConfigs(t, env, name),
|
||||||
|
"%s: the webhook does not have the targets asked for", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// targetConfigs reads the targets of the webhook named name, and
|
||||||
|
// returns each one's stored configuration by its type.
|
||||||
|
func targetConfigs(
|
||||||
|
t *testing.T, env *testEnv, name string,
|
||||||
|
) map[database.TargetType]string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var (
|
||||||
|
webhook database.Webhook
|
||||||
|
targets []database.Target
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, env.db.DB().
|
||||||
|
Where("name = ?", name).First(&webhook).Error)
|
||||||
|
require.NoError(t, env.db.DB().
|
||||||
|
Where("webhook_id = ?", webhook.ID).Find(&targets).Error)
|
||||||
|
|
||||||
|
configs := map[database.TargetType]string{}
|
||||||
|
for _, target := range targets {
|
||||||
|
configs[target.Type] = target.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
return configs
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkRefusedNewWebhook submits the new webhook page with archive
|
||||||
|
// checked and an HTTP target URL the server refuses, a loopback
|
||||||
|
// destination, and checks that the page comes back with the reason and
|
||||||
|
// every value entered, archive still checked and its pruning choice
|
||||||
|
// showing.
|
||||||
|
func checkRefusedNewWebhook(ctx context.Context, t *testing.T, url string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
const refusedURL = "http://127.0.0.1/hook"
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx,
|
||||||
|
loadPage(url),
|
||||||
|
chromedp.SetValue("#name", "refused", chromedp.ByQuery),
|
||||||
|
chromedp.SetValue("#description", "kept", chromedp.ByQuery),
|
||||||
|
chromedp.SetValue("#retention_days", "7", chromedp.ByQuery),
|
||||||
|
chromedp.SetValue("#http_url", refusedURL, chromedp.ByQuery),
|
||||||
|
))
|
||||||
|
click(ctx, t, archiveBox)
|
||||||
|
require.True(t, shown(ctx, pruningChoice),
|
||||||
|
"checking archive does not show the pruning choice")
|
||||||
|
require.NoError(t, chromedp.Run(ctx, chromedp.SetValue(
|
||||||
|
pruningChoice, "2160h", chromedp.BySearch,
|
||||||
|
)))
|
||||||
|
click(ctx, t, createButton)
|
||||||
|
|
||||||
|
assert.True(t, shown(ctx, `//div[@class="alert-error"]`),
|
||||||
|
"a refused webhook does not show the reason")
|
||||||
|
|
||||||
|
var (
|
||||||
|
name, description, retention, typed, expiry string
|
||||||
|
checked bool
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx,
|
||||||
|
chromedp.Value("#name", &name, chromedp.ByQuery),
|
||||||
|
chromedp.Value("#description", &description, chromedp.ByQuery),
|
||||||
|
chromedp.Value("#retention_days", &retention, chromedp.ByQuery),
|
||||||
|
chromedp.Value("#http_url", &typed, chromedp.ByQuery),
|
||||||
|
chromedp.Value("#archive_expiry", &expiry, chromedp.ByQuery),
|
||||||
|
chromedp.Evaluate(archiveIsOn, &checked),
|
||||||
|
))
|
||||||
|
|
||||||
|
assert.Equal(t, "refused", name, "the name entered is lost")
|
||||||
|
assert.Equal(t, "kept", description, "the description entered is lost")
|
||||||
|
assert.Equal(t, "7", retention, "the retention entered is lost")
|
||||||
|
assert.Equal(t, refusedURL, typed, "the url entered is lost")
|
||||||
|
assert.True(t, checked, "archive is no longer checked")
|
||||||
|
assert.True(t, shown(ctx, pruningChoice), "the pruning choice is hidden")
|
||||||
|
assert.Equal(t, "2160h", expiry, "the pruning chosen is lost")
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkMobileMenu loads a page in a phone-sized window and checks that
|
// checkMobileMenu loads a page in a phone-sized window and checks that
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -439,7 +440,8 @@ func (e *testEnv) storedEntrypoint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// seedFailedDelivery records a terminally failed delivery of an event
|
// seedFailedDelivery records a terminally failed delivery of an event
|
||||||
// to a target in the webhook's own database.
|
// to a target in the webhook's own database, as the delivery engine
|
||||||
|
// leaves one: finished now, and counted in its target's totals.
|
||||||
func (e *testEnv) seedFailedDelivery(
|
func (e *testEnv) seedFailedDelivery(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
webhookID, eventID, targetID string,
|
webhookID, eventID, targetID string,
|
||||||
@@ -449,16 +451,21 @@ func (e *testEnv) seedFailedDelivery(
|
|||||||
webhookDB, err := e.dbMgr.GetDB(webhookID)
|
webhookDB, err := e.dbMgr.GetDB(webhookID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
finishedAt := time.Now()
|
||||||
dlv := &database.Delivery{
|
dlv := &database.Delivery{
|
||||||
EventID: eventID,
|
EventID: eventID,
|
||||||
TargetID: targetID,
|
TargetID: targetID,
|
||||||
Status: database.DeliveryStatusFailed,
|
Status: database.DeliveryStatusFailed,
|
||||||
|
FinishedAt: &finishedAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(
|
require.NoError(
|
||||||
t,
|
t,
|
||||||
webhookDB.Omit(clause.Associations).Create(dlv).Error,
|
webhookDB.Omit(clause.Associations).Create(dlv).Error,
|
||||||
)
|
)
|
||||||
|
require.NoError(t, database.AddTargetTotals(webhookDB,
|
||||||
|
database.TargetTotals{TargetID: targetID, Deliveries: 1, Failed: 1},
|
||||||
|
))
|
||||||
|
|
||||||
return dlv
|
return dlv
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -60,9 +60,9 @@ main() {
|
|||||||
if missing go; then pkg_install go golang go go; fi
|
if missing go; then pkg_install go golang go go; fi
|
||||||
|
|
||||||
# Not installed here: docker is platform-specific and out of scope for a
|
# Not installed here: docker is platform-specific and out of scope for a
|
||||||
# package-manager bootstrap, but script/lint needs it.
|
# package-manager bootstrap, but script/lint and script/css need it.
|
||||||
if missing docker; then
|
if missing docker; then
|
||||||
echo "bootstrap: docker not found; script/lint requires it" >&2
|
echo "bootstrap: docker not found; script/lint and script/css require it" >&2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
go mod download
|
go mod download
|
||||||
|
|||||||
+3
-2
@@ -1,8 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/check: run all checks (test, lint, fmt-check). Our own
|
# script/check: run all checks (test, lint, fmt-check, css-check). Our own
|
||||||
# extension to scripts-to-rule-them-all.
|
# extension to scripts-to-rule-them-all.
|
||||||
# Writes only the ignored static/js/alpine.min.js, through script/test.
|
# Writes only the ignored static/js/alpine.min.js, through script/test.
|
||||||
# Generic: usually needs no adaptation.
|
# Generic, apart from css-check.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
@@ -11,6 +11,7 @@ main() {
|
|||||||
"$SCRIPT_DIR/test"
|
"$SCRIPT_DIR/test"
|
||||||
"$SCRIPT_DIR/lint"
|
"$SCRIPT_DIR/lint"
|
||||||
"$SCRIPT_DIR/fmt-check"
|
"$SCRIPT_DIR/fmt-check"
|
||||||
|
"$SCRIPT_DIR/css-check"
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/css: regenerate static/css/tailwind.css (writes). tailwindcss is
|
||||||
|
# never installed locally: it runs in docker, at the version and sha256
|
||||||
|
# pinned in the Dockerfile's stylesheet stages, which also say what the
|
||||||
|
# stylesheet is generated from.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
docker build --target css-output --output type=local,dest=static/css .
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Executable
+14
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/css-check: fail when static/css/tailwind.css differs from what
|
||||||
|
# script/css would generate (read-only). The comparison is the Dockerfile's
|
||||||
|
# css-check stage, which the image build runs too.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
docker build --target css-check --output type=cacheonly .
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
@import "tailwindcss";
|
/* Classes are taken only from the files named below. A class written in
|
||||||
|
any other file is not generated: name that file here too. */
|
||||||
/* Source the templates */
|
@import "tailwindcss" source(none);
|
||||||
@source "../../templates/**/*.html";
|
@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 */
|
/* Material Design inspired theme customization */
|
||||||
@theme {
|
@theme {
|
||||||
@@ -53,10 +56,6 @@
|
|||||||
@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;
|
@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 */
|
/* Cards */
|
||||||
.card {
|
.card {
|
||||||
@apply bg-white rounded-lg shadow-elevation-1 overflow-hidden;
|
@apply bg-white rounded-lg shadow-elevation-1 overflow-hidden;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+17
-2
@@ -71,17 +71,32 @@ document.addEventListener("alpine:init", function () {
|
|||||||
|
|
||||||
// Something a click shows and hides: the mobile menu, an add form,
|
// 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
|
// an entrypoint's edit form, an event in the event log or in the
|
||||||
// recent events, a delivery's attempts. It starts hidden, or shown
|
// recent events, a delivery's attempts, the new webhook page's
|
||||||
// when its element has the data-open attribute.
|
// archive pruning choice. It starts hidden, or shown when its
|
||||||
|
// element has the data-open attribute.
|
||||||
window.Alpine.data("collapsible", function () {
|
window.Alpine.data("collapsible", function () {
|
||||||
return {
|
return {
|
||||||
open: false,
|
open: false,
|
||||||
|
// The timer of the toggle a single click is waiting to make.
|
||||||
|
pendingToggle: null,
|
||||||
init() {
|
init() {
|
||||||
this.open = this.$root.hasAttribute("data-open");
|
this.open = this.$root.hasAttribute("data-open");
|
||||||
},
|
},
|
||||||
toggle() {
|
toggle() {
|
||||||
this.open = !this.open;
|
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 click of a double- or triple-click cancels that, so
|
||||||
|
// nothing moves under the pointer while it selects text.
|
||||||
|
toggleUnlessSelecting(event) {
|
||||||
|
if (event.detail > 1) {
|
||||||
|
clearTimeout(this.pendingToggle);
|
||||||
|
} else if (window.getSelection().toString() === "") {
|
||||||
|
this.pendingToggle = setTimeout(() => this.toggle(), 500);
|
||||||
|
}
|
||||||
|
},
|
||||||
get closed() {
|
get closed() {
|
||||||
return !this.open;
|
return !this.open;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -271,6 +271,18 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{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>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="p-4 text-sm text-gray-500">No targets configured.</div>
|
<div class="p-4 text-sm text-gray-500">No targets configured.</div>
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100">
|
||||||
{{range .Events}}
|
{{range .Events}}
|
||||||
<div class="p-4" x-data="collapsible">
|
<div class="p-4" x-data="collapsible">
|
||||||
<button type="button" class="btn-small w-full flex flex-wrap justify-between gap-2 text-left" @click="toggle">
|
<!-- 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" @click="toggleUnlessSelecting" @keydown.enter.prevent="toggle" @keydown.space.prevent="toggle">
|
||||||
<span class="flex flex-wrap items-center gap-3">
|
<span class="flex flex-wrap items-center gap-3">
|
||||||
<span class="badge-info">{{.Method}}</span>
|
<span class="badge-info">{{.Method}}</span>
|
||||||
<span class="text-sm font-mono text-gray-700">{{.ID}}</span>
|
<span class="text-sm font-mono text-gray-700">{{.ID}}</span>
|
||||||
@@ -39,7 +40,7 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
<div x-show="open" x-cloak class="mt-3 p-3 bg-gray-50 rounded-md">
|
<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="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
|||||||
@@ -18,20 +18,50 @@
|
|||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="name" class="label">Name</label>
|
<label for="name" class="label">Name</label>
|
||||||
<input type="text" id="name" name="name" value="{{.Name}}" required autofocus placeholder="My Webhook" class="input">
|
<input type="text" id="name" name="name" value="{{.Form.Name}}" required autofocus placeholder="My Webhook" class="input">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="description" class="label">Description</label>
|
<label for="description" class="label">Description</label>
|
||||||
<textarea id="description" name="description" rows="3" placeholder="Optional description" class="input">{{.Description}}</textarea>
|
<textarea id="description" name="description" rows="3" placeholder="Optional description" class="input">{{.Form.Description}}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="retention_days" class="label">Retention (days)</label>
|
<label for="retention_days" class="label">Retention (days)</label>
|
||||||
<input type="number" id="retention_days" name="retention_days" value="{{.DefaultRetentionDays}}" min="0" class="input">
|
<input type="number" id="retention_days" name="retention_days" value="{{.Form.RetentionDays}}" 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>
|
<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>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<option value="never"{{if eq .Form.ArchiveExpiry "never"}} selected{{end}}>never</option>
|
||||||
|
<option value="1h"{{if eq .Form.ArchiveExpiry "1h"}} selected{{end}}>1h</option>
|
||||||
|
<option value="12h"{{if eq .Form.ArchiveExpiry "12h"}} selected{{end}}>12h</option>
|
||||||
|
<option value="24h"{{if eq .Form.ArchiveExpiry "24h"}} selected{{end}}>24h</option>
|
||||||
|
<option value="720h"{{if eq .Form.ArchiveExpiry "720h"}} selected{{end}}>30d</option>
|
||||||
|
<option value="2160h"{{if eq .Form.ArchiveExpiry "2160h"}} selected{{end}}>90d</option>
|
||||||
|
<option value="8760h"{{if eq .Form.ArchiveExpiry "8760h"}} selected{{end}}>365d</option>
|
||||||
|
</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">
|
<div class="flex gap-3">
|
||||||
<button type="submit" class="btn-primary">Create Webhook</button>
|
<button type="submit" class="btn-primary">Create Webhook</button>
|
||||||
<a href="/hooks" class="btn-secondary">Cancel</a>
|
<a href="/hooks" class="btn-secondary">Cancel</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user