| 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") |
| +} |