rastrillo / aviso Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "context"
5 "crypto/ecdh"
6 "crypto/rand"
7 "encoding/base64"
8 "encoding/json"
9 "net/http"
10 "net/http/httptest"
11 "path/filepath"
12 "strings"
13 "testing"
14
15 "amadan.net/rastrillo/rastrillo/db"
16 "amadan.net/rastrillo/rastrillo/migrate"
17 "amadan.net/rastrillo/rastrillo/sessions"
18
19 "amadan.net/rastrillo/aviso"
20)
21
22func newApp(t *testing.T) (http.Handler, *aviso.Service) {
23 t.Helper()
24 d, err := db.Open(filepath.Join(t.TempDir(), "example.db"), nil)
25 if err != nil {
26 t.Fatal(err)
27 }
28 t.Cleanup(func() { d.Close() })
29 if _, err := migrate.Apply(context.Background(), d, migrate.Merge(sessions.Schema, aviso.Schema)); err != nil {
30 t.Fatal(err)
31 }
32 key, _ := aviso.GenerateKey()
33 svc, err := aviso.New(aviso.Config{DB: d.Writer(), PrivateKey: key, Contact: "mailto:ops@example.test", Origin: "http://localhost:8080"})
34 if err != nil {
35 t.Fatal(err)
36 }
37 return handler(svc, "http://localhost:8080"), svc
38}
39
40func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
41 t.Helper()
42 w := httptest.NewRecorder()
43 h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
44 return w
45}
46
47// Every seam answers: the page, the two embedded JS files, the
48// worker at root scope, the key, and a subscribe that lands as "dev".
49func TestEverySeamIsWired(t *testing.T) {
50 h, svc := newApp(t)
51 for path, want := range map[string]string{
52 "/": "<button id=\"enable\"",
53 "/static/app.js": "import { enable",
54 "/static/aviso/push.mjs": "export async function enable(",
55 "/static/aviso/aviso-sw.js": "AvisoSW",
56 "/sw.js": "importScripts(\"/static/aviso/aviso-sw.js\")",
57 "/aviso/public-key": "publicKey",
58 "/static/manifest.webmanifest": "/static/icon-512.png",
59 } {
60 w := get(t, h, path)
61 if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), want) {
62 t.Errorf("%s: %d, body lacks %q", path, w.Code, want)
63 }
64 }
65 if cc := get(t, h, "/sw.js").Header().Get("Cache-Control"); cc != "no-cache" {
66 t.Errorf("sw.js Cache-Control = %q", cc)
67 }
68 // A module or worker script served as text/plain is refused by the
69 // browser; the example must model the right content type.
70 for _, path := range []string{"/static/aviso/push.mjs", "/static/aviso/aviso-sw.js", "/sw.js", "/static/app.js"} {
71 if ct := get(t, h, path).Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") {
72 t.Errorf("%s Content-Type = %q", path, ct)
73 }
74 }
75 // The installability recipe, modelled: manifest icons at 192 and
76 // 512, an Apple touch icon at 180 linked from the page, and
77 // buttons that start disabled until the page can act on a click.
78 for _, path := range []string{"/static/icon-192.png", "/static/icon-512.png", "/static/icon-180.png"} {
79 if w := get(t, h, path); w.Code != http.StatusOK || !strings.HasPrefix(w.Header().Get("Content-Type"), "image/png") {
80 t.Errorf("%s: %d %q", path, w.Code, w.Header().Get("Content-Type"))
81 }
82 }
83 page := get(t, h, "/").Body.String()
84 for _, want := range []string{`rel="apple-touch-icon" href="/static/icon-180.png"`, `rel="manifest"`, `id="enable" disabled`, `id="notify" disabled`} {
85 if !strings.Contains(page, want) {
86 t.Errorf("index.html lacks %q", want)
87 }
88 }
89
90 k, _ := ecdh.P256().GenerateKey(rand.Reader)
91 auth := make([]byte, 16)
92 _, _ = rand.Read(auth)
93 body, _ := json.Marshal(map[string]any{
94 "subscription": map[string]any{
95 "endpoint": "https://push.example/dev-phone",
96 "keys": map[string]string{
97 "p256dh": base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes()),
98 "auth": base64.RawURLEncoding.EncodeToString(auth),
99 },
100 },
101 "publicKey": svc.PublicKeyString(),
102 })
103 r := httptest.NewRequest(http.MethodPost, "/aviso/subscribe", strings.NewReader(string(body)))
104 r.Header.Set("Content-Type", "application/json")
105 r.Header.Set("Sec-Fetch-Site", "same-origin")
106 w := httptest.NewRecorder()
107 h.ServeHTTP(w, r)
108 if w.Code != http.StatusNoContent {
109 t.Fatalf("subscribe: %d %s", w.Code, w.Body)
110 }
111 rows, err := svc.List(context.Background(), "dev")
112 if err != nil || len(rows) != 1 {
113 t.Fatalf("rows for dev: %v %+v", err, rows)
114 }
115
116 // notify is gated like any state-changing POST.
117 r = httptest.NewRequest(http.MethodPost, "/notify", nil)
118 r.Header.Set("Sec-Fetch-Site", "cross-site")
119 w = httptest.NewRecorder()
120 h.ServeHTTP(w, r)
121 if w.Code != http.StatusForbidden {
122 t.Fatalf("cross-site notify: %d", w.Code)
123 }
124}
125