carlos / contra Public

Clone
git clone https://amadan.net/carlos/contra

Plain git — no account needed to clone.

Download

Download this file

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.
21package contratest
22
23import (
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.
41func awaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams {
42 return p.WithAwaitPromise(true)
43}
44
45func 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.
72func 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.
100func 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.
131func 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.
178func 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.
254func 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.
322func 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.
370func 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