| 1 | //go:build browser |
| 2 | |
| 3 | // The browser-side proof of this package, in a real Chromium: the |
| 4 | // page imports js/push.mjs exactly as an app serves it, registers a |
| 5 | // worker that importScripts js/aviso-sw.js, and enrols through the |
| 6 | // real Subscribe handler — with a declared subscription double |
| 7 | // standing in for the browser's push service. Chromium's own |
| 8 | // subscription path cannot be pointed at a test service, and a test |
| 9 | // that pretended otherwise would prove less than it claimed; this one |
| 10 | // proves the module, the worker import and the handlers agree. |
| 11 | package aviso_test |
| 12 | |
| 13 | import ( |
| 14 | "context" |
| 15 | "encoding/json" |
| 16 | "net/http" |
| 17 | "testing" |
| 18 | |
| 19 | "github.com/chromedp/cdproto/runtime" |
| 20 | "github.com/chromedp/chromedp" |
| 21 | |
| 22 | "amadan.net/rastrillo/rastrillo/harness" |
| 23 | "amadan.net/rastrillo/rastrillo/sessions" |
| 24 | |
| 25 | "amadan.net/rastrillo/aviso" |
| 26 | ) |
| 27 | |
| 28 | 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>` |
| 29 | |
| 30 | // The worker: what SKILL.md tells an app to write, minus listeners |
| 31 | // the drive never fires. |
| 32 | const workerScript = `importScripts("/aviso-sw.js"); |
| 33 | self.addEventListener("push", (e) => e.waitUntil(AvisoSW.handlePush(e, { fallback: () => ({ title: "fixture" }) })));` |
| 34 | |
| 35 | // The driver stubs exactly two platform surfaces — the permission |
| 36 | // prompt, and pushManager on the registration instance — and leaves |
| 37 | // everything else real: module loading, worker registration and |
| 38 | // activation, fetch, cookies, the handlers. The double mints real |
| 39 | // P-256 keys with WebCrypto because Subscribe validates them. |
| 40 | const driverScript = `import { enable, reconcile, disable, status, capabilities } from "/push.mjs"; |
| 41 | |
| 42 | const b64url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
| 43 | |
| 44 | async function makeDouble() { |
| 45 | let current = null; |
| 46 | return { |
| 47 | async getSubscription() { return current; }, |
| 48 | async subscribe(opts) { |
| 49 | if (!opts.userVisibleOnly) throw new Error("userVisibleOnly required"); |
| 50 | const kp = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]); |
| 51 | const p256dh = b64url(await crypto.subtle.exportKey("raw", kp.publicKey)); |
| 52 | const auth = b64url(crypto.getRandomValues(new Uint8Array(16))); |
| 53 | const endpoint = "https://push.example/double/" + b64url(crypto.getRandomValues(new Uint8Array(8))); |
| 54 | current = { |
| 55 | endpoint, |
| 56 | options: { applicationServerKey: opts.applicationServerKey }, |
| 57 | toJSON() { return { endpoint, expirationTime: null, keys: { p256dh, auth } }; }, |
| 58 | async unsubscribe() { current = null; return true; }, |
| 59 | }; |
| 60 | return current; |
| 61 | }, |
| 62 | }; |
| 63 | } |
| 64 | |
| 65 | const post = (path) => (body) => fetch(path, { method: "POST", credentials: "same-origin", |
| 66 | headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); |
| 67 | const save = post("/aviso/subscribe"), remove = post("/aviso/unsubscribe"); |
| 68 | |
| 69 | await navigator.serviceWorker.register("/sw.js"); |
| 70 | const registration = await navigator.serviceWorker.ready; |
| 71 | Object.defineProperty(registration, "pushManager", { value: await makeDouble() }); |
| 72 | Notification.requestPermission = async () => "granted"; |
| 73 | Object.defineProperty(Notification, "permission", { get: () => "granted" }); |
| 74 | const { publicKey } = await (await fetch("/aviso/public-key")).json(); |
| 75 | |
| 76 | window.driver = { |
| 77 | caps: () => JSON.stringify(capabilities()), |
| 78 | workerActive: () => !!registration.active, |
| 79 | enable: async () => { const s = await enable({ registration, publicKey, save }); return s ? s.endpoint : ""; }, |
| 80 | reconcile: async () => { const s = await reconcile({ registration, publicKey, save }); return s ? s.endpoint : ""; }, |
| 81 | status: async () => (await status(registration)).permission, |
| 82 | disable: async () => { await disable({ registration, remove }); return "ok"; }, |
| 83 | }; |
| 84 | const ready = document.createElement("p"); |
| 85 | ready.id = "ready"; |
| 86 | document.body.append(ready); |
| 87 | ` |
| 88 | |
| 89 | type fixture struct { |
| 90 | svc *aviso.Service |
| 91 | } |
| 92 | |
| 93 | func (f *fixture) handler(origin string) http.Handler { |
| 94 | mux := http.NewServeMux() |
| 95 | serve := func(ct string, b []byte) http.HandlerFunc { |
| 96 | return func(w http.ResponseWriter, r *http.Request) { |
| 97 | w.Header().Set("Content-Type", ct) |
| 98 | w.Header().Set("Cache-Control", "no-cache") |
| 99 | _, _ = w.Write(b) |
| 100 | } |
| 101 | } |
| 102 | mux.HandleFunc("GET /{$}", serve("text/html; charset=utf-8", []byte(driverPage))) |
| 103 | mux.HandleFunc("GET /driver.mjs", serve("text/javascript", []byte(driverScript))) |
| 104 | mux.HandleFunc("GET /push.mjs", serve("text/javascript", aviso.JS())) |
| 105 | mux.HandleFunc("GET /aviso-sw.js", serve("text/javascript", aviso.WorkerJS())) |
| 106 | mux.HandleFunc("GET /sw.js", serve("text/javascript", []byte(workerScript))) |
| 107 | mux.HandleFunc("GET /aviso/public-key", f.svc.PublicKey) |
| 108 | mux.HandleFunc("POST /aviso/subscribe", f.svc.Subscribe) |
| 109 | mux.HandleFunc("POST /aviso/unsubscribe", f.svc.Unsubscribe) |
| 110 | // The drive is signed in as "drive"; a real app's session |
| 111 | // middleware sits here. |
| 112 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 113 | mux.ServeHTTP(w, sessions.WithSession(r, sessions.Session{Subject: "drive"})) |
| 114 | }) |
| 115 | } |
| 116 | |
| 117 | func evalString(r *harness.Rig, expr string) string { |
| 118 | var out string |
| 119 | r.Run(chromedp.Evaluate(expr, &out, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { |
| 120 | return p.WithAwaitPromise(true) |
| 121 | })) |
| 122 | return out |
| 123 | } |
| 124 | |
| 125 | func TestBrowserEnrolmentRoundTripsThroughTheHandlers(t *testing.T) { |
| 126 | key, _ := aviso.GenerateKey() |
| 127 | db := openDB(t) |
| 128 | var f fixture |
| 129 | rig := harness.New(t, func(origin string) http.Handler { |
| 130 | svc, err := aviso.New(aviso.Config{DB: db, PrivateKey: key, Contact: "mailto:ops@example.test", Origin: origin}) |
| 131 | if err != nil { |
| 132 | t.Fatal(err) |
| 133 | } |
| 134 | f.svc = svc |
| 135 | return f.handler(origin) |
| 136 | }) |
| 137 | rig.Run(chromedp.Navigate(rig.Origin + "/")) |
| 138 | rig.Screen("#ready", "fixture booted: module imported, worker active, double installed") |
| 139 | |
| 140 | if got := evalString(rig, "driver.workerActive() ? 'active' : 'inactive'"); got != "active" { |
| 141 | t.Fatalf("worker %s after ready", got) |
| 142 | } |
| 143 | var caps struct{ ServiceWorker, Push, Notifications bool } |
| 144 | if err := json.Unmarshal([]byte(evalString(rig, "driver.caps()")), &caps); err != nil || !caps.ServiceWorker || !caps.Notifications { |
| 145 | t.Fatalf("capabilities: %+v (%v)", caps, err) |
| 146 | } |
| 147 | |
| 148 | ctx := context.Background() |
| 149 | if rows, _ := f.svc.List(ctx, "drive"); len(rows) != 0 { |
| 150 | t.Fatal("rows before enable") |
| 151 | } |
| 152 | endpoint := evalString(rig, "driver.enable()") |
| 153 | if endpoint == "" { |
| 154 | t.Fatal("enable resolved null") |
| 155 | } |
| 156 | rows, err := f.svc.List(ctx, "drive") |
| 157 | if err != nil || len(rows) != 1 || rows[0].Endpoint != endpoint || rows[0].Revision != 1 { |
| 158 | t.Fatalf("after enable: %+v (%v)", rows, err) |
| 159 | } |
| 160 | |
| 161 | // A second load's reconcile re-saves the same subscription and |
| 162 | // bumps the revision, without a second row. |
| 163 | if got := evalString(rig, "driver.reconcile()"); got != endpoint { |
| 164 | t.Fatalf("reconcile returned %q, want %q", got, endpoint) |
| 165 | } |
| 166 | rows, _ = f.svc.List(ctx, "drive") |
| 167 | if len(rows) != 1 || rows[0].Revision != 2 { |
| 168 | t.Fatalf("after reconcile: %+v", rows) |
| 169 | } |
| 170 | |
| 171 | if got := evalString(rig, "driver.status()"); got != "granted" { |
| 172 | t.Fatalf("status = %q", got) |
| 173 | } |
| 174 | if got := evalString(rig, "driver.disable()"); got != "ok" { |
| 175 | t.Fatalf("disable: %q", got) |
| 176 | } |
| 177 | if rows, _ := f.svc.List(ctx, "drive"); len(rows) != 0 { |
| 178 | t.Fatalf("after disable: %+v", rows) |
| 179 | } |
| 180 | // Permission is still granted; the next load's reconcile must not |
| 181 | // undo the opt-out. |
| 182 | if got := evalString(rig, "driver.reconcile()"); got != "" { |
| 183 | t.Fatalf("reconcile after disable re-enrolled: %q", got) |
| 184 | } |
| 185 | if rows, _ := f.svc.List(ctx, "drive"); len(rows) != 0 { |
| 186 | t.Fatalf("reconcile after disable stored: %+v", rows) |
| 187 | } |
| 188 | rig.Screen("#ready", "after enable, reconcile, disable, reconcile") |
| 189 | } |
| 190 | |