//go:build browser package movementtest import ( "crypto/sha256" "encoding/hex" "fmt" "io" "log/slog" "net/http" "path/filepath" "testing" "github.com/carlosframework/rastrillo/db" "github.com/carlosframework/rastrillo/harness" "github.com/chromedp/cdproto/runtime" "github.com/chromedp/chromedp" movement "amadan.net/carlos/movement/internal/movement" ) // awaitPromise makes Evaluate wait for the expression's promise instead of // handing back a pending object. Every script here uses dynamic import, which // is asynchronous by nature. chromedp exports no such option of its own. func awaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) } func goSHA256(b []byte) []byte { sum := sha256.Sum256(b) return sum[:] } // This is the gate the whole proof-of-work scheme rests on. // // A Go-only test proves the verifier and nothing else. The failure it cannot // see is the one that actually happens: the browser and the server building // a different preimage from the same inputs — a stray Unicode case fold, a // different separator, a counter formatted another way — so every honest // visitor's solution is rejected as too short, with no error they can read // and nothing in the logs to explain it. The only way to know the two agree // is to run the shipped module in a real browser and check its answer here. // // It imports powcore.js, which is exactly what the Worker runs. func powRig(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() }) movement.SetLogger(logger) env := testEnv() env.Origin = origin mux, err := movement.App(d, env) if err != nil { t.Fatalf("App: %v", err) } return mux }) } // browserSolve runs the shipped solver in the page and returns its counter. const browserSolve = `(async () => { const m = await import("/static/powcore.js"); return m.solve(%q, %q, %d, null); })()` func TestBrowserSolutionSatisfiesGoVerifier(t *testing.T) { rig := powRig(t) rig.Run(chromedp.Navigate(rig.Origin + "/")) // Low difficulty: the property under test is agreement, not cost. A // realistic 18 bits would make this test take as long as a real signup // for no extra information. const difficulty = 12 const nonce = "0f1e2d3c4b5a69788796a5b4c3d2e1f0" const email = "Alice@Example.COM" var counter string rig.Run(chromedp.Evaluate( fmt.Sprintf(browserSolve, nonce, email, difficulty), &counter, awaitPromise, )) if counter == "" { t.Fatal("the browser returned no counter") } if !movement.VerifyPow(nonce, email, counter, difficulty) { t.Fatalf("the browser solved %q for %q but Go rejects it: the two sides "+ "are building different preimages", counter, email) } } // The address is inside the hash, and this is what that buys. If the binding // were ever dropped — or built differently on the two sides — one solve would // buy every address in a list. func TestBrowserSolutionDoesNotTransfer(t *testing.T) { rig := powRig(t) rig.Run(chromedp.Navigate(rig.Origin + "/")) const difficulty = 12 const nonce = "0f1e2d3c4b5a69788796a5b4c3d2e1f0" var counter string rig.Run(chromedp.Evaluate( fmt.Sprintf(browserSolve, nonce, "alice@example.com", difficulty), &counter, awaitPromise, )) if movement.VerifyPow(nonce, "bob@example.com", counter, difficulty) { t.Fatal("a solution the browser found for one address verified for another") } } // The two implementations of SHA-256 must agree byte for byte, not merely // agree about which counters pass. This catches a divergence directly rather // than waiting for one to show up as a rejected signup. func TestBrowserSHA256MatchesGo(t *testing.T) { rig := powRig(t) rig.Run(chromedp.Navigate(rig.Origin + "/")) inputs := []string{ "", "a", "abc", "the quick brown fox jumps over the lazy dog", // 55, 56 and 64 bytes: the padding boundaries, where a transcribed // SHA-256 goes wrong if it is going to. "5555555555555555555555555555555555555555555555555555555", "66666666666666666666666666666666666666666666666666666666", "6666666666666666666666666666666666666666666666666666666666666666", } const digestJS = `(async () => { const m = await import("/static/sha256.js"); const b = new TextEncoder().encode(%q); return m.hex(m.sha256(b, b.length)); })()` for _, in := range inputs { var got string rig.Run(chromedp.Evaluate(fmt.Sprintf(digestJS, in), &got, awaitPromise)) want := hex.EncodeToString(goSHA256([]byte(in))) if got != want { t.Errorf("sha256(%q):\n browser %s\n go %s", in, got, want) } } }