| 1 | //go:build browser |
| 2 | |
| 3 | // The browser drive: the whole app in a real engine, wired exactly as |
| 4 | // cmd/contra/main.go wires it — loud on any console error, failed request, |
| 5 | // 4xx/5xx, or JS value that leaked to the screen unrendered. |
| 6 | // |
| 7 | // go test -tags browser ./... |
| 8 | // |
| 9 | // It needs a Chromium: on PATH, via RASTRILLO_CHROME, or in a Playwright |
| 10 | // cache. A skip is not a pass — with no browser this fails, unless |
| 11 | // RASTRILLO_BROWSER_OPTIONAL is set, which makes the skip a deliberate |
| 12 | // visible choice rather than an accident. |
| 13 | // |
| 14 | // # Why this matters more here than in most apps |
| 15 | // |
| 16 | // CONTRA's crypto runs in the browser, as ES modules with no build step. A |
| 17 | // syntax error in any one of them takes the whole client down, and node's |
| 18 | // module loader is not the thing that will find it — only a real engine |
| 19 | // executing a real page load does. The node suite proves the crypto is |
| 20 | // correct; this proves it *runs* where it has to run. |
| 21 | package contratest |
| 22 | |
| 23 | import ( |
| 24 | "io" |
| 25 | "log/slog" |
| 26 | "net/http" |
| 27 | "path/filepath" |
| 28 | "strings" |
| 29 | "testing" |
| 30 | |
| 31 | "github.com/carlosframework/rastrillo/db" |
| 32 | "github.com/carlosframework/rastrillo/harness" |
| 33 | "github.com/chromedp/cdproto/runtime" |
| 34 | "github.com/chromedp/chromedp" |
| 35 | |
| 36 | contra "github.com/carlosframework/contra/internal/contra" |
| 37 | ) |
| 38 | |
| 39 | // awaitPromise lets chromedp.Evaluate wait on an async import rather than |
| 40 | // returning the pending promise. |
| 41 | func awaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams { |
| 42 | return p.WithAwaitPromise(true) |
| 43 | } |
| 44 | |
| 45 | func newRig(t *testing.T) (*harness.Rig, *sentMail) { |
| 46 | t.Helper() |
| 47 | box := &sentMail{} |
| 48 | rig := harness.New(t, func(origin string) http.Handler { |
| 49 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 50 | d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) |
| 51 | if err != nil { |
| 52 | t.Fatalf("db.Open: %v", err) |
| 53 | } |
| 54 | t.Cleanup(func() { d.Close() }) |
| 55 | mux, err := contra.App(d, contra.Config{ |
| 56 | Origin: origin, Pepper: testPepper, Mailer: box, |
| 57 | }, logger) |
| 58 | if err != nil { |
| 59 | t.Fatalf("App: %v", err) |
| 60 | } |
| 61 | return mux |
| 62 | }) |
| 63 | return rig, box |
| 64 | } |
| 65 | |
| 66 | // signUpInBrowser walks the three steps a person walks: type an address, |
| 67 | // follow the link out of the message, choose a passphrase, confirm the kit. |
| 68 | // |
| 69 | // The link is the point. It is what makes signup something only the owner of |
| 70 | // an address can finish, and driving it here is the only way to know the two |
| 71 | // halves of that actually meet. |
| 72 | func signUpInBrowser(t *testing.T, rig *harness.Rig, box *sentMail, |
| 73 | email, passphrase string) { |
| 74 | t.Helper() |
| 75 | |
| 76 | rig.Run( |
| 77 | chromedp.Navigate(rig.Origin+"/signup"), |
| 78 | chromedp.SendKeys(`input[name=email]`, email, chromedp.ByQuery), |
| 79 | chromedp.Click(`button[type=submit]`, chromedp.ByQuery), |
| 80 | // Wait for the next screen, not for the click. The button fires an |
| 81 | // async request and only then navigates, so checking the outbox any |
| 82 | // earlier is a race the test would lose about half the time. |
| 83 | chromedp.WaitVisible(`[data-sent]`, chromedp.ByQuery), |
| 84 | ) |
| 85 | |
| 86 | link := box.linkFor(t, email) |
| 87 | rig.Run( |
| 88 | chromedp.Navigate(rig.Origin+link), |
| 89 | chromedp.WaitVisible(`[data-passphrase]`, chromedp.ByQuery), |
| 90 | chromedp.SendKeys(`input[name=passphrase]`, passphrase, chromedp.ByQuery), |
| 91 | chromedp.SendKeys(`input[name=passphrase_again]`, passphrase, chromedp.ByQuery), |
| 92 | chromedp.Click(`button[type=submit]`, chromedp.ByQuery), |
| 93 | chromedp.WaitVisible(`[data-kit="recovery"]`, chromedp.ByQuery), |
| 94 | ) |
| 95 | } |
| 96 | |
| 97 | // TestBrowserWalk is the loud walk over every page a signed-out visitor can |
| 98 | // reach. Each one loads the crypto module graph, so a syntax error anywhere |
| 99 | // in it fails here. |
| 100 | func TestBrowserWalk(t *testing.T) { |
| 101 | rig, box := newRig(t) |
| 102 | rig.Run(chromedp.Navigate(rig.Origin + "/")) |
| 103 | rig.Screen("body", "home") |
| 104 | |
| 105 | rig.Run(chromedp.Navigate(rig.Origin + "/signup")) |
| 106 | rig.Screen("body", "signup") |
| 107 | |
| 108 | rig.Run(chromedp.Navigate(rig.Origin + "/signup/sent")) |
| 109 | rig.Screen("body", "check your email") |
| 110 | |
| 111 | rig.Run(chromedp.Navigate(rig.Origin + "/signin")) |
| 112 | rig.Screen("body", "signin") |
| 113 | |
| 114 | // The passphrase step is not reachable without a link. |
| 115 | rig.Run(chromedp.Navigate(rig.Origin + "/signup/passphrase")) |
| 116 | var path string |
| 117 | rig.Run(chromedp.Location(&path)) |
| 118 | if !strings.Contains(path, "/signup") || strings.Contains(path, "passphrase") { |
| 119 | t.Errorf("the passphrase step was reachable without a link: %s", path) |
| 120 | } |
| 121 | _ = box |
| 122 | } |
| 123 | |
| 124 | // TestBrowserSignupAndSignIn is the ceremony in a real engine: Argon2id at |
| 125 | // 64 MiB, the P-256 derivation through WebCrypto, and the whole module graph |
| 126 | // executing under a page load rather than under node's loader. |
| 127 | // |
| 128 | // It is the check node cannot make. WebCrypto's PKCS#8 import of a raw |
| 129 | // scalar, in particular, is a place engines have differed, and a difference |
| 130 | // there is not a slow login — it is an account that cannot be signed into. |
| 131 | func TestBrowserSignupAndSignIn(t *testing.T) { |
| 132 | rig, box := newRig(t) |
| 133 | |
| 134 | const email = "browser-walk@example.invalid" |
| 135 | const passphrase = "a generated test passphrase 8f3a1c" |
| 136 | |
| 137 | // Argon2id at 64 MiB takes a couple of seconds in a real engine, so the |
| 138 | // helper waits on the kit appearing rather than on a timer. |
| 139 | signUpInBrowser(t, rig, box, email, passphrase) |
| 140 | rig.Screen(`[data-screen="kit"]`, "emergency kit") |
| 141 | |
| 142 | // Both printed values must actually be on the page: this is the only |
| 143 | // moment either exists outside the user's hands. |
| 144 | var accountKey, recovery string |
| 145 | rig.Run( |
| 146 | chromedp.Text(`[data-kit="account-key"]`, &accountKey, chromedp.ByQuery), |
| 147 | chromedp.Text(`[data-kit="recovery"]`, &recovery, chromedp.ByQuery), |
| 148 | ) |
| 149 | if len(accountKey) != 38 || accountKey[:3] != "A1-" { |
| 150 | t.Errorf("Account Key card reads %q", accountKey) |
| 151 | } |
| 152 | if len(recovery) != 38 || recovery[:3] != "C1-" { |
| 153 | t.Errorf("Recovery sheet reads %q", recovery) |
| 154 | } |
| 155 | if accountKey == recovery { |
| 156 | t.Error("the two printed values are the same") |
| 157 | } |
| 158 | |
| 159 | // And the account signs in, in the same engine. |
| 160 | rig.Run( |
| 161 | chromedp.Navigate(rig.Origin+"/signin"), |
| 162 | chromedp.SendKeys(`input[name=email]`, email, chromedp.ByQuery), |
| 163 | chromedp.SendKeys(`input[name=passphrase]`, passphrase, chromedp.ByQuery), |
| 164 | chromedp.Click(`button[type=submit]`, chromedp.ByQuery), |
| 165 | chromedp.WaitVisible(`form[action="/auth/signout"]`, chromedp.ByQuery), |
| 166 | ) |
| 167 | rig.Screen("body", "vault") |
| 168 | } |
| 169 | |
| 170 | // TestBrowserVaultRoundTrip is the whole product in one walk: sign up, unlock |
| 171 | // with the passphrase and the cached Account Key, add an item, watch it |
| 172 | // survive a reload, edit it, delete it. |
| 173 | // |
| 174 | // It is the check nothing else makes. The node suite proves the crypto is |
| 175 | // right and the Go suite proves the API is scoped; only this proves the two |
| 176 | // halves agree in a real engine, over a real session, with the real |
| 177 | // localStorage the Account Key cache depends on. |
| 178 | func TestBrowserVaultRoundTrip(t *testing.T) { |
| 179 | rig, box := newRig(t) |
| 180 | |
| 181 | const email = "vault-walk@example.invalid" |
| 182 | const passphrase = "a generated test passphrase 8f3a1c" |
| 183 | |
| 184 | // Signup provisions the account keyset and the first vault, and caches |
| 185 | // the Account Key for this browser. |
| 186 | signUpInBrowser(t, rig, box, email, passphrase) |
| 187 | |
| 188 | unlockVault := []chromedp.Action{ |
| 189 | chromedp.Navigate(rig.Origin + "/vault"), |
| 190 | chromedp.WaitVisible(`[data-unlock]`, chromedp.ByQuery), |
| 191 | chromedp.SendKeys(`[data-unlock] input[name=passphrase]`, passphrase, chromedp.ByQuery), |
| 192 | chromedp.Click(`[data-unlock] button[type=submit]`, chromedp.ByQuery), |
| 193 | } |
| 194 | |
| 195 | // Signing in is not unlocking: the vault asks for the passphrase again, |
| 196 | // because the key comes from a wrap the server cannot open. |
| 197 | rig.Run(append(unlockVault, chromedp.WaitVisible(`[data-item-form]`, chromedp.ByQuery))...) |
| 198 | rig.Screen("[data-vault]", "unlocked vault") |
| 199 | |
| 200 | rig.Run( |
| 201 | chromedp.SendKeys(`[data-item-form] input[name=title]`, "Jamaica Bakery", chromedp.ByQuery), |
| 202 | chromedp.SendKeys(`[data-item-form] input[name=username]`, "me@example.invalid", chromedp.ByQuery), |
| 203 | chromedp.SendKeys(`[data-item-form] input[name=password]`, "generated-not-real-8f3a", chromedp.ByQuery), |
| 204 | chromedp.Click(`[data-item-form] [data-save]`, chromedp.ByQuery), |
| 205 | chromedp.WaitVisible(`[data-items] li`, chromedp.ByQuery), |
| 206 | ) |
| 207 | |
| 208 | var listed string |
| 209 | rig.Run(chromedp.Text(`[data-items]`, &listed, chromedp.ByQuery)) |
| 210 | if !strings.Contains(listed, "Jamaica Bakery") { |
| 211 | t.Fatalf("the item did not appear in the list: %q", listed) |
| 212 | } |
| 213 | rig.Screen("[data-vault]", "one item") |
| 214 | |
| 215 | // It survives a reload, which means it really went to the server sealed, |
| 216 | // came back, and opened — rather than sitting in a JS variable. |
| 217 | rig.Run(append(unlockVault, |
| 218 | chromedp.WaitVisible(`[data-items] li`, chromedp.ByQuery), |
| 219 | chromedp.Text(`[data-items]`, &listed, chromedp.ByQuery))...) |
| 220 | if !strings.Contains(listed, "Jamaica Bakery") { |
| 221 | t.Fatalf("the item did not survive a reload: %q", listed) |
| 222 | } |
| 223 | |
| 224 | // Saving is asynchronous — it seals, posts, and only then redraws — so |
| 225 | // this waits for the list to actually carry the edit rather than reading |
| 226 | // it a millisecond too early. |
| 227 | rig.Run( |
| 228 | chromedp.Click(`[data-items] [data-edit]`, chromedp.ByQuery), |
| 229 | chromedp.SetValue(`[data-item-form] input[name=title]`, "Jamaica Bakery (edited)", chromedp.ByQuery), |
| 230 | chromedp.Click(`[data-item-form] [data-save]`, chromedp.ByQuery), |
| 231 | chromedp.Poll( |
| 232 | `document.querySelector('[data-items]').textContent.includes('(edited)')`, |
| 233 | nil), |
| 234 | ) |
| 235 | |
| 236 | rig.Run( |
| 237 | chromedp.Click(`[data-items] [data-delete]`, chromedp.ByQuery), |
| 238 | chromedp.WaitNotPresent(`[data-items] li`, chromedp.ByQuery), |
| 239 | ) |
| 240 | rig.Screen("[data-vault]", "empty vault") |
| 241 | } |
| 242 | |
| 243 | // The Emergency Kit, in a real engine. |
| 244 | // |
| 245 | // The node suite renders the two PDFs and scans them, which proves the |
| 246 | // documents are right. This proves the screen is: that signup actually builds |
| 247 | // them in the browser, that both are offered by one gesture, and — the part |
| 248 | // that only a real engine can show — that there is no way past this screen |
| 249 | // until the person says they have them. |
| 250 | // |
| 251 | // Walking past this screen without the recovery code is the one mistake in |
| 252 | // the whole product that cannot be undone later, so the gate is worth a test |
| 253 | // rather than a comment. |
| 254 | func TestBrowserEmergencyKit(t *testing.T) { |
| 255 | rig, box := newRig(t) |
| 256 | |
| 257 | const email = "kit-walk@example.invalid" |
| 258 | const passphrase = "a generated test passphrase 8f3a1c" |
| 259 | |
| 260 | signUpInBrowser(t, rig, box, email, passphrase) |
| 261 | |
| 262 | // The passphrase form must be OFF the screen by now. It appearing above |
| 263 | // the kit was the bug: two steps stacked in one scroll, with the fields |
| 264 | // you just used still sitting there. |
| 265 | var formVisible bool |
| 266 | rig.Run(chromedp.Evaluate( |
| 267 | `!document.querySelector('[data-screen="form"]').hidden`, &formVisible)) |
| 268 | if formVisible { |
| 269 | t.Error("the passphrase form is still on screen behind the kit") |
| 270 | } |
| 271 | |
| 272 | // Both printed values are on screen, and they are different values. |
| 273 | var accountKey, recovery string |
| 274 | rig.Run( |
| 275 | chromedp.Text(`[data-kit="account-key"]`, &accountKey, chromedp.ByQuery), |
| 276 | chromedp.Text(`[data-kit="recovery"]`, &recovery, chromedp.ByQuery), |
| 277 | ) |
| 278 | if accountKey[:3] != "A1-" || recovery[:3] != "C1-" || accountKey == recovery { |
| 279 | t.Fatalf("kit values look wrong: %q and %q", accountKey, recovery) |
| 280 | } |
| 281 | |
| 282 | // The way onward is closed until the person confirms. |
| 283 | var onwardHidden bool |
| 284 | rig.Run(chromedp.Evaluate( |
| 285 | `document.querySelector('[data-onward]').hidden`, &onwardHidden)) |
| 286 | if !onwardHidden { |
| 287 | t.Error("the vault was reachable before the kit was confirmed") |
| 288 | } |
| 289 | |
| 290 | // Building both documents must not throw. Clicking the real button would |
| 291 | // start two downloads Chromium has nowhere to put, so this calls the same |
| 292 | // module the button calls and checks the bytes are a PDF. |
| 293 | var sizes []int |
| 294 | rig.Run(chromedp.Evaluate(`(async () => { |
| 295 | const { accountKeyCard, recoverySheet } = await import("/static/crypto/kit.js"); |
| 296 | const id = "b22c6a5eae1be9d6a773f474fd0532b0"; |
| 297 | const a = accountKeyCard(id, document.querySelector('[data-kit="account-key"]').textContent); |
| 298 | const b = recoverySheet(id, document.querySelector('[data-kit="recovery"]').textContent); |
| 299 | const head = (x) => new TextDecoder().decode(x.slice(0, 5)); |
| 300 | if (head(a) !== "%PDF-" || head(b) !== "%PDF-") throw new Error("not a PDF"); |
| 301 | return [a.length, b.length]; |
| 302 | })()`, &sizes, awaitPromise)) |
| 303 | if len(sizes) != 2 || sizes[0] < 2000 || sizes[1] < 2000 { |
| 304 | t.Errorf("the kit documents look wrong: %v bytes", sizes) |
| 305 | } |
| 306 | |
| 307 | // Confirming opens it. |
| 308 | rig.Run( |
| 309 | chromedp.Click(`[data-confirmed]`, chromedp.ByQuery), |
| 310 | chromedp.WaitVisible(`[data-onward] a`, chromedp.ByQuery), |
| 311 | ) |
| 312 | rig.Screen(`[data-screen="kit"]`, "emergency kit confirmed") |
| 313 | } |
| 314 | |
| 315 | // The kit offers each document its own button. |
| 316 | // |
| 317 | // A single "save both" cannot work: browsers refuse the second programmatic |
| 318 | // download from one gesture, and Safari refuses it silently — which is how |
| 319 | // that button delivered one file and reported success. Two buttons is what |
| 320 | // actually delivers two files, and it is also the shape that matches the |
| 321 | // reason there are two documents. |
| 322 | func TestBrowserKitSavesBothDocumentsSeparately(t *testing.T) { |
| 323 | rig, box := newRig(t) |
| 324 | signUpInBrowser(t, rig, box, "two-files@example.invalid", |
| 325 | "a generated test passphrase 8f3a1c") |
| 326 | |
| 327 | var buttons int |
| 328 | rig.Run(chromedp.Evaluate( |
| 329 | `document.querySelectorAll('[data-kit-body] [data-save]').length`, &buttons)) |
| 330 | if buttons != 2 { |
| 331 | t.Fatalf("the kit offers %d save buttons, want one per document", buttons) |
| 332 | } |
| 333 | |
| 334 | // Each builds a real PDF for its own document, and they are different |
| 335 | // documents — a pair of buttons that saved the same file twice would look |
| 336 | // identical from the outside. |
| 337 | var sizes []int |
| 338 | rig.Run(chromedp.Evaluate(`(async () => { |
| 339 | const { accountKeyCard, recoverySheet } = await import("/static/crypto/kit.js"); |
| 340 | const id = "b22c6a5eae1be9d6a773f474fd0532b0"; |
| 341 | const card = document.querySelector('[data-kit="account-key"]').textContent; |
| 342 | const sheet = document.querySelector('[data-kit="recovery"]').textContent; |
| 343 | if (card === sheet) throw new Error("the two documents carry the same code"); |
| 344 | const a = accountKeyCard(id, card); |
| 345 | const b = recoverySheet(id, sheet); |
| 346 | const head = (x) => new TextDecoder().decode(x.slice(0, 5)); |
| 347 | if (head(a) !== "%PDF-" || head(b) !== "%PDF-") throw new Error("not a PDF"); |
| 348 | return [a.length, b.length]; |
| 349 | })()`, &sizes, awaitPromise)) |
| 350 | if len(sizes) != 2 || sizes[0] < 2000 || sizes[1] < 2000 { |
| 351 | t.Errorf("the kit documents look wrong: %v bytes", sizes) |
| 352 | } |
| 353 | |
| 354 | // Clicking one marks that one saved, and only that one. |
| 355 | rig.Run( |
| 356 | chromedp.Click(`[data-kit-body] [data-save="account-key"]`, chromedp.ByQuery), |
| 357 | chromedp.WaitVisible(`[data-save="account-key"][data-saved]`, chromedp.ByQuery), |
| 358 | ) |
| 359 | var recoverySaved bool |
| 360 | rig.Run(chromedp.Evaluate( |
| 361 | `document.querySelector('[data-save="recovery-sheet"]').hasAttribute("data-saved")`, |
| 362 | &recoverySaved)) |
| 363 | if recoverySaved { |
| 364 | t.Error("saving one document marked the other saved too") |
| 365 | } |
| 366 | } |
| 367 | |
| 368 | // The working screen is a screen, not a spinner: it names what is happening |
| 369 | // while a couple of seconds of Argon2id go by, so nobody concludes it hung. |
| 370 | func TestBrowserShowsProgressWhileCreating(t *testing.T) { |
| 371 | rig, box := newRig(t) |
| 372 | |
| 373 | rig.Run( |
| 374 | chromedp.Navigate(rig.Origin+"/signup"), |
| 375 | chromedp.SendKeys(`input[name=email]`, "progress@example.invalid", chromedp.ByQuery), |
| 376 | chromedp.Click(`button[type=submit]`, chromedp.ByQuery), |
| 377 | chromedp.WaitVisible(`[data-sent]`, chromedp.ByQuery), |
| 378 | ) |
| 379 | rig.Run( |
| 380 | chromedp.Navigate(rig.Origin+box.linkFor(t, "progress@example.invalid")), |
| 381 | chromedp.WaitVisible(`[data-passphrase]`, chromedp.ByQuery), |
| 382 | chromedp.SendKeys(`input[name=passphrase]`, "a generated test passphrase 8f3a1c", chromedp.ByQuery), |
| 383 | chromedp.SendKeys(`input[name=passphrase_again]`, "a generated test passphrase 8f3a1c", chromedp.ByQuery), |
| 384 | chromedp.Click(`button[type=submit]`, chromedp.ByQuery), |
| 385 | // The working screen appears immediately, long before the kit. |
| 386 | chromedp.WaitVisible(`[data-screen="working"] [data-progress]`, chromedp.ByQuery), |
| 387 | ) |
| 388 | rig.Screen(`[data-screen="working"]`, "creating your vault") |
| 389 | |
| 390 | // And at least one task is marked as under way rather than the list just |
| 391 | // sitting there inert. |
| 392 | var doing int |
| 393 | rig.Run(chromedp.Evaluate( |
| 394 | `document.querySelectorAll('[data-progress] li[data-state]').length`, &doing)) |
| 395 | if doing == 0 { |
| 396 | t.Error("the progress list never marked a task, so it is decoration") |
| 397 | } |
| 398 | |
| 399 | rig.Run(chromedp.WaitVisible(`[data-kit="recovery"]`, chromedp.ByQuery)) |
| 400 | } |
| 401 | |