rastrillo / aviso Public

Clone
git clone https://amadan.net/rastrillo/aviso

Plain git — no account needed to clone.

Download

Download this file

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.
11package aviso_test
12
13import (
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
28const 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.
32const workerScript = `importScripts("/aviso-sw.js");
33self.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.
40const driverScript = `import { enable, reconcile, disable, status, capabilities } from "/push.mjs";
41
42const b64url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
43
44async 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
65const post = (path) => (body) => fetch(path, { method: "POST", credentials: "same-origin",
66 headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
67const save = post("/aviso/subscribe"), remove = post("/aviso/unsubscribe");
68
69await navigator.serviceWorker.register("/sw.js");
70const registration = await navigator.serviceWorker.ready;
71Object.defineProperty(registration, "pushManager", { value: await makeDouble() });
72Notification.requestPermission = async () => "granted";
73Object.defineProperty(Notification, "permission", { get: () => "granted" });
74const { publicKey } = await (await fetch("/aviso/public-key")).json();
75
76window.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};
84const ready = document.createElement("p");
85ready.id = "ready";
86document.body.append(ready);
87`
88
89type fixture struct {
90 svc *aviso.Service
91}
92
93func (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
117func 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
125func 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