//go:build browser // The browser drive: the whole app in a real engine, wired exactly as // cmd/contra/main.go wires it — loud on any console error, failed request, // 4xx/5xx, or JS value that leaked to the screen unrendered. // // go test -tags browser ./... // // It needs a Chromium: on PATH, via RASTRILLO_CHROME, or in a Playwright // cache. A skip is not a pass — with no browser this fails, unless // RASTRILLO_BROWSER_OPTIONAL is set, which makes the skip a deliberate // visible choice rather than an accident. // // # Why this matters more here than in most apps // // CONTRA's crypto runs in the browser, as ES modules with no build step. A // syntax error in any one of them takes the whole client down, and node's // module loader is not the thing that will find it — only a real engine // executing a real page load does. The node suite proves the crypto is // correct; this proves it *runs* where it has to run. package contratest import ( "io" "log/slog" "net/http" "path/filepath" "strings" "testing" "github.com/carlosframework/rastrillo/db" "github.com/carlosframework/rastrillo/harness" "github.com/chromedp/cdproto/runtime" "github.com/chromedp/chromedp" contra "github.com/carlosframework/contra/internal/contra" ) // awaitPromise lets chromedp.Evaluate wait on an async import rather than // returning the pending promise. func awaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) } func newRig(t *testing.T) (*harness.Rig, *sentMail) { t.Helper() box := &sentMail{} rig := harness.New(t, func(origin string) http.Handler { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) if err != nil { t.Fatalf("db.Open: %v", err) } t.Cleanup(func() { d.Close() }) mux, err := contra.App(d, contra.Config{ Origin: origin, Pepper: testPepper, Mailer: box, }, logger) if err != nil { t.Fatalf("App: %v", err) } return mux }) return rig, box } // signUpInBrowser walks the three steps a person walks: type an address, // follow the link out of the message, choose a passphrase, confirm the kit. // // The link is the point. It is what makes signup something only the owner of // an address can finish, and driving it here is the only way to know the two // halves of that actually meet. func signUpInBrowser(t *testing.T, rig *harness.Rig, box *sentMail, email, passphrase string) { t.Helper() rig.Run( chromedp.Navigate(rig.Origin+"/signup"), chromedp.SendKeys(`input[name=email]`, email, chromedp.ByQuery), chromedp.Click(`button[type=submit]`, chromedp.ByQuery), // Wait for the next screen, not for the click. The button fires an // async request and only then navigates, so checking the outbox any // earlier is a race the test would lose about half the time. chromedp.WaitVisible(`[data-sent]`, chromedp.ByQuery), ) link := box.linkFor(t, email) rig.Run( chromedp.Navigate(rig.Origin+link), chromedp.WaitVisible(`[data-passphrase]`, chromedp.ByQuery), chromedp.SendKeys(`input[name=passphrase]`, passphrase, chromedp.ByQuery), chromedp.SendKeys(`input[name=passphrase_again]`, passphrase, chromedp.ByQuery), chromedp.Click(`button[type=submit]`, chromedp.ByQuery), chromedp.WaitVisible(`[data-kit="recovery"]`, chromedp.ByQuery), ) } // TestBrowserWalk is the loud walk over every page a signed-out visitor can // reach. Each one loads the crypto module graph, so a syntax error anywhere // in it fails here. func TestBrowserWalk(t *testing.T) { rig, box := newRig(t) rig.Run(chromedp.Navigate(rig.Origin + "/")) rig.Screen("body", "home") rig.Run(chromedp.Navigate(rig.Origin + "/signup")) rig.Screen("body", "signup") rig.Run(chromedp.Navigate(rig.Origin + "/signup/sent")) rig.Screen("body", "check your email") rig.Run(chromedp.Navigate(rig.Origin + "/signin")) rig.Screen("body", "signin") // The passphrase step is not reachable without a link. rig.Run(chromedp.Navigate(rig.Origin + "/signup/passphrase")) var path string rig.Run(chromedp.Location(&path)) if !strings.Contains(path, "/signup") || strings.Contains(path, "passphrase") { t.Errorf("the passphrase step was reachable without a link: %s", path) } _ = box } // TestBrowserSignupAndSignIn is the ceremony in a real engine: Argon2id at // 64 MiB, the P-256 derivation through WebCrypto, and the whole module graph // executing under a page load rather than under node's loader. // // It is the check node cannot make. WebCrypto's PKCS#8 import of a raw // scalar, in particular, is a place engines have differed, and a difference // there is not a slow login — it is an account that cannot be signed into. func TestBrowserSignupAndSignIn(t *testing.T) { rig, box := newRig(t) const email = "browser-walk@example.invalid" const passphrase = "a generated test passphrase 8f3a1c" // Argon2id at 64 MiB takes a couple of seconds in a real engine, so the // helper waits on the kit appearing rather than on a timer. signUpInBrowser(t, rig, box, email, passphrase) rig.Screen(`[data-screen="kit"]`, "emergency kit") // Both printed values must actually be on the page: this is the only // moment either exists outside the user's hands. var accountKey, recovery string rig.Run( chromedp.Text(`[data-kit="account-key"]`, &accountKey, chromedp.ByQuery), chromedp.Text(`[data-kit="recovery"]`, &recovery, chromedp.ByQuery), ) if len(accountKey) != 38 || accountKey[:3] != "A1-" { t.Errorf("Account Key card reads %q", accountKey) } if len(recovery) != 38 || recovery[:3] != "C1-" { t.Errorf("Recovery sheet reads %q", recovery) } if accountKey == recovery { t.Error("the two printed values are the same") } // And the account signs in, in the same engine. rig.Run( chromedp.Navigate(rig.Origin+"/signin"), chromedp.SendKeys(`input[name=email]`, email, chromedp.ByQuery), chromedp.SendKeys(`input[name=passphrase]`, passphrase, chromedp.ByQuery), chromedp.Click(`button[type=submit]`, chromedp.ByQuery), chromedp.WaitVisible(`form[action="/auth/signout"]`, chromedp.ByQuery), ) rig.Screen("body", "vault") } // TestBrowserVaultRoundTrip is the whole product in one walk: sign up, unlock // with the passphrase and the cached Account Key, add an item, watch it // survive a reload, edit it, delete it. // // It is the check nothing else makes. The node suite proves the crypto is // right and the Go suite proves the API is scoped; only this proves the two // halves agree in a real engine, over a real session, with the real // localStorage the Account Key cache depends on. func TestBrowserVaultRoundTrip(t *testing.T) { rig, box := newRig(t) const email = "vault-walk@example.invalid" const passphrase = "a generated test passphrase 8f3a1c" // Signup provisions the account keyset and the first vault, and caches // the Account Key for this browser. signUpInBrowser(t, rig, box, email, passphrase) unlockVault := []chromedp.Action{ chromedp.Navigate(rig.Origin + "/vault"), chromedp.WaitVisible(`[data-unlock]`, chromedp.ByQuery), chromedp.SendKeys(`[data-unlock] input[name=passphrase]`, passphrase, chromedp.ByQuery), chromedp.Click(`[data-unlock] button[type=submit]`, chromedp.ByQuery), } // Signing in is not unlocking: the vault asks for the passphrase again, // because the key comes from a wrap the server cannot open. rig.Run(append(unlockVault, chromedp.WaitVisible(`[data-item-form]`, chromedp.ByQuery))...) rig.Screen("[data-vault]", "unlocked vault") rig.Run( chromedp.SendKeys(`[data-item-form] input[name=title]`, "Jamaica Bakery", chromedp.ByQuery), chromedp.SendKeys(`[data-item-form] input[name=username]`, "me@example.invalid", chromedp.ByQuery), chromedp.SendKeys(`[data-item-form] input[name=password]`, "generated-not-real-8f3a", chromedp.ByQuery), chromedp.Click(`[data-item-form] [data-save]`, chromedp.ByQuery), chromedp.WaitVisible(`[data-items] li`, chromedp.ByQuery), ) var listed string rig.Run(chromedp.Text(`[data-items]`, &listed, chromedp.ByQuery)) if !strings.Contains(listed, "Jamaica Bakery") { t.Fatalf("the item did not appear in the list: %q", listed) } rig.Screen("[data-vault]", "one item") // It survives a reload, which means it really went to the server sealed, // came back, and opened — rather than sitting in a JS variable. rig.Run(append(unlockVault, chromedp.WaitVisible(`[data-items] li`, chromedp.ByQuery), chromedp.Text(`[data-items]`, &listed, chromedp.ByQuery))...) if !strings.Contains(listed, "Jamaica Bakery") { t.Fatalf("the item did not survive a reload: %q", listed) } // Saving is asynchronous — it seals, posts, and only then redraws — so // this waits for the list to actually carry the edit rather than reading // it a millisecond too early. rig.Run( chromedp.Click(`[data-items] [data-edit]`, chromedp.ByQuery), chromedp.SetValue(`[data-item-form] input[name=title]`, "Jamaica Bakery (edited)", chromedp.ByQuery), chromedp.Click(`[data-item-form] [data-save]`, chromedp.ByQuery), chromedp.Poll( `document.querySelector('[data-items]').textContent.includes('(edited)')`, nil), ) rig.Run( chromedp.Click(`[data-items] [data-delete]`, chromedp.ByQuery), chromedp.WaitNotPresent(`[data-items] li`, chromedp.ByQuery), ) rig.Screen("[data-vault]", "empty vault") } // The Emergency Kit, in a real engine. // // The node suite renders the two PDFs and scans them, which proves the // documents are right. This proves the screen is: that signup actually builds // them in the browser, that both are offered by one gesture, and — the part // that only a real engine can show — that there is no way past this screen // until the person says they have them. // // Walking past this screen without the recovery code is the one mistake in // the whole product that cannot be undone later, so the gate is worth a test // rather than a comment. func TestBrowserEmergencyKit(t *testing.T) { rig, box := newRig(t) const email = "kit-walk@example.invalid" const passphrase = "a generated test passphrase 8f3a1c" signUpInBrowser(t, rig, box, email, passphrase) // The passphrase form must be OFF the screen by now. It appearing above // the kit was the bug: two steps stacked in one scroll, with the fields // you just used still sitting there. var formVisible bool rig.Run(chromedp.Evaluate( `!document.querySelector('[data-screen="form"]').hidden`, &formVisible)) if formVisible { t.Error("the passphrase form is still on screen behind the kit") } // Both printed values are on screen, and they are different values. var accountKey, recovery string rig.Run( chromedp.Text(`[data-kit="account-key"]`, &accountKey, chromedp.ByQuery), chromedp.Text(`[data-kit="recovery"]`, &recovery, chromedp.ByQuery), ) if accountKey[:3] != "A1-" || recovery[:3] != "C1-" || accountKey == recovery { t.Fatalf("kit values look wrong: %q and %q", accountKey, recovery) } // The way onward is closed until the person confirms. var onwardHidden bool rig.Run(chromedp.Evaluate( `document.querySelector('[data-onward]').hidden`, &onwardHidden)) if !onwardHidden { t.Error("the vault was reachable before the kit was confirmed") } // Building both documents must not throw. Clicking the real button would // start two downloads Chromium has nowhere to put, so this calls the same // module the button calls and checks the bytes are a PDF. var sizes []int rig.Run(chromedp.Evaluate(`(async () => { const { accountKeyCard, recoverySheet } = await import("/static/crypto/kit.js"); const id = "b22c6a5eae1be9d6a773f474fd0532b0"; const a = accountKeyCard(id, document.querySelector('[data-kit="account-key"]').textContent); const b = recoverySheet(id, document.querySelector('[data-kit="recovery"]').textContent); const head = (x) => new TextDecoder().decode(x.slice(0, 5)); if (head(a) !== "%PDF-" || head(b) !== "%PDF-") throw new Error("not a PDF"); return [a.length, b.length]; })()`, &sizes, awaitPromise)) if len(sizes) != 2 || sizes[0] < 2000 || sizes[1] < 2000 { t.Errorf("the kit documents look wrong: %v bytes", sizes) } // Confirming opens it. rig.Run( chromedp.Click(`[data-confirmed]`, chromedp.ByQuery), chromedp.WaitVisible(`[data-onward] a`, chromedp.ByQuery), ) rig.Screen(`[data-screen="kit"]`, "emergency kit confirmed") } // The kit offers each document its own button. // // A single "save both" cannot work: browsers refuse the second programmatic // download from one gesture, and Safari refuses it silently — which is how // that button delivered one file and reported success. Two buttons is what // actually delivers two files, and it is also the shape that matches the // reason there are two documents. func TestBrowserKitSavesBothDocumentsSeparately(t *testing.T) { rig, box := newRig(t) signUpInBrowser(t, rig, box, "two-files@example.invalid", "a generated test passphrase 8f3a1c") var buttons int rig.Run(chromedp.Evaluate( `document.querySelectorAll('[data-kit-body] [data-save]').length`, &buttons)) if buttons != 2 { t.Fatalf("the kit offers %d save buttons, want one per document", buttons) } // Each builds a real PDF for its own document, and they are different // documents — a pair of buttons that saved the same file twice would look // identical from the outside. var sizes []int rig.Run(chromedp.Evaluate(`(async () => { const { accountKeyCard, recoverySheet } = await import("/static/crypto/kit.js"); const id = "b22c6a5eae1be9d6a773f474fd0532b0"; const card = document.querySelector('[data-kit="account-key"]').textContent; const sheet = document.querySelector('[data-kit="recovery"]').textContent; if (card === sheet) throw new Error("the two documents carry the same code"); const a = accountKeyCard(id, card); const b = recoverySheet(id, sheet); const head = (x) => new TextDecoder().decode(x.slice(0, 5)); if (head(a) !== "%PDF-" || head(b) !== "%PDF-") throw new Error("not a PDF"); return [a.length, b.length]; })()`, &sizes, awaitPromise)) if len(sizes) != 2 || sizes[0] < 2000 || sizes[1] < 2000 { t.Errorf("the kit documents look wrong: %v bytes", sizes) } // Clicking one marks that one saved, and only that one. rig.Run( chromedp.Click(`[data-kit-body] [data-save="account-key"]`, chromedp.ByQuery), chromedp.WaitVisible(`[data-save="account-key"][data-saved]`, chromedp.ByQuery), ) var recoverySaved bool rig.Run(chromedp.Evaluate( `document.querySelector('[data-save="recovery-sheet"]').hasAttribute("data-saved")`, &recoverySaved)) if recoverySaved { t.Error("saving one document marked the other saved too") } } // The working screen is a screen, not a spinner: it names what is happening // while a couple of seconds of Argon2id go by, so nobody concludes it hung. func TestBrowserShowsProgressWhileCreating(t *testing.T) { rig, box := newRig(t) rig.Run( chromedp.Navigate(rig.Origin+"/signup"), chromedp.SendKeys(`input[name=email]`, "progress@example.invalid", chromedp.ByQuery), chromedp.Click(`button[type=submit]`, chromedp.ByQuery), chromedp.WaitVisible(`[data-sent]`, chromedp.ByQuery), ) rig.Run( chromedp.Navigate(rig.Origin+box.linkFor(t, "progress@example.invalid")), chromedp.WaitVisible(`[data-passphrase]`, chromedp.ByQuery), chromedp.SendKeys(`input[name=passphrase]`, "a generated test passphrase 8f3a1c", chromedp.ByQuery), chromedp.SendKeys(`input[name=passphrase_again]`, "a generated test passphrase 8f3a1c", chromedp.ByQuery), chromedp.Click(`button[type=submit]`, chromedp.ByQuery), // The working screen appears immediately, long before the kit. chromedp.WaitVisible(`[data-screen="working"] [data-progress]`, chromedp.ByQuery), ) rig.Screen(`[data-screen="working"]`, "creating your vault") // And at least one task is marked as under way rather than the list just // sitting there inert. var doing int rig.Run(chromedp.Evaluate( `document.querySelectorAll('[data-progress] li[data-state]').length`, &doing)) if doing == 0 { t.Error("the progress list never marked a task, so it is decoration") } rig.Run(chromedp.WaitVisible(`[data-kit="recovery"]`, chromedp.ByQuery)) }