| 1 | package main |
| 2 | |
| 3 | import ( |
| 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 | |
| 22 | func 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 | |
| 40 | func 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". |
| 49 | func 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 | |