diff --git a/.golangci.yml b/.golangci.yml index 26b1610..60b9fa6 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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 diff --git a/3p/alpinejs-3.14.9.tgz b/3p/alpinejs-3.14.9.tgz deleted file mode 100644 index 4d31381..0000000 Binary files a/3p/alpinejs-3.14.9.tgz and /dev/null differ diff --git a/3p/alpinejs-csp-3.14.9.tgz b/3p/alpinejs-csp-3.14.9.tgz new file mode 100644 index 0000000..f35c940 Binary files /dev/null and b/3p/alpinejs-csp-3.14.9.tgz differ diff --git a/Dockerfile.browser b/Dockerfile.browser new file mode 100644 index 0000000..9ead22e --- /dev/null +++ b/Dockerfile.browser @@ -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$' diff --git a/Makefile b/Makefile index b5438f8..6d55a2e 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 8173e92..fe7a04e 100644 --- a/README.md +++ b/README.md @@ -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-.tgz`, check it +`https://registry.npmjs.org/@alpinejs/csp/-/csp-.tgz`, check it against the `dist.integrity` hash listed at -`https://registry.npmjs.org/alpinejs/`, replace the tarball in `3p/` -with it, update its file name in `script/assets`, and run `make check`. +`https://registry.npmjs.org/@alpinejs/csp/`, replace the tarball in +`3p/` with it as `alpinejs-csp-.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 ``` diff --git a/go.mod b/go.mod index ba81406..684de3d 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index d2d615e..8c96a5e 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/server/alpine_browser_test.go b/internal/server/alpine_browser_test.go new file mode 100644 index 0000000..4c287df --- /dev/null +++ b/internal/server/alpine_browser_test.go @@ -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") +} diff --git a/script/assets b/script/assets index 69db60d..54e4185 100755 --- a/script/assets +++ b/script/assets @@ -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 } diff --git a/script/test-browser b/script/test-browser new file mode 100755 index 0000000..08bb621 --- /dev/null +++ b/script/test-browser @@ -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 "$@" diff --git a/static/js/app.js b/static/js/app.js index ee4f7fd..b94b12f 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -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; + }, + }; + }); +}); diff --git a/templates/navbar.html b/templates/navbar.html index edd5573..90840fb 100644 --- a/templates/navbar.html +++ b/templates/navbar.html @@ -1,14 +1,14 @@ {{define "navbar"}} -