carlos / contra Public

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>
Paul Campbell pushed by paul@keymail.dev 82664653a5949c4c8b4acb44c0f3bb36e05a33ce parent f63ef12
1 file changed, +85 −18
  • internal/contratest/browser_test.go +85 −18
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")
}