Files
mpc/pkg/api/cas_concurrency_test.go
zeekay e4937f493d fix(mpc): F2/F10/F20/F23 — CAS on approve + enum validation + pagination total
- F2: handleApproveOperation now runs tryApproveOperation inside
  RunInTransaction with CAS guard on (ApprovedBy, Status). Two
  concurrent final-approvers can no longer both satisfy quorum and
  both trigger signing; exactly one wins, the other retries or sees
  "operation not in pending_approval state". 5 retries with backoff,
  then 409 conflict.
- F23: initiator can NEVER self-approve, regardless of how many
  approvals already exist. Previous code only blocked self-approval
  when len(ApprovedBy) == 0.
- F10: list handlers (operations + audit) now call Count() on the
  filtered query before fetching the page; totalItems reflects the
  real count, not len(page). Added page/perPage response fields.
- F20: operation status filter is validated against the spec enum
  (pending_approval, approved, signed, rejected, failed, expired).
  Unknown values return 400 instead of silently dropping the filter.
2025-12-28 10:36:19 -08:00

266 lines
7.5 KiB
Go

package api
// CAS concurrency tests. These exercise the read-modify-write races Red
// identified (F2, F3) using real SQLite-backed storage and parallel goroutines
// all racing to mutate the same row.
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/hanzoai/orm"
"github.com/luxfi/mpc/pkg/db"
)
func newConcurrencyServer(t *testing.T) *Server {
t.Helper()
path := t.TempDir() + "/cas.db"
d, err := db.New("sqlite://"+path, "")
if err != nil {
t.Fatalf("db.New: %v", err)
}
t.Cleanup(func() { d.Close() })
return &Server{db: d, mpc: &mockMPCBackend{signResult: &SignResult{Signature: "sig"}}}
}
// TestConsumeSessionForSign_CASUnderRace — F3 reproducer. 10 concurrent sign
// attempts racing against an operationLimit=5 session must yield exactly 5
// successes and 5 denials (limit reached or conflict after retry exhaustion).
// Pre-fix, we could observe up to 10 successes because read-then-write had
// no atomicity.
func TestConsumeSessionForSign_CASUnderRace(t *testing.T) {
s := newConcurrencyServer(t)
ctx := context.Background()
orgID := "org-cas-1"
walletID := "wallet-cas-1"
userID := "user-cas-1"
// Create the session with operationLimit=5.
limit := 5
sess := orm.New[db.Session](s.db.ORM)
sess.OrgID = orgID
sess.WalletID = walletID
sess.GrantedTo = userID
sess.Scopes = []string{"sign"}
sess.OperationLimit = &limit
sess.OperationsUsed = 0
sess.ValueAccum = "0"
sess.Status = "active"
sess.ExpiresAt = time.Now().Add(time.Hour)
if err := sess.Create(); err != nil {
t.Fatalf("create session: %v", err)
}
sessionID := sess.Id()
const attempts = 10
var wg sync.WaitGroup
var success int64
var denied int64
for i := 0; i < attempts; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, err := s.consumeSessionForSign(ctx, orgID, walletID, userID, sessionID, "")
if err == nil {
atomic.AddInt64(&success, 1)
} else {
atomic.AddInt64(&denied, 1)
}
}()
}
wg.Wait()
if got := atomic.LoadInt64(&success); got > int64(limit) {
t.Fatalf("session over-consumed: %d successes with limit %d", got, limit)
}
if atomic.LoadInt64(&success)+atomic.LoadInt64(&denied) != attempts {
t.Fatalf("lost attempts: success=%d denied=%d", success, denied)
}
// Verify persisted state — OperationsUsed must equal the observed number
// of successes, not more, not less.
final, err := orm.Get[db.Session](s.db.ORM, sessionID)
if err != nil {
t.Fatalf("reload session: %v", err)
}
if int64(final.OperationsUsed) != atomic.LoadInt64(&success) {
t.Fatalf("OperationsUsed=%d, want %d", final.OperationsUsed, success)
}
}
// TestApproveOperation_CASUnderRace — F2 reproducer. Two distinct approvers
// racing against an operation with required=3 must result in exactly one
// final quorum transition (one "finalized=true" approve, one "finalized=false"
// approve since quorum is not yet reached), never both, never zero.
//
// We seed ApprovedBy=[pre-approver] so required=3 means A and B are the 2nd
// and 3rd approvers. Only the 3rd one to commit transitions state to approved.
func TestApproveOperation_CASUnderRace(t *testing.T) {
s := newConcurrencyServer(t)
ctx := context.Background()
orgID := "org-cas-2"
walletID := "wallet-cas-2"
initiator := "user-init"
approverA := "user-A"
approverB := "user-B"
// Seed a policy requiring 3 approvers so that racing A & B produce
// deterministic final state: after both commit, ApprovedBy=[pre, A, B]
// and status transitions to "approved" exactly once.
pol := orm.New[db.Policy](s.db.ORM)
pol.OrgID = orgID
pol.Name = "three-approvers"
pol.Priority = 100
pol.Action = "require_approval"
pol.RequiredApprovers = 3
pol.Enabled = true
pol.Conditions = []byte(`{}`)
if err := pol.Create(); err != nil {
t.Fatalf("create policy: %v", err)
}
tx := orm.New[db.Transaction](s.db.ORM)
tx.OrgID = orgID
tx.TxType = "send"
tx.Chain = "evm"
amt := "1000"
tx.Amount = &amt
tx.WalletID = &walletID
tx.Status = "pending_approval"
tx.InitiatedBy = &initiator
tx.ApprovedBy = []string{"pre-approver"}
if err := tx.Create(); err != nil {
t.Fatalf("create tx: %v", err)
}
opID := tx.Id()
// Sanity-check that loadPolicies returns our seeded policy (requires
// sqlite json1 tag; see Makefile `test` target).
got, _ := s.loadPolicies(ctx, orgID, nil)
if len(got) != 1 {
t.Skipf("loadPolicies returned %d (json1 tag required)", len(got))
}
if got[0].RequiredApprovers != 3 {
t.Fatalf("policy RequiredApprovers=%d, want 3", got[0].RequiredApprovers)
}
// Two approvers race, each trying to be the final 2nd approval.
// (With required=2 and zero prior approvers, both A and B are 1st-of-2
// for some ordering; only one sequence can achieve quorum.)
var wg sync.WaitGroup
resA := make(chan struct {
tx *db.Transaction
finalized bool
err error
}, 1)
resB := make(chan struct {
tx *db.Transaction
finalized bool
err error
}, 1)
approve := func(userID string, out chan struct {
tx *db.Transaction
finalized bool
err error
}) {
defer wg.Done()
const retries = 20
for i := 0; i < retries; i++ {
t0, fin, err := s.tryApproveOperation(ctx, orgID, userID, opID)
if err != nil {
// Conflict errors map to a retry; policy errors do not.
if err == errOperationConflict {
time.Sleep(time.Duration(2+i) * time.Millisecond)
continue
}
}
out <- struct {
tx *db.Transaction
finalized bool
err error
}{t0, fin, err}
return
}
out <- struct {
tx *db.Transaction
finalized bool
err error
}{nil, false, errOperationConflict}
}
wg.Add(2)
go approve(approverA, resA)
go approve(approverB, resB)
wg.Wait()
a := <-resA
b := <-resB
// Exactly one of the two approvals must have finalized.
finalCount := 0
if a.finalized {
finalCount++
}
if b.finalized {
finalCount++
}
if finalCount != 1 {
t.Fatalf("expected exactly one finalized approve, got %d (A.finalized=%v B.finalized=%v A.err=%v B.err=%v)",
finalCount, a.finalized, b.finalized, a.err, b.err)
}
// Persisted state must reflect both approvers present in ApprovedBy and
// the status transitioned to "approved".
final, err := orm.Get[db.Transaction](s.db.ORM, opID)
if err != nil {
t.Fatalf("reload tx: %v", err)
}
// With required=3 and 1 pre-seeded approver, the final state after A+B
// commit should have 3 approvers in ApprovedBy and status "approved".
if final.Status != "approved" {
t.Fatalf("final.Status=%q, want approved", final.Status)
}
if len(final.ApprovedBy) != 3 {
t.Fatalf("ApprovedBy=%v, want 3 entries", final.ApprovedBy)
}
}
// TestInitiatorCannotApprove_Regardless — F23 regression. Even if many other
// approvers have already approved, the initiator must never succeed at
// approving.
func TestInitiatorCannotApprove_Regardless(t *testing.T) {
s := newConcurrencyServer(t)
ctx := context.Background()
orgID := "org-cas-3"
initiator := "user-init-3"
tx := orm.New[db.Transaction](s.db.ORM)
tx.OrgID = orgID
tx.TxType = "send"
tx.Chain = "evm"
tx.Status = "pending_approval"
tx.InitiatedBy = &initiator
// Simulate prior approvers already present.
tx.ApprovedBy = []string{"approver-1", "approver-2"}
if err := tx.Create(); err != nil {
t.Fatalf("create tx: %v", err)
}
opID := tx.Id()
_, _, err := s.tryApproveOperation(ctx, orgID, initiator, opID)
if err == nil {
t.Fatal("expected initiator self-approve to be rejected")
}
herr, ok := err.(*httpError)
if !ok || herr.code != 403 {
t.Fatalf("expected 403 httpError, got %T %v", err, err)
}
}