//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 = `
aviso fixture
` // 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) } // Permission is still granted; the next load's reconcile must not // undo the opt-out. if got := evalString(rig, "driver.reconcile()"); got != "" { t.Fatalf("reconcile after disable re-enrolled: %q", got) } if rows, _ := f.svc.List(ctx, "drive"); len(rows) != 0 { t.Fatalf("reconcile after disable stored: %+v", rows) } rig.Screen("#ready", "after enable, reconcile, disable, reconcile") }