@@ -4,13 +4,17 @@ import (
44 "context"
55 "errors"
66 "net"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
710 "testing"
811
912 "github.com/stablekernel/crucible/cluster"
1013 "github.com/stablekernel/crucible/durable"
1114 "github.com/stablekernel/crucible/state"
1215 "github.com/stablekernel/crucible/state/expr"
1316 "github.com/stablekernel/crucible/transport"
17+ "github.com/stablekernel/crucible/wasm"
1418 "google.golang.org/grpc"
1519 "google.golang.org/grpc/credentials/insecure"
1620 "google.golang.org/grpc/test/bufconn"
@@ -84,6 +88,119 @@ func TestE2E_DurableCELAssignSurvivesRecovery(t *testing.T) {
8488 }
8589}
8690
91+ // ---- wasm ⊗ state ⊗ durable: a WASM-backed guard survives record/replay ----
92+
93+ type approvalOrder struct {
94+ Amount int64 `json:"amount"`
95+ Status string `json:"status"`
96+ }
97+
98+ // buildApprovalGuest compiles the approval guard guest to wasip1/wasm with the
99+ // standard Go toolchain (no committed binary, no TinyGo) and returns its bytes,
100+ // mirroring the wasm package's own guest build so the e2e joint compiles its
101+ // guard the same proven way.
102+ func buildApprovalGuest (t * testing.T ) []byte {
103+ t .Helper ()
104+ dir := t .TempDir ()
105+ out := filepath .Join (dir , "approval.wasm" )
106+ cmd := exec .Command ("go" , "build" , "-buildmode=c-shared" , "-o" , out , "./testdata/approvalguest" )
107+ cmd .Env = append (os .Environ (), "GOOS=wasip1" , "GOARCH=wasm" )
108+ if buildOut , err := cmd .CombinedOutput (); err != nil {
109+ t .Fatalf ("build approval guest: %v\n %s" , err , buildOut )
110+ }
111+ b , err := os .ReadFile (out )
112+ if err != nil {
113+ t .Fatalf ("read built guest: %v" , err )
114+ }
115+ return b
116+ }
117+
118+ // approvalMachine forges an order machine whose approve transition is gated by a
119+ // WASM-backed guard (amount >= 100), authored through the full production flow
120+ // (Forge → ToJSON → LoadFromJSON → Provide) so the foreign-engine guard resolves
121+ // exactly like an in-tree one.
122+ func approvalMachine (t * testing.T , mod * wasm.Module ) * state.Machine [string , string , approvalOrder ] {
123+ t .Helper ()
124+ reg := state .NewRegistry [approvalOrder ]()
125+ node := wasm .Guard [string ](reg , "approved" , mod )
126+
127+ def := state.Forge [string , string , approvalOrder ]("approval" ).
128+ Guard ("approved" , func (state.GuardCtx [approvalOrder ]) bool { return false }). // stub, replaced by Provide
129+ State ("pending" ).
130+ State ("approved" ).
131+ Initial ("pending" ).
132+ Transition ("pending" ).On ("approve" ).GoTo ("approved" ).WhenExpr (node ).
133+ Quench ()
134+
135+ js , err := def .ToJSON ()
136+ if err != nil {
137+ t .Fatalf ("ToJSON: %v" , err )
138+ }
139+ ir , err := state.LoadFromJSON [string , string , approvalOrder ](js )
140+ if err != nil {
141+ t .Fatalf ("LoadFromJSON: %v" , err )
142+ }
143+ return ir .Provide (reg ).Quench ()
144+ }
145+
146+ // TestE2E_WASMGuardedDurableTransitionSurvivesRecovery drives a durable instance
147+ // through a transition gated by a WebAssembly-backed guard, then recovers it from
148+ // the store — proving a foreign-engine guard composes with the durable
149+ // record/replay seam: the approved order persists at approved and replays
150+ // deterministically, and a below-threshold order is blocked through the same
151+ // WASM evaluator. It is hermetic (the guest is built on demand with the Go wasm
152+ // toolchain) and deterministic (the guard is a pure predicate over context).
153+ func TestE2E_WASMGuardedDurableTransitionSurvivesRecovery (t * testing.T ) {
154+ ctx := context .Background ()
155+ mod , err := wasm .Compile (ctx , buildApprovalGuest (t ))
156+ if err != nil {
157+ t .Fatalf ("compile guard: %v" , err )
158+ }
159+ t .Cleanup (func () { _ = mod .Close (ctx ) })
160+
161+ m := approvalMachine (t , mod )
162+ store := durable .NewMemStore ()
163+ runner := durable .NewRunner (m , store )
164+
165+ // An at-threshold order: the WASM guard admits it, the durable runner records
166+ // the transition, and recovery replays it to the same approved state.
167+ const okID = "order-ok"
168+ okH , err := runner .Start (ctx , okID , approvalOrder {Amount : 150 , Status : "pending" }, state .WithInitialState ("pending" ))
169+ if err != nil {
170+ t .Fatalf ("start ok order: %v" , err )
171+ }
172+ if _ , err = okH .Fire (ctx , "approve" ); err != nil {
173+ t .Fatalf ("fire approve: %v" , err )
174+ }
175+ if got := okH .Instance ().Current (); got != "approved" {
176+ t .Fatalf ("WASM guard should admit amount 150; current=%q, want approved" , got )
177+ }
178+
179+ rec , err := durable .Recover (ctx , m , store , okID )
180+ if err != nil {
181+ t .Fatalf ("recover: %v" , err )
182+ }
183+ if got := rec .Instance ().Current (); got != "approved" {
184+ t .Fatalf ("recovered state = %q, want approved (WASM-guarded transition replayed)" , got )
185+ }
186+
187+ // A below-threshold order: the same WASM guard blocks it, so the transition is
188+ // rejected (a GuardFailedError) and the durable instance never leaves pending.
189+ const lowID = "order-low"
190+ lowH , err := runner .Start (ctx , lowID , approvalOrder {Amount : 50 , Status : "pending" }, state .WithInitialState ("pending" ))
191+ if err != nil {
192+ t .Fatalf ("start low order: %v" , err )
193+ }
194+ _ , err = lowH .Fire (ctx , "approve" )
195+ var guardErr * state.GuardFailedError
196+ if ! errors .As (err , & guardErr ) {
197+ t .Fatalf ("fire approve (low) error = %v, want a *state.GuardFailedError from the WASM guard" , err )
198+ }
199+ if got := lowH .Instance ().Current (); got != "pending" {
200+ t .Fatalf ("WASM guard should block amount 50; current=%q, want pending" , got )
201+ }
202+ }
203+
87204// ---- cluster ⊗ transport ⊗ supervisor: supervised remote actor over gRPC ----
88205
89206type pinger struct {}
0 commit comments