Author SHA1 Message Date
sneak 383786b23a Load Alpine's CSP build so the UI's directives run (closes #371)
check / check (push) Waiting to run
The pages' Content-Security-Policy forbids eval, which the standard
Alpine.js build needs, so no directive ran: add forms showed open and
events never collapsed. 3p/ now holds the @alpinejs/csp 3.14.9 tarball
instead, and every directive in templates/ names a property or method
of a component registered in static/js/app.js, as that build requires.
The policy is unchanged.

A browser test in internal/server, built only with the browser tag,
loads the webhook page and the event log under the real headers.
make test-browser runs it in Docker, in a digest-pinned headless
browser image (Dockerfile.browser).

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

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

Model: opus-5-5
2026-10-02 03:02:28 +02:00
20 changed files with 635 additions and 54 deletions
+3
View File
@@ -7,6 +7,9 @@ version: "2"
run:
timeout: 5m
modules-download-mode: readonly
# Lint the browser test too (make test-browser builds it with this tag).
build-tags:
- browser
linters:
default: all
Binary file not shown.
Binary file not shown.
+29
View File
@@ -0,0 +1,29 @@
# Browser test image, built by script/test-browser (make test-browser). It
# runs the test in internal/server that loads the pages in a headless
# browser under the real Content-Security-Policy. That test is built only
# with the browser build tag, so make test leaves it out. Here the browser
# comes from a digest-pinned image, and if it is missing the test fails.
# golang:1.26.1-bookworm, 2026-03-17: the builder stage's image in Dockerfile.
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# The test binary embeds the templates and static files, so the browser
# stage needs nothing else. -p 4 keeps the compile's memory down, as in
# script/test.
RUN make assets && go test -c -p 4 -tags browser -o /browser.test ./internal/server
# chromedp/headless-shell:151.0.7922.109 (Debian trixie), 2026-08-11. The
# browser is on PATH as headless-shell, where the test's browser library
# looks for it.
FROM chromedp/headless-shell:151.0.7922.109@sha256:2d349b544a1ea6b5b5fd7c0fe99215ff662339c57407ee2e8c0a11af93516b04 AS browser
COPY --from=build /browser.test /browser.test
RUN /browser.test -test.v -test.timeout 90s -test.run '^TestAlpineRunsUnderTheSecurityPolicy$'
+4 -1
View File
@@ -1,4 +1,4 @@
.PHONY: bootstrap setup assets test lint fmt fmt-check check build run dev deps docker clean hooks css version
.PHONY: bootstrap setup assets test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css version
# Default target
.DEFAULT_GOAL := check
@@ -33,6 +33,9 @@ assets:
test:
@script/test
test-browser:
@script/test-browser
lint:
@script/lint
+38 -13
View File
@@ -19,8 +19,8 @@ before deploying one.
### Prerequisites
- Go 1.26.1+ (the version in `go.mod`)
- Docker (for linting, for the test stage of the CI gate, and for
containerized deployment)
- Docker (for linting, for the browser test, for the test stage of the
CI gate, and for containerized deployment)
golangci-lint is not a prerequisite and must not be installed on the
host: `script/bootstrap` does not install it, and `make lint` runs the
@@ -58,6 +58,7 @@ make fmt # Format code (gofmt + goimports)
make fmt-check # Fail if gofmt would change anything (writes nothing)
make lint # Run golangci-lint in Docker (Dockerfile.lint)
make test # Run tests with race detection
make test-browser # Run the browser test in Docker (Dockerfile.browser)
make check # test + lint + fmt-check (CI gate)
make build # Build binary to bin/webhooker (version-stamped)
make version # Print the version this checkout would stamp
@@ -1224,7 +1225,7 @@ What that means for an operator:
This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the
development workflow. Ten of the Makefile's seventeen targets are thin
development workflow. Eleven of the Makefile's eighteen targets are thin
shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and
`version` are inline commands with no script behind them, though `build`,
`run` and `dev` first run `script/assets`, and `build` and `version` both
@@ -1245,6 +1246,8 @@ We provide:
- `script/assets` — extract Alpine.js from its tarball in `3p/` (see
[Third-party browser assets](#third-party-browser-assets))
- `script/test` — run the test suite
- `script/test-browser` — run the browser test in Docker (see
[Third-party browser assets](#third-party-browser-assets))
- `script/lint` — run golangci-lint in Docker (see Linting below)
- `script/fmt` — format all code (writes)
- `script/fmt-check` — check formatting (read-only)
@@ -1265,9 +1268,29 @@ We provide:
## Third-party browser assets
The web UI serves one third-party script, Alpine.js. Its npm package tarball
is committed as `3p/alpinejs-3.14.9.tgz`, byte for byte as the npm registry
publishes it. It is a dependency, not this repo's build output, so
The web UI serves one third-party script, Alpine.js, in its CSP build: the npm
package `@alpinejs/csp`. The pages' Content-Security-Policy forbids eval, which
the standard `alpinejs` build needs to run the expressions written in the
markup. The CSP build runs no expressions, so every Alpine directive in
`templates/` only names a property or method of a component registered in
`static/js/app.js`: `x-data="collapsible"` and `@click="toggle"`, never
`x-data="{ open: false }"` or `@click="open = !open"`.
A browser test in `internal/server` loads the webhook page and the event log
under the real policy and checks that: both add forms stay hidden until Add is
clicked; choosing Slack in the add target form leaves the HTTP fields out of
what it submits; an event expands and collapses, and so do a delivery's
attempts inside it; and at phone width the menu button opens and closes the
mobile menu. It also fails if the browser reports a console warning or error,
an uncaught exception, or anything the policy refused. It is not part of
`make test`, `make check` or the image build (its file is built only with the
`browser` build tag). Run it with `make test-browser` after changing
`templates/` or `static/js/`: that builds `Dockerfile.browser`, which runs the
test in a digest-pinned headless browser image, so the host needs no browser.
The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for
byte as the npm registry publishes it. It is a dependency, not this repo's
build output, so
`REPO_POLICIES.md`'s rule against committed build artifacts does not apply.
The directory is `3p/` rather than `vendor/` because Go treats a root
`vendor/` directory as its module vendor directory.
@@ -1280,10 +1303,11 @@ nothing downloads Alpine.js. The extracted file is not committed, and
`.dockerignore` keeps any host copy out of the build context.
To move to a new version: download
`https://registry.npmjs.org/alpinejs/-/alpinejs-<version>.tgz`, check it
`https://registry.npmjs.org/@alpinejs/csp/-/csp-<version>.tgz`, check it
against the `dist.integrity` hash listed at
`https://registry.npmjs.org/alpinejs/<version>`, replace the tarball in `3p/`
with it, update its file name in `script/assets`, and run `make check`.
`https://registry.npmjs.org/@alpinejs/csp/<version>`, replace the tarball in
`3p/` with it as `alpinejs-csp-<version>.tgz`, update its file name in
`script/assets`, and run `make check`.
## Rationale
@@ -2764,7 +2788,7 @@ imports. The entry point is `cmd/webhooker/main.go`.
```
webhooker/
├── 3p/
│ └── alpinejs-3.14.9.tgz # Alpine.js npm package, extracted by make assets
│ └── alpinejs-csp-3.14.9.tgz # Alpine.js CSP build npm package, extracted by make assets
├── cmd/webhooker/
│ └── main.go # Entry point: subcommand dispatch; no args locks DATA_DIR and wires fx
├── internal/
@@ -2857,13 +2881,14 @@ webhooker/
│ ├── css/input.css # Tailwind input, source for tailwind.css (make css)
│ ├── css/tailwind.css # Generated stylesheet the pages load
│ ├── css/style.css # Older hand-written stylesheet, no longer loaded
│ ├── js/app.js # Progressive-enhancement copy-to-clipboard
│ └── js/alpine.min.js # Alpine.js, extracted from 3p/ by make assets, not committed
│ ├── js/app.js # Copy-to-clipboard, and the Alpine.js components
│ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed
├── templates/ # Go HTML templates (base, login, sources, etc.)
├── script/ # Scripts to Rule Them All entrypoints
├── Dockerfile # Three stages: lint, test+build, Alpine runtime
├── Dockerfile.lint # Lint-only image built by script/lint
├── Makefile # 10 of 17 targets shim script/; 7 are inline
├── Dockerfile.browser # Browser test image built by script/test-browser
├── Makefile # 11 of 18 targets shim script/; 7 are inline
├── go.mod / go.sum
└── .golangci.yml # Linter configuration
```
+8 -1
View File
@@ -4,6 +4,8 @@ go 1.26.1
require (
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f
github.com/chromedp/chromedp v0.16.0
github.com/dustin/go-humanize v1.0.1
github.com/getsentry/sentry-go v0.25.0
github.com/go-chi/chi v1.5.5
@@ -29,7 +31,12 @@ require (
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -50,7 +57,7 @@ require (
go.uber.org/zap v1.23.0 // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
google.golang.org/protobuf v1.31.0 // indirect
+20 -2
View File
@@ -6,6 +6,12 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f h1:8PK9FM4bE0C8GMoWBW5lVsef3U7sPICjDg6JqngyYhk=
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f/go.mod h1:3v4FIp5njIUyPDvqXsxEOxnB34lijG0up98/5kM1KaE=
github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk=
github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -23,6 +29,14 @@ github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 h1:UADEEmDKgfXbtnGJZ97beY5XLo9ZechG1nlU4KnRrkE=
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
@@ -55,12 +69,16 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -111,8 +129,8 @@ golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
+12
View File
@@ -5,6 +5,7 @@ import (
"io"
"log/slog"
"os"
"testing"
"time"
"go.uber.org/fx"
@@ -79,3 +80,14 @@ func (d *Database) ExportSetBannerOut(w io.Writer) {
func DummyPasswordHashForTest() string {
return dummyPasswordHash()
}
// HashAtShippedCostForTest makes HashPassword hash at the shipped
// memory cost until t ends. t must not run in parallel with other
// tests, which would hash at that cost alongside it.
func HashAtShippedCostForTest(t *testing.T) {
t.Helper()
hashAtShippedCostInTest = true
t.Cleanup(func() { hashAtShippedCostInTest = false })
}
+22 -1
View File
@@ -9,6 +9,7 @@ import (
"math/big"
"strings"
"sync"
"testing"
"golang.org/x/crypto/argon2"
)
@@ -63,10 +64,30 @@ func DefaultPasswordConfig() *PasswordConfig {
}
}
// HashPassword generates an Argon2id hash of the password
// testArgon2Memory is the Argon2id memory cost, in KiB, that a test
// binary hashes with: 1 MB instead of the shipped 64 MB. Every test
// that starts a database hashes the bootstrap admin password, dozens
// of them run in parallel, and under the race detector each 64 MB hash
// holds about 150 MB. VerifyPassword reads the cost from the hash it
// checks, so verification follows.
const testArgon2Memory = 1024
// hashAtShippedCostInTest makes a test binary hash at the shipped
// memory cost. Only TestHashPassword_ShippedParameters sets it.
//
//nolint:gochecknoglobals // set by one test, see above
var hashAtShippedCostInTest bool
// HashPassword generates an Argon2id hash of the password. A binary
// built by go test hashes at testArgon2Memory; one built by go build
// always hashes at the defaults.
func HashPassword(password string) (string, error) {
config := DefaultPasswordConfig()
if testing.Testing() && !hashAtShippedCostInTest {
config.Memory = testArgon2Memory
}
// Generate a salt
salt := make([]byte, config.SaltLen)
+33
View File
@@ -192,6 +192,39 @@ func TestHashPasswordUniqueness(t *testing.T) {
}
}
// TestHashPassword_ShippedParameters hashes and verifies through
// HashPassword at the shipped Argon2id parameters. Every other test
// hashes at the lower memory cost a test binary uses, so this is the
// one that keeps production hashing covered. One hash and one
// verification: each costs 64 MB.
//
//nolint:paralleltest // changes the hashing cost for the whole binary
func TestHashPassword_ShippedParameters(t *testing.T) {
database.HashAtShippedCostForTest(t)
password := "correct horse battery staple"
hash, err := database.HashPassword(password)
if err != nil {
t.Fatalf("hashing with the shipped parameters: %v", err)
}
const shipped = "$argon2id$v=19$m=65536,t=1,p=4$"
if !strings.HasPrefix(hash, shipped) {
t.Errorf("hash = %q, want prefix %q", hash, shipped)
}
valid, err := database.VerifyPassword(password, hash)
if err != nil {
t.Fatalf("VerifyPassword() error = %v", err)
}
if !valid {
t.Error("VerifyPassword() returned false for correct password")
}
}
// TestVerifyDummyPassword_DoesRealWork covers the anti-enumeration
// path. Login charges an unknown username a verification against a
// dummy hash so that a nonexistent account is not answered in
+1 -1
View File
@@ -140,7 +140,7 @@ func (n *noopEvictor) EvictWebhook(string) {}
// and the database, exactly as internal/handlers builds them.
//
// One application per test function, not per case: every start that
// finds no account seeds one at 64 MB of Argon2id, and this package's
// finds no account seeds one with an Argon2id hash, and this package's
// budget is not the place to spend that repeatedly.
func newServerApp(
t *testing.T, dir string,
+342
View File
@@ -0,0 +1,342 @@
//go:build browser
// This test needs a headless browser, so it is built only with the
// browser build tag: `make test` leaves it out, and `make test-browser`
// runs it in the browser image that Dockerfile.browser pins.
package server_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/chromedp/cdproto/log"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
)
const (
// browserTimeout bounds everything one test does in the browser.
browserTimeout = 60 * time.Second
// settleTimeout bounds the wait for an element to show or hide.
settleTimeout = 5 * time.Second
// The window size of a phone, narrow enough that the pages show
// the mobile menu button instead of the navigation links.
phoneWidth = 390
phoneHeight = 844
)
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
// event log in a headless browser, served by the real router and so
// under the real Content-Security-Policy, and checks that the pages'
// Alpine.js directives work.
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
t.Parallel()
ctx, problems := startBrowser(t)
env := newTestEnv(t)
srv := httptest.NewServer(env.router)
t.Cleanup(srv.Close)
userID, _ := env.seedUser(t, "browser", "browser-password")
webhook := env.seedWebhook(t, userID)
event := env.seedEvent(t, webhook.ID, `{"hello":"browser"}`)
target := env.seedTarget(t, webhook.ID)
dlv := env.seedFailedDelivery(t, webhook.ID, event.ID, target.ID)
webhookDB, err := env.dbMgr.GetDB(webhook.ID)
require.NoError(t, err)
require.NoError(t, webhookDB.Omit(clause.Associations).Create(
&database.DeliveryResult{
DeliveryID: dlv.ID,
AttemptNum: 1,
StatusCode: http.StatusBadGateway,
},
).Error)
require.NoError(t, chromedp.Run(
ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")),
))
page := srv.URL + "/source/" + webhook.ID
checkAddForms(ctx, t, page)
checkTargetType(ctx, t)
checkEventLog(ctx, t, page+"/logs", event.ID, target.Name)
checkMobileMenu(ctx, t, page)
assert.Empty(t, problems(), "the browser reported problems")
}
// startBrowser starts a headless browser for one test. It returns the
// context that drives it, and a function listing what the browser
// reported going wrong on its pages: console warnings and errors,
// which is how Alpine.js reports an expression it cannot run; uncaught
// exceptions; and every entry in the browser's own security log, which
// is where it reports each script, style, image or request the
// Content-Security-Policy refused.
//
// The browser library finds the browser on PATH. Without one the first
// chromedp.Run fails, and with it the test.
func startBrowser(t *testing.T) (context.Context, func() []string) {
t.Helper()
allocCtx, cancelAlloc := chromedp.NewExecAllocator(
t.Context(),
append(
chromedp.DefaultExecAllocatorOptions[:],
// Dockerfile.browser runs the test as root, where the
// browser's sandbox cannot start.
chromedp.NoSandbox,
)...,
)
t.Cleanup(cancelAlloc)
ctx, cancel := chromedp.NewContext(allocCtx)
t.Cleanup(cancel)
ctx, cancelTimeout := context.WithTimeout(ctx, browserTimeout)
t.Cleanup(cancelTimeout)
var (
mu sync.Mutex
problems []string
)
chromedp.ListenTarget(ctx, func(ev any) {
var problem string
switch ev := ev.(type) {
case *runtime.EventConsoleAPICalled:
if ev.Type != runtime.APITypeWarning &&
ev.Type != runtime.APITypeError {
return
}
args := make([]string, 0, len(ev.Args))
for _, arg := range ev.Args {
args = append(args, string(arg.Value))
}
problem = strings.Join(args, " ")
case *runtime.EventExceptionThrown:
problem = ev.ExceptionDetails.Error()
case *log.EventEntryAdded:
if ev.Entry.Source != log.SourceSecurity {
return
}
problem = ev.Entry.Text
default:
return
}
mu.Lock()
defer mu.Unlock()
problems = append(problems, problem)
})
return ctx, func() []string {
mu.Lock()
defer mu.Unlock()
return slices.Clone(problems)
}
}
// setCookies gives the browser the cookies for the server at base.
func setCookies(base string, cookies []*http.Cookie) chromedp.ActionFunc {
return chromedp.ActionFunc(func(ctx context.Context) error {
for _, c := range cookies {
err := network.SetCookie(c.Name, c.Value).
WithURL(base).
Do(ctx)
if err != nil {
return fmt.Errorf("set cookie %s: %w", c.Name, err)
}
}
return nil
})
}
// loadPage opens url and waits for Alpine.js to start, which it does
// by removing every x-cloak attribute. Until then x-cloak hides the
// elements Alpine would hide, so a check made earlier proves nothing.
func loadPage(url string) chromedp.Tasks {
return chromedp.Tasks{
chromedp.Navigate(url),
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
}
}
// shown waits up to settleTimeout for the elements matching a CSS
// selector or an XPath expression to be rendered, and reports whether
// they were. The wait is needed because Alpine.js shows an element on
// the next animation frame, not at once.
func shown(ctx context.Context, selector string) bool {
ctx, cancel := context.WithTimeout(ctx, settleTimeout)
defer cancel()
return chromedp.Run(
ctx, chromedp.WaitVisible(selector, chromedp.BySearch),
) == nil
}
// hidden is shown's opposite: it waits for the elements to be hidden.
func hidden(ctx context.Context, selector string) bool {
ctx, cancel := context.WithTimeout(ctx, settleTimeout)
defer cancel()
return chromedp.Run(
ctx, chromedp.WaitNotVisible(selector, chromedp.BySearch),
) == nil
}
// click clicks the element matching an XPath expression.
func click(ctx context.Context, t *testing.T, xpath string) {
t.Helper()
require.NoError(t, chromedp.Run(
ctx, chromedp.Click(xpath, chromedp.BySearch),
))
}
// checkAddForms loads a webhook page and checks that each section's add
// form stays hidden until the Add button beside its heading is clicked.
func checkAddForms(ctx context.Context, t *testing.T, url string) {
t.Helper()
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
sections := []struct{ heading, form string }{
{"Entrypoints", `form[action$="/entrypoints"]`},
{"Targets", `form[action$="/targets"]`},
}
for _, s := range sections {
assert.Truef(
t, hidden(ctx, s.form),
"%s: the add form shows before Add is clicked", s.heading,
)
click(ctx, t, `//h2[text()="`+s.heading+
`"]/following-sibling::button`)
assert.Truef(
t, shown(ctx, s.form),
"%s: the add form stays hidden when Add is clicked", s.heading,
)
}
}
// checkTargetType chooses Slack in the open add target form and checks
// what the form would then submit: one url field, the Slack one, and
// not the HTTP url, headers or timeout, which are hidden and disabled.
func checkTargetType(ctx context.Context, t *testing.T) {
t.Helper()
const (
chooseSlack = `(() => {
const type = document.querySelector('select[name="type"]');
type.value = "slack";
type.dispatchEvent(new Event("change"));
})()`
submitted = `[...new FormData(
document.querySelector('form[action$="/targets"]')).keys()]`
)
var fields []string
require.NoError(t, chromedp.Run(
ctx,
chromedp.Evaluate(chooseSlack, nil),
chromedp.Evaluate(submitted, &fields),
))
assert.Equal(
t,
[]string{"csrf_token", "name", "type", "max_retries", "url"},
fields,
"with Slack chosen, the HTTP fields must not be submitted",
)
}
// checkEventLog loads the event log and checks that clicking an event's
// row expands it, that in there clicking its delivery shows the
// delivery's attempts and clicking again hides them, and that clicking
// the event's row again collapses it.
func checkEventLog(
ctx context.Context, t *testing.T, url, eventID, targetName string,
) {
t.Helper()
// The event's row shows its ID, and its Resubmit form is in the part
// that expands. The delivery's row there shows the target's name.
eventRow := `//span[text()="` + eventID + `"]`
expanded := `form[action$="/resubmit"]`
deliveryRow := `//span[text()="` + targetName + `"]`
attempt := `//span[text()="Attempt 1"]`
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
assert.True(t, hidden(ctx, expanded), "the event starts expanded")
click(ctx, t, eventRow)
assert.True(t, shown(ctx, expanded), "clicking the event does not expand it")
assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown")
click(ctx, t, deliveryRow)
assert.True(t, shown(ctx, attempt),
"clicking the delivery does not show its attempts")
click(ctx, t, deliveryRow)
assert.True(t, hidden(ctx, attempt),
"clicking the delivery again does not hide its attempts")
click(ctx, t, eventRow)
assert.True(t, hidden(ctx, expanded),
"clicking the event again does not collapse it")
}
// checkMobileMenu loads a page in a phone-sized window and checks that
// the menu button opens and closes the mobile menu.
func checkMobileMenu(ctx context.Context, t *testing.T, url string) {
t.Helper()
// The menu button is the only button directly in the navigation
// bar's top row. Profile is a link only the mobile menu has.
button := `//nav/div/button`
menu := `//nav//a[text()="Profile"]`
require.NoError(t, chromedp.Run(
ctx,
chromedp.EmulateViewport(phoneWidth, phoneHeight),
loadPage(url),
))
assert.True(t, hidden(ctx, menu), "the mobile menu starts open")
click(ctx, t, button)
assert.True(t, shown(ctx, menu), "the menu button does not open the menu")
click(ctx, t, button)
assert.True(t, hidden(ctx, menu), "the menu button does not close the menu")
}
+5 -4
View File
@@ -1,15 +1,16 @@
#!/bin/sh
# script/assets: extract Alpine.js from its npm package tarball, committed
# in 3p/, to static/js/alpine.min.js, where go:embed reads it. The
# extracted file is not committed. script/test, make build and make dev run
# this first.
# in 3p/, to static/js/alpine.min.js, where go:embed reads it. The package
# is @alpinejs/csp, Alpine's build for pages whose Content-Security-Policy
# forbids eval. The extracted file is not committed. script/test, make
# build and make dev run this first.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
tar -xzOf 3p/alpinejs-3.14.9.tgz package/dist/cdn.min.js \
tar -xzOf 3p/alpinejs-csp-3.14.9.tgz package/dist/cdn.min.js \
>static/js/alpine.min.js
}
+6 -1
View File
@@ -22,6 +22,11 @@
# The one figure above 90s is GOMAXPROCS 1, a synthetic core floor rather than
# a condition CI runs under. If a CPU-limited runner ever puts a real run near
# 67s, that is the datum to revisit the org figure with.
#
# -p 4 -parallel 8 keep the run under 2 GB of memory: at most four test
# binaries build or run at once, each with at most eight parallel tests. Under
# -race every test binary and every link costs a few hundred MB, so the
# defaults (one per core) add up to several GB on a many-core host.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
@@ -29,7 +34,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
"$ROOT/script/assets"
go test -v -race -timeout 90s ./...
go test -v -race -p 4 -parallel 8 -timeout 90s ./...
}
main "$@"
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
# script/test-browser: run the browser test in internal/server. It runs in
# Docker: Dockerfile.browser builds the test and runs it in a digest-pinned
# headless browser image, so the host needs no browser.
#
# --no-cache-filter=browser runs the test again even when nothing changed;
# it must name the stage in Dockerfile.browser that runs it.
# --output=type=cacheonly leaves no image behind to clean up.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
docker build \
-f Dockerfile.browser \
--no-cache-filter=browser \
--progress=plain \
--output=type=cacheonly \
.
}
main "$@"
+59
View File
@@ -57,3 +57,62 @@
init();
}
})();
// Alpine.js components.
//
// The pages' Content-Security-Policy forbids eval, so the UI loads
// Alpine's CSP build, which cannot run expressions written in the
// markup: a directive in templates/ may only name a property or method,
// and each x-data names a component registered here. This script runs
// before Alpine, whose script tag is deferred, so this listener is in
// place when Alpine starts.
document.addEventListener("alpine:init", function () {
"use strict";
// Something a click shows and hides: the mobile menu, an add form,
// an event in the event log, a delivery's attempts.
window.Alpine.data("collapsible", function () {
return {
open: false,
toggle() {
this.open = !this.open;
},
get closed() {
return !this.open;
},
// Turns a downward caret up while open.
get caretClass() {
return { "rotate-180": this.open };
},
};
});
// The add target form. Only the chosen type's fields show, and the
// others are disabled so that the form does not submit them.
window.Alpine.data("targetForm", function () {
return {
targetType: "http",
chooseType(event) {
this.targetType = event.target.value;
},
get isHttp() {
return this.targetType === "http";
},
get isSlack() {
return this.targetType === "slack";
},
get isDatabase() {
return this.targetType === "database";
},
get notHttp() {
return !this.isHttp;
},
get notSlack() {
return !this.isSlack;
},
get notDatabase() {
return !this.isDatabase;
},
};
});
});
+3 -3
View File
@@ -1,14 +1,14 @@
{{define "navbar"}}
<nav class="app-bar" x-data="{ open: false }">
<nav class="app-bar" x-data="collapsible">
<div class="max-w-6xl mx-auto flex justify-between items-center">
<div class="flex items-center gap-3">
<a href="/" class="text-xl font-medium text-gray-900 hover:text-primary-600 transition-colors">Webhooker</a>
</div>
<!-- Mobile menu button -->
<button @click="open = !open" class="md:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100">
<button @click="toggle" class="md:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path x-show="!open" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
<path x-show="closed" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
<path x-show="open" x-cloak stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
+20 -20
View File
@@ -3,7 +3,7 @@
{{define "title"}}{{.Webhook.Name}} - Webhooker{{end}}
{{define "content"}}
<div class="max-w-6xl mx-auto px-6 py-8" x-data="{ showAddEntrypoint: false, showAddTarget: false }">
<div class="max-w-6xl mx-auto px-6 py-8">
<div class="mb-6">
<a href="/sources" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to webhooks</a>
<div class="flex justify-between items-center mt-2">
@@ -26,10 +26,10 @@
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Entrypoints -->
<div class="card">
<div class="card" x-data="collapsible">
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
<h2 class="text-lg font-medium text-gray-900">Entrypoints</h2>
<button @click="showAddEntrypoint = !showAddEntrypoint" class="btn-text text-sm">
<button @click="toggle" class="btn-text text-sm">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
@@ -38,7 +38,7 @@
</div>
<!-- Add entrypoint form -->
<div x-show="showAddEntrypoint" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
<div x-show="open" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
<form method="POST" action="/source/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1">
@@ -85,10 +85,10 @@
</div>
<!-- Targets -->
<div class="card">
<div class="card" x-data="collapsible">
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
<h2 class="text-lg font-medium text-gray-900">Targets</h2>
<button @click="showAddTarget = !showAddTarget" class="btn-text text-sm">
<button @click="toggle" class="btn-text text-sm">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
@@ -97,42 +97,42 @@
</div>
<!-- Add target form -->
<div x-show="showAddTarget" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
<form method="POST" action="/source/{{.Webhook.ID}}/targets" x-data="{ targetType: 'http' }" class="space-y-3">
<div x-show="open" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
<form method="POST" action="/source/{{.Webhook.ID}}/targets" x-data="targetForm" class="space-y-3">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="flex gap-2">
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
<select name="type" x-model="targetType" class="input text-sm w-32">
<select name="type" @change="chooseType" class="input text-sm w-32">
<option value="http">HTTP</option>
<option value="slack">Slack</option>
<option value="database">Database</option>
<option value="log">Log</option>
</select>
</div>
<div x-show="targetType === 'http'">
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="targetType !== 'http'" class="input text-sm">
<div x-show="isHttp">
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="notHttp" class="input text-sm">
</div>
<div x-show="targetType === 'http'">
<textarea name="headers" rows="3" placeholder="Authorization: Bearer ..." :disabled="targetType !== 'http'" class="input text-sm"></textarea>
<div x-show="isHttp">
<textarea name="headers" rows="3" placeholder="Authorization: Bearer ..." :disabled="notHttp" class="input text-sm"></textarea>
<p class="text-xs text-gray-500 mt-1">Optional request headers, one <code>Name: value</code> per line, sent with every delivery.</p>
</div>
<div x-show="targetType === 'http'" class="flex gap-2 items-center">
<div x-show="isHttp" class="flex gap-2 items-center">
<label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label>
<input type="number" name="timeout" min="0" max="300" :disabled="targetType !== 'http'" class="input text-sm w-24">
<input type="number" name="timeout" min="0" max="300" :disabled="notHttp" class="input text-sm w-24">
</div>
<div x-show="targetType === 'http'">
<div x-show="isHttp">
<div class="flex gap-2 items-center">
<label class="text-sm text-gray-700">Max retries:</label>
<input type="number" name="max_retries" value="0" min="0" max="20" class="input text-sm w-24">
</div>
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
</div>
<div x-show="targetType === 'slack'">
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="targetType !== 'slack'" class="input text-sm">
<div x-show="isSlack">
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="notSlack" class="input text-sm">
<p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Payloads are pretty-printed in code blocks.</p>
</div>
<div x-show="targetType === 'database'">
<input type="text" name="expiry" placeholder="never" :disabled="targetType !== 'database'" class="input text-sm">
<div x-show="isDatabase">
<input type="text" name="expiry" placeholder="never" :disabled="notDatabase" class="input text-sm">
<p class="text-xs text-gray-500 mt-1">Archive expiry: "never" (default) keeps rows forever, or a duration like "720h" prunes older rows.</p>
</div>
<button type="submit" class="btn-primary text-sm">Add Target</button>
+7 -7
View File
@@ -23,8 +23,8 @@
<div class="card">
<div class="divide-y divide-gray-100">
{{range .Events}}
<div class="p-4" x-data="{ open: false }">
<div class="flex items-center justify-between cursor-pointer" @click="open = !open">
<div class="p-4" x-data="collapsible">
<div class="flex items-center justify-between cursor-pointer" @click="toggle">
<div class="flex items-center gap-3">
<span class="badge-info">{{.Method}}</span>
<span class="text-sm font-mono text-gray-700">{{.ID}}</span>
@@ -43,7 +43,7 @@
</span>
{{end}}
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</span>
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="{ 'rotate-180': open }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
@@ -71,8 +71,8 @@
<h3 class="text-xs font-medium uppercase tracking-wide text-gray-500">Deliveries</h3>
<div class="mt-2 divide-y divide-gray-200">
{{range .Deliveries}}
<div class="py-2" x-data="{ attempts: false }">
<div class="flex items-center justify-between cursor-pointer" @click="attempts = !attempts">
<div class="py-2" x-data="collapsible">
<div class="flex items-center justify-between cursor-pointer" @click="toggle">
<div class="flex items-center gap-3">
<span class="text-sm text-gray-700">{{.Target.DisplayName}}</span>
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{.Status}}</span>
@@ -86,13 +86,13 @@
</form>
{{end}}
<span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span>
<svg class="w-3 h-3 text-gray-400 transition-transform" :class="{ 'rotate-180': attempts }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-3 h-3 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
</div>
<div x-show="attempts" x-cloak class="mt-2 space-y-2">
<div x-show="open" x-cloak class="mt-2 space-y-2">
{{if .AttemptsOmitted}}
<p class="text-xs text-gray-500">{{.AttemptsOmitted}} attempt{{if ne .AttemptsOmitted 1}}s{{end}} omitted between the first and last shown.</p>
{{end}}