rastrillo / aviso Public

Add the browser wiring test and the browser gate target

Proves the module, the worker import and the real handlers agree in
Chromium, through rastrillo's harness, with a declared subscription
double: Chromium's own push subscription cannot be pointed at a test
service, and a test that pretended otherwise would prove less than it
claimed. The double mints real P-256 keys with WebCrypto because
Subscribe validates them; everything else — module loading, worker
registration and activation, cookies, fetch, the handlers — is real.
The `browser` target joins `ci` and fails rather than skips without a
browser, rastrillo's own rule.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev feb58dc48c24a3f3e0b33894d59127b4a6e51309 parent bb867e2
5 files changed, +219 −2
  • .amadan/ci.d/50-browser +3 −0
  • Makefile +10 −2
  • browser_test.go +181 −0
  • go.mod +7 −0
  • go.sum +18 −0
diff --git a/.amadan/ci.d/50-browser b/.amadan/ci.d/50-browser
new file mode 100755
index 0000000..1f3bc2a
--- /dev/null
+++ b/.amadan/ci.d/50-browser
@@ -0,0 +1,3 @@
+#!/bin/sh
+set -e
+exec make browser
diff --git a/Makefile b/Makefile
index 9276d6f..6636e17 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: vet fmt-check test race ci
+.PHONY: vet fmt-check test race browser ci
# ci is the one gate: what a runner executes and what you run before
# pushing are the same definition (amadan's own rule — CI steps
@@ -8,7 +8,7 @@
# inside `go test`, skipping when node is absent, so a runner without
# node still reports the Go half honestly instead of failing a step
# it cannot run.
-ci: vet fmt-check test race
+ci: vet fmt-check test race browser
# The example is its own module with a replace back here, so ./...
# does not reach it; each target visits it explicitly.
@@ -30,3 +30,11 @@ test:
# this target sets CGO_ENABLED for its own command only.
race:
CGO_ENABLED=1 go test ./... -race -count=1
+
+# The browser drive: the module, the worker import and the handlers
+# agreeing in a real Chromium, via rastrillo's harness. It fails, not
+# skips, without a browser — rastrillo's rule (RASTRILLO_BROWSER_OPTIONAL
+# stays unset): a skip is not a pass, and a machine that loses its
+# browser should say so loudly.
+browser:
+ go test -tags browser -run TestBrowser -count=1 .
diff --git a/browser_test.go b/browser_test.go
new file mode 100644
index 0000000..eef30e8
--- /dev/null
+++ b/browser_test.go
@@ -0,0 +1,181 @@
+//go:build browser
+
+// The browser-side proof of this package, in a real Chromium: the
+// page imports js/push.mjs exactly as an app serves it, registers a
+// worker that importScripts js/aviso-sw.js, and enrols through the
+// real Subscribe handler — with a declared subscription double
+// standing in for the browser's push service. Chromium's own
+// subscription path cannot be pointed at a test service, and a test
+// that pretended otherwise would prove less than it claimed; this one
+// proves the module, the worker import and the handlers agree.
+package aviso_test
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/chromedp/cdproto/runtime"
+ "github.com/chromedp/chromedp"
+
+ "amadan.net/rastrillo/rastrillo/harness"
+ "amadan.net/rastrillo/rastrillo/sessions"
+
+ "amadan.net/rastrillo/aviso"
+)
+
+const driverPage = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>aviso fixture</title><script type="module" src="/driver.mjs"></script></head><body><p id="page">aviso fixture</p></body></html>`
+
+// The worker: what SKILL.md tells an app to write, minus listeners
+// the drive never fires.
+const workerScript = `importScripts("/aviso-sw.js");
+self.addEventListener("push", (e) => e.waitUntil(AvisoSW.handlePush(e, { fallback: () => ({ title: "fixture" }) })));`
+
+// The driver stubs exactly two platform surfaces — the permission
+// prompt, and pushManager on the registration instance — and leaves
+// everything else real: module loading, worker registration and
+// activation, fetch, cookies, the handlers. The double mints real
+// P-256 keys with WebCrypto because Subscribe validates them.
+const driverScript = `import { enable, reconcile, disable, status, capabilities } from "/push.mjs";
+
+const b64url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+
+async function makeDouble() {
+ let current = null;
+ return {
+ async getSubscription() { return current; },
+ async subscribe(opts) {
+ if (!opts.userVisibleOnly) throw new Error("userVisibleOnly required");
+ const kp = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
+ const p256dh = b64url(await crypto.subtle.exportKey("raw", kp.publicKey));
+ const auth = b64url(crypto.getRandomValues(new Uint8Array(16)));
+ const endpoint = "https://push.example/double/" + b64url(crypto.getRandomValues(new Uint8Array(8)));
+ current = {
+ endpoint,
+ options: { applicationServerKey: opts.applicationServerKey },
+ toJSON() { return { endpoint, expirationTime: null, keys: { p256dh, auth } }; },
+ async unsubscribe() { current = null; return true; },
+ };
+ return current;
+ },
+ };
+}
+
+const post = (path) => (body) => fetch(path, { method: "POST", credentials: "same-origin",
+ headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
+const save = post("/aviso/subscribe"), remove = post("/aviso/unsubscribe");
+
+await navigator.serviceWorker.register("/sw.js");
+const registration = await navigator.serviceWorker.ready;
+Object.defineProperty(registration, "pushManager", { value: await makeDouble() });
+Notification.requestPermission = async () => "granted";
+Object.defineProperty(Notification, "permission", { get: () => "granted" });
+const { publicKey } = await (await fetch("/aviso/public-key")).json();
+
+window.driver = {
+ caps: () => JSON.stringify(capabilities()),
+ workerActive: () => !!registration.active,
+ enable: async () => { const s = await enable({ registration, publicKey, save }); return s ? s.endpoint : ""; },
+ reconcile: async () => { const s = await reconcile({ registration, publicKey, save }); return s ? s.endpoint : ""; },
+ status: async () => (await status(registration)).permission,
+ disable: async () => { await disable({ registration, remove }); return "ok"; },
+};
+const ready = document.createElement("p");
+ready.id = "ready";
+document.body.append(ready);
+`
+
+type fixture struct {
+ svc *aviso.Service
+}
+
+func (f *fixture) handler(origin string) http.Handler {
+ mux := http.NewServeMux()
+ serve := func(ct string, b []byte) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", ct)
+ w.Header().Set("Cache-Control", "no-cache")
+ _, _ = w.Write(b)
+ }
+ }
+ mux.HandleFunc("GET /{$}", serve("text/html; charset=utf-8", []byte(driverPage)))
+ mux.HandleFunc("GET /driver.mjs", serve("text/javascript", []byte(driverScript)))
+ mux.HandleFunc("GET /push.mjs", serve("text/javascript", aviso.JS()))
+ mux.HandleFunc("GET /aviso-sw.js", serve("text/javascript", aviso.WorkerJS()))
+ mux.HandleFunc("GET /sw.js", serve("text/javascript", []byte(workerScript)))
+ mux.HandleFunc("GET /aviso/public-key", f.svc.PublicKey)
+ mux.HandleFunc("POST /aviso/subscribe", f.svc.Subscribe)
+ mux.HandleFunc("POST /aviso/unsubscribe", f.svc.Unsubscribe)
+ // The drive is signed in as "drive"; a real app's session
+ // middleware sits here.
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mux.ServeHTTP(w, sessions.WithSession(r, sessions.Session{Subject: "drive"}))
+ })
+}
+
+func evalString(r *harness.Rig, expr string) string {
+ var out string
+ r.Run(chromedp.Evaluate(expr, &out, func(p *runtime.EvaluateParams) *runtime.EvaluateParams {
+ return p.WithAwaitPromise(true)
+ }))
+ return out
+}
+
+func TestBrowserEnrolmentRoundTripsThroughTheHandlers(t *testing.T) {
+ key, _ := aviso.GenerateKey()
+ db := openDB(t)
+ var f fixture
+ rig := harness.New(t, func(origin string) http.Handler {
+ svc, err := aviso.New(aviso.Config{DB: db, PrivateKey: key, Contact: "mailto:ops@example.test", Origin: origin})
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.svc = svc
+ return f.handler(origin)
+ })
+ rig.Run(chromedp.Navigate(rig.Origin + "/"))
+ rig.Screen("#ready", "fixture booted: module imported, worker active, double installed")
+
+ if got := evalString(rig, "driver.workerActive() ? 'active' : 'inactive'"); got != "active" {
+ t.Fatalf("worker %s after ready", got)
+ }
+ var caps struct{ ServiceWorker, Push, Notifications bool }
+ if err := json.Unmarshal([]byte(evalString(rig, "driver.caps()")), &caps); err != nil || !caps.ServiceWorker || !caps.Notifications {
+ t.Fatalf("capabilities: %+v (%v)", caps, err)
+ }
+
+ ctx := context.Background()
+ if rows, _ := f.svc.List(ctx, "drive"); len(rows) != 0 {
+ t.Fatal("rows before enable")
+ }
+ endpoint := evalString(rig, "driver.enable()")
+ if endpoint == "" {
+ t.Fatal("enable resolved null")
+ }
+ rows, err := f.svc.List(ctx, "drive")
+ if err != nil || len(rows) != 1 || rows[0].Endpoint != endpoint || rows[0].Revision != 1 {
+ t.Fatalf("after enable: %+v (%v)", rows, err)
+ }
+
+ // A second load's reconcile re-saves the same subscription and
+ // bumps the revision, without a second row.
+ if got := evalString(rig, "driver.reconcile()"); got != endpoint {
+ t.Fatalf("reconcile returned %q, want %q", got, endpoint)
+ }
+ rows, _ = f.svc.List(ctx, "drive")
+ if len(rows) != 1 || rows[0].Revision != 2 {
+ t.Fatalf("after reconcile: %+v", rows)
+ }
+
+ if got := evalString(rig, "driver.status()"); got != "granted" {
+ t.Fatalf("status = %q", got)
+ }
+ if got := evalString(rig, "driver.disable()"); got != "ok" {
+ t.Fatalf("disable: %q", got)
+ }
+ if rows, _ := f.svc.List(ctx, "drive"); len(rows) != 0 {
+ t.Fatalf("after disable: %+v", rows)
+ }
+ rig.Screen("#ready", "after enable, reconcile, disable")
+}
diff --git a/go.mod b/go.mod
index 66c1a97..2aa4581 100644
--- a/go.mod
+++ b/go.mod
@@ -5,10 +5,17 @@ go 1.25.0
require (
amadan.net/rastrillo/rastrillo v0.26.0
github.com/SherClockHolmes/webpush-go v1.4.0
+ github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327
+ github.com/chromedp/chromedp v0.14.2
)
require (
+ github.com/chromedp/sysutil v1.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // 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/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
diff --git a/go.sum b/go.sum
index 7c585c7..d50a6c8 100644
--- a/go.sum
+++ b/go.sum
@@ -2,10 +2,24 @@ amadan.net/rastrillo/rastrillo v0.26.0 h1:I7UkiDbT304q9wXnmgabe84P6RDFTjMbKU174i
amadan.net/rastrillo/rastrillo v0.26.0/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE=
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
+github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
+github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
+github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
+github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
+github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
+github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
+github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
+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/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@@ -21,12 +35,16 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/keymaildev/signin v0.1.1 h1:gO+1IAM99sqUkBtCezkgGxab+/DOrqwDxd5H32N1s9Q=
github.com/keymaildev/signin v0.1.1/go.mod h1:Eb/sCmEel1jlcdkgPOrNeMn5jvxzoFvJrdjDUxOBHls=
+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.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=