web: drive the ceremony in a real engine, not just in node
The node suite proves the browser crypto is correct. It cannot prove it runs. CONTRA ships ES modules with no build step, so a syntax error in any one of them takes the whole client down, and node's module loader is not what will find that — only a real engine executing a real page load is. So the scaffold's browser walk grows into the actual ceremony: fill the signup form, wait for Argon2id at 64 MiB to finish in-engine, assert both printed values are on the page and are different from each other, then sign in and land on the signed-in surface. The rig is loud on any console error, failed request or 4xx/5xx along the way. WebCrypto's PKCS#8 import of a raw P-256 scalar is the specific thing worth watching. Engines have differed there, and a difference is not a slow login — it is an account nobody can sign into. It also fixes the scaffolded test for the Config signature, which was compiling only because the build tag kept it out of the ordinary suite. A test that does not compile is a test that is not run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+85
−18
internal/contratest/browser_test.go+85 −18
Intent
diff --git a/internal/contratest/browser_test.go b/internal/contratest/browser_test.go| index eb90888..31605fa 100644 |
| --- a/internal/contratest/browser_test.go |
| +++ b/internal/contratest/browser_test.go |
| @@ -1,16 +1,23 @@ |
| //go:build browser |
| -// The browser drive: the whole app in a real Chromium, 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. |
| -// Not part of the plain suite: |
| +// 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. |
| +// 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 ( |
| @@ -27,27 +34,87 @@ import ( |
| contra "github.com/carlosframework/contra/internal/contra" |
| ) |
| -// TestBrowserWalk is the minimal loud walk: the home screen arrives |
| -// with nothing wrong anywhere the rig watches. Grow it with the app — |
| -// each screen a flow reaches earns a Screen() line, and an expected |
| -// probe (a signed-out 401, say) earns a rig.Allow. |
| -func TestBrowserWalk(t *testing.T) { |
| - rig := harness.New(t, func(origin string) http.Handler { |
| - // The scaffold's own wiring, origin included: the rig hands |
| - // the app its localhost origin before serving — the same seam |
| - // main.go fills from CONTRA_ORIGIN. |
| +func newRig(t *testing.T) *harness.Rig { |
| + t.Helper() |
| + return 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, origin, logger) |
| + mux, err := contra.App(d, contra.Config{Origin: origin, Pepper: testPepper}, logger) |
| if err != nil { |
| t.Fatalf("App: %v", err) |
| } |
| return mux |
| }) |
| +} |
| + |
| +// 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 := 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 + "/signin")) |
| + rig.Screen("body", "signin") |
| +} |
| + |
| +// 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 := newRig(t) |
| + rig.Run(chromedp.Navigate(rig.Origin + "/signup")) |
| + |
| + const email = "browser-walk@example.invalid" |
| + const passphrase = "a generated test passphrase 8f3a1c" |
| + |
| + rig.Run( |
| + chromedp.SendKeys(`input[name=email]`, email, 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), |
| + // Argon2id at 64 MiB takes a couple of seconds in a real engine, so |
| + // this waits on the kit rather than on a timer. |
| + chromedp.WaitVisible(`[data-kit="recovery"]`, chromedp.ByQuery), |
| + ) |
| + rig.Screen("[data-status]", "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") |
| } |