mirror of
https://github.com/luxfi/bridge.git
synced 2026-09-24 00:26:46 +00:00
879 lines
28 KiB
Go
879 lines
28 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// =============================================================================
|
|
// fakeMpcd — minimal upstream stub
|
|
// =============================================================================
|
|
|
|
// fakeMpcd mimics the two endpoints the proxy hits:
|
|
//
|
|
// POST /v1/mpc/wallets/{id}/sessions
|
|
// POST /v1/mpc/sign
|
|
//
|
|
// Each test configures behavior by flipping flags on the stub. The
|
|
// stub asserts (a) the bearer token matches what the proxy was
|
|
// configured with, and (b) request bodies decode to the expected
|
|
// shape. Anything else fails the test fast.
|
|
type fakeMpcd struct {
|
|
server *httptest.Server
|
|
|
|
expectedToken string
|
|
|
|
// flags
|
|
sessionFails atomic.Bool
|
|
signFails atomic.Bool
|
|
sessionCode int // override status code for session
|
|
signCode int // override status code for sign
|
|
sessionBody []byte // override body for session (raw)
|
|
signBody []byte // override body for sign (raw)
|
|
|
|
// invariants captured by handlers
|
|
lastSessionScopes []string
|
|
lastSignReq mpcdSignReq
|
|
|
|
sessionCalls atomic.Uint64
|
|
signCalls atomic.Uint64
|
|
|
|
fixedSessionID string
|
|
fixedSig string
|
|
}
|
|
|
|
func newFakeMpcd(t *testing.T, token string) *fakeMpcd {
|
|
t.Helper()
|
|
f := &fakeMpcd{
|
|
expectedToken: token,
|
|
fixedSessionID: "ses-abc123",
|
|
fixedSig: "0x" +
|
|
"1111111111111111111111111111111111111111111111111111111111111111" +
|
|
"2222222222222222222222222222222222222222222222222222222222222222" +
|
|
"00",
|
|
}
|
|
f.server = httptest.NewServer(http.HandlerFunc(f.handle))
|
|
t.Cleanup(f.server.Close)
|
|
return f
|
|
}
|
|
|
|
func (f *fakeMpcd) URL() string { return f.server.URL }
|
|
|
|
func (f *fakeMpcd) handle(w http.ResponseWriter, r *http.Request) {
|
|
// Bearer token check — every protected endpoint must carry it.
|
|
if f.expectedToken != "" {
|
|
got := r.Header.Get("Authorization")
|
|
want := "Bearer " + f.expectedToken
|
|
if got != want {
|
|
http.Error(w, `{"error":"unauthorized: bad bearer"}`, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
}
|
|
switch {
|
|
case strings.HasSuffix(r.URL.Path, "/sessions") && strings.HasPrefix(r.URL.Path, "/v1/mpc/wallets/"):
|
|
f.handleSession(w, r)
|
|
case r.URL.Path == "/v1/mpc/sign":
|
|
f.handleSign(w, r)
|
|
default:
|
|
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
|
}
|
|
}
|
|
|
|
func (f *fakeMpcd) handleSession(w http.ResponseWriter, r *http.Request) {
|
|
f.sessionCalls.Add(1)
|
|
if f.sessionFails.Load() {
|
|
code := f.sessionCode
|
|
if code == 0 {
|
|
code = http.StatusForbidden
|
|
}
|
|
w.WriteHeader(code)
|
|
if len(f.sessionBody) > 0 {
|
|
_, _ = w.Write(f.sessionBody)
|
|
} else {
|
|
_, _ = w.Write([]byte(`{"error":"session denied (test stub)"}`))
|
|
}
|
|
return
|
|
}
|
|
var req mpcdSessionReq
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
f.lastSessionScopes = req.Scopes
|
|
if req.ExpiresAt.IsZero() || !req.ExpiresAt.After(time.Now()) {
|
|
http.Error(w, `{"error":"expiresAt must be in the future"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(mpcdSessionResp{
|
|
SessionID: f.fixedSessionID,
|
|
Status: "active",
|
|
})
|
|
}
|
|
|
|
func (f *fakeMpcd) handleSign(w http.ResponseWriter, r *http.Request) {
|
|
f.signCalls.Add(1)
|
|
if f.signFails.Load() {
|
|
code := f.signCode
|
|
if code == 0 {
|
|
code = http.StatusInternalServerError
|
|
}
|
|
w.WriteHeader(code)
|
|
if len(f.signBody) > 0 {
|
|
_, _ = w.Write(f.signBody)
|
|
} else {
|
|
_, _ = w.Write([]byte(`{"error":"sign denied (test stub)"}`))
|
|
}
|
|
return
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&f.lastSignReq)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(mpcdSignResp{
|
|
Signature: f.fixedSig,
|
|
})
|
|
}
|
|
|
|
// newProxyAgainst wires a Proxy against the given fake mpcd.
|
|
func newProxyAgainst(f *fakeMpcd) *Proxy {
|
|
return &Proxy{
|
|
UpstreamURL: f.URL(),
|
|
UpstreamToken: f.expectedToken,
|
|
SessionTTL: 30 * time.Second,
|
|
HTTPClient: &http.Client{Timeout: 5 * time.Second},
|
|
}
|
|
}
|
|
|
|
// postBridgeSign drives one /sign call against the proxy and returns
|
|
// the status code + decoded bridge-shape response.
|
|
func postBridgeSign(t *testing.T, p *Proxy, req bridgeSignReq) (int, bridgeSignResp) {
|
|
t.Helper()
|
|
body, _ := json.Marshal(req)
|
|
r := httptest.NewRequest(http.MethodPost, "/sign", bytes.NewReader(body))
|
|
r.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
p.handleSign(w, r)
|
|
out, _ := io.ReadAll(w.Body)
|
|
var resp bridgeSignResp
|
|
if err := json.Unmarshal(out, &resp); err != nil {
|
|
t.Fatalf("decode proxy resp: %v body=%s", err, out)
|
|
}
|
|
return w.Code, resp
|
|
}
|
|
|
|
// =============================================================================
|
|
// Happy path
|
|
// =============================================================================
|
|
|
|
func TestProxy_HappyPath(t *testing.T) {
|
|
fake := newFakeMpcd(t, "proxy-token-xyz")
|
|
p := newProxyAgainst(fake)
|
|
|
|
status, resp := postBridgeSign(t, p, bridgeSignReq{
|
|
OrgID: "bridge",
|
|
WalletID: "wallet-abc",
|
|
Message: "0xdeadbeef" + strings.Repeat("00", 28), // 32-byte sighash
|
|
})
|
|
if status != http.StatusOK {
|
|
t.Fatalf("status=%d, resp=%+v", status, resp)
|
|
}
|
|
if resp.ResultType != "success" {
|
|
t.Errorf("result_type=%q want success", resp.ResultType)
|
|
}
|
|
if resp.Signature != fake.fixedSig {
|
|
t.Errorf("signature=%q want %q", resp.Signature, fake.fixedSig)
|
|
}
|
|
if resp.SessionID != fake.fixedSessionID {
|
|
t.Errorf("session_id=%q want %q", resp.SessionID, fake.fixedSessionID)
|
|
}
|
|
if resp.WalletID != "wallet-abc" {
|
|
t.Errorf("wallet_id=%q want wallet-abc", resp.WalletID)
|
|
}
|
|
if fake.sessionCalls.Load() != 1 || fake.signCalls.Load() != 1 {
|
|
t.Errorf("expected 1 session + 1 sign call, got %d/%d",
|
|
fake.sessionCalls.Load(), fake.signCalls.Load())
|
|
}
|
|
if len(fake.lastSessionScopes) != 1 || fake.lastSessionScopes[0] != "sign" {
|
|
t.Errorf("session scopes=%v want [sign]", fake.lastSessionScopes)
|
|
}
|
|
if fake.lastSignReq.SessionID != fake.fixedSessionID {
|
|
t.Errorf("sign call session_id=%q want %q",
|
|
fake.lastSignReq.SessionID, fake.fixedSessionID)
|
|
}
|
|
if fake.lastSignReq.Encoding != "hex" {
|
|
t.Errorf("sign encoding=%q want hex", fake.lastSignReq.Encoding)
|
|
}
|
|
if got := p.Stats(); got.SignSuccess != 1 || got.SignRequests != 1 {
|
|
t.Errorf("stats=%+v", got)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Request validation
|
|
// =============================================================================
|
|
|
|
func TestProxy_RejectsMissingWalletID(t *testing.T) {
|
|
fake := newFakeMpcd(t, "")
|
|
p := newProxyAgainst(fake)
|
|
|
|
status, resp := postBridgeSign(t, p, bridgeSignReq{Message: "0xab"})
|
|
if status != http.StatusBadRequest {
|
|
t.Fatalf("status=%d want 400", status)
|
|
}
|
|
if resp.ResultType != "error" || !strings.Contains(resp.Error, "wallet_id") {
|
|
t.Errorf("resp=%+v", resp)
|
|
}
|
|
if fake.sessionCalls.Load() != 0 {
|
|
t.Errorf("upstream was called for invalid request")
|
|
}
|
|
}
|
|
|
|
func TestProxy_RejectsMissingMessage(t *testing.T) {
|
|
fake := newFakeMpcd(t, "")
|
|
p := newProxyAgainst(fake)
|
|
|
|
status, resp := postBridgeSign(t, p, bridgeSignReq{WalletID: "w-1"})
|
|
if status != http.StatusBadRequest {
|
|
t.Fatalf("status=%d want 400", status)
|
|
}
|
|
if resp.ResultType != "error" || !strings.Contains(resp.Error, "message") {
|
|
t.Errorf("resp=%+v", resp)
|
|
}
|
|
}
|
|
|
|
func TestProxy_503WhenUpstreamNotConfigured(t *testing.T) {
|
|
p := &Proxy{} // no UpstreamURL
|
|
status, resp := postBridgeSign(t, p, bridgeSignReq{WalletID: "w", Message: "0xab"})
|
|
if status != http.StatusServiceUnavailable {
|
|
t.Fatalf("status=%d want 503", status)
|
|
}
|
|
if !strings.Contains(resp.Error, "not configured") {
|
|
t.Errorf("error msg=%q", resp.Error)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Upstream failure passthrough
|
|
// =============================================================================
|
|
|
|
func TestProxy_SessionDeniedSurfacesAs403(t *testing.T) {
|
|
fake := newFakeMpcd(t, "tok")
|
|
fake.sessionFails.Store(true)
|
|
fake.sessionCode = http.StatusForbidden
|
|
fake.sessionBody = []byte(`{"error":"requested session grant exceeds wallet policy"}`)
|
|
p := newProxyAgainst(fake)
|
|
|
|
status, resp := postBridgeSign(t, p, bridgeSignReq{WalletID: "w", Message: "0xab"})
|
|
if status != http.StatusForbidden {
|
|
t.Fatalf("status=%d want 403", status)
|
|
}
|
|
if !strings.Contains(resp.Error, "session_mint") {
|
|
t.Errorf("error should label upstream op: %q", resp.Error)
|
|
}
|
|
if !strings.Contains(resp.Error, "exceeds wallet policy") {
|
|
t.Errorf("upstream detail not preserved: %q", resp.Error)
|
|
}
|
|
if fake.signCalls.Load() != 0 {
|
|
t.Errorf("sign should not be called when session mint fails")
|
|
}
|
|
if got := p.Stats(); got.SessionErrors != 1 || got.SignFailures != 1 {
|
|
t.Errorf("stats=%+v", got)
|
|
}
|
|
}
|
|
|
|
func TestProxy_SignFailureSurfacesAs502(t *testing.T) {
|
|
fake := newFakeMpcd(t, "tok")
|
|
fake.signFails.Store(true)
|
|
fake.signCode = http.StatusInternalServerError
|
|
fake.signBody = []byte(`{"error":"signing failed: ceremony aborted"}`)
|
|
p := newProxyAgainst(fake)
|
|
|
|
status, resp := postBridgeSign(t, p, bridgeSignReq{WalletID: "w", Message: "0xab"})
|
|
if status != http.StatusBadGateway {
|
|
t.Fatalf("status=%d want 502 (upstream 5xx folds to 502)", status)
|
|
}
|
|
if resp.ErrorCode != http.StatusInternalServerError {
|
|
t.Errorf("error_code=%d want upstream 500", resp.ErrorCode)
|
|
}
|
|
if !strings.Contains(resp.Error, "ceremony aborted") {
|
|
t.Errorf("upstream detail not preserved: %q", resp.Error)
|
|
}
|
|
}
|
|
|
|
func TestProxy_UpstreamAuthFailureSurfacesAs401(t *testing.T) {
|
|
fake := newFakeMpcd(t, "real-token")
|
|
p := newProxyAgainst(fake)
|
|
p.UpstreamToken = "wrong-token" // proxy was misconfigured
|
|
|
|
status, resp := postBridgeSign(t, p, bridgeSignReq{WalletID: "w", Message: "0xab"})
|
|
if status != http.StatusUnauthorized {
|
|
t.Fatalf("status=%d want 401 (auth failure passes through)", status)
|
|
}
|
|
if !strings.Contains(resp.Error, "session_mint") {
|
|
t.Errorf("auth failure should be labelled session_mint (first call): %q", resp.Error)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Signature shape — guard against silent corruption
|
|
// =============================================================================
|
|
|
|
func TestProxy_RejectsResponseWithRSButNoConcatenatedSignature(t *testing.T) {
|
|
fake := newFakeMpcd(t, "tok")
|
|
// Override sign body to return r + s but no `signature` field.
|
|
fake.signBody, _ = json.Marshal(mpcdSignResp{
|
|
R: "0xaa",
|
|
S: "0xbb",
|
|
})
|
|
fake.signCode = http.StatusOK
|
|
// Custom handler that emits the override body even on success.
|
|
old := fake.server.Config.Handler
|
|
fake.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/v1/mpc/sign" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write(fake.signBody)
|
|
return
|
|
}
|
|
old.ServeHTTP(w, r)
|
|
})
|
|
|
|
p := newProxyAgainst(fake)
|
|
status, resp := postBridgeSign(t, p, bridgeSignReq{WalletID: "w", Message: "0xab"})
|
|
if status != http.StatusBadGateway {
|
|
t.Fatalf("status=%d want 502 (cannot derive v)", status)
|
|
}
|
|
if !strings.Contains(resp.Error, "cannot derive v") {
|
|
t.Errorf("error should explain missing v byte: %q", resp.Error)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Concurrency
|
|
// =============================================================================
|
|
|
|
func TestProxy_ConcurrentSignsBothSucceed(t *testing.T) {
|
|
fake := newFakeMpcd(t, "tok")
|
|
p := newProxyAgainst(fake)
|
|
|
|
const N = 10
|
|
errs := make(chan error, N)
|
|
for i := 0; i < N; i++ {
|
|
go func() {
|
|
body, _ := json.Marshal(bridgeSignReq{
|
|
WalletID: "w-cc",
|
|
Message: "0xab",
|
|
})
|
|
r := httptest.NewRequest(http.MethodPost, "/sign", bytes.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
p.handleSign(w, r)
|
|
if w.Code != http.StatusOK {
|
|
errs <- httpErr(w.Code)
|
|
return
|
|
}
|
|
errs <- nil
|
|
}()
|
|
}
|
|
deadline := time.After(5 * time.Second)
|
|
for i := 0; i < N; i++ {
|
|
select {
|
|
case err := <-errs:
|
|
if err != nil {
|
|
t.Errorf("concurrent call %d failed: %v", i, err)
|
|
}
|
|
case <-deadline:
|
|
t.Fatalf("concurrent calls didn't finish in time (received %d/%d)", i, N)
|
|
}
|
|
}
|
|
if fake.sessionCalls.Load() != N || fake.signCalls.Load() != N {
|
|
t.Errorf("expected %d/%d upstream calls, got %d/%d",
|
|
N, N, fake.sessionCalls.Load(), fake.signCalls.Load())
|
|
}
|
|
if got := p.Stats(); got.SignSuccess != N {
|
|
t.Errorf("success count=%d want %d", got.SignSuccess, N)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Method gate + health
|
|
// =============================================================================
|
|
|
|
func TestProxy_RejectsGET(t *testing.T) {
|
|
fake := newFakeMpcd(t, "tok")
|
|
p := newProxyAgainst(fake)
|
|
r := httptest.NewRequest(http.MethodGet, "/sign", nil)
|
|
w := httptest.NewRecorder()
|
|
p.handleSign(w, r)
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("status=%d want 405", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestProxy_HealthDoesNotCallUpstream(t *testing.T) {
|
|
fake := newFakeMpcd(t, "tok")
|
|
p := newProxyAgainst(fake)
|
|
r := httptest.NewRequest(http.MethodGet, "/health", nil)
|
|
w := httptest.NewRecorder()
|
|
p.handleHealth(w, r)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status=%d want 200", w.Code)
|
|
}
|
|
var body map[string]any
|
|
_ = json.Unmarshal(w.Body.Bytes(), &body)
|
|
if got, _ := body["sign_upstream_configured"].(bool); !got {
|
|
t.Errorf("expected sign_upstream_configured=true, body=%v", body)
|
|
}
|
|
if got, _ := body["keygen_upstream_configured"].(bool); !got {
|
|
t.Errorf("expected keygen_upstream_configured=true (falls back to sign upstream), body=%v", body)
|
|
}
|
|
if mode, _ := body["sign_mode"].(string); mode != "translate" {
|
|
t.Errorf("default sign_mode=%q want translate", mode)
|
|
}
|
|
if fake.sessionCalls.Load() != 0 || fake.signCalls.Load() != 0 {
|
|
t.Errorf("/health must not call upstream")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Context cancellation
|
|
// =============================================================================
|
|
|
|
func TestProxy_RespectsCallerContextCancel(t *testing.T) {
|
|
// A fake mpcd that hangs on the sign call simulates a slow ceremony.
|
|
hang := make(chan struct{})
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/v1/mpc/wallets/w/sessions":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(mpcdSessionResp{SessionID: "s", Status: "active"})
|
|
case "/v1/mpc/sign":
|
|
<-hang
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
defer close(hang)
|
|
|
|
p := &Proxy{
|
|
UpstreamURL: srv.URL,
|
|
UpstreamToken: "tok",
|
|
SessionTTL: 30 * time.Second,
|
|
HTTPClient: &http.Client{Timeout: 5 * time.Second},
|
|
}
|
|
|
|
body, _ := json.Marshal(bridgeSignReq{WalletID: "w", Message: "0xab"})
|
|
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
|
defer cancel()
|
|
r := httptest.NewRequest(http.MethodPost, "/sign", bytes.NewReader(body)).WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
p.handleSign(w, r)
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
// Ctx-cancel means we return 502 (upstream error wrap from
|
|
// the underlying http.Do failure). The exact code is less
|
|
// important than the fact that we don't hang forever.
|
|
if w.Code == http.StatusOK {
|
|
t.Errorf("expected non-200 after cancel, got 200")
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatalf("handler did not return after context cancel")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// fakeKeygenUpstream — minimal /keygen stub
|
|
// =============================================================================
|
|
|
|
// fakeKeygenUpstream serves POST /keygen in the bridge wire shape
|
|
// (mirrors mpcd's internal API at port :6000). Tests configure
|
|
// behavior by flipping flags before issuing a request.
|
|
type fakeKeygenUpstream struct {
|
|
server *httptest.Server
|
|
|
|
expectedToken string
|
|
|
|
keygenFails atomic.Bool
|
|
keygenCode int // override status code on failure
|
|
keygenBody []byte // override body (success or failure)
|
|
|
|
calls atomic.Uint64
|
|
|
|
// last request observed — for assertions on passthrough fidelity.
|
|
lastReqBody atomic.Value // []byte
|
|
}
|
|
|
|
func newFakeKeygenUpstream(t *testing.T, token string) *fakeKeygenUpstream {
|
|
t.Helper()
|
|
f := &fakeKeygenUpstream{expectedToken: token}
|
|
f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if f.expectedToken != "" {
|
|
got := r.Header.Get("Authorization")
|
|
want := "Bearer " + f.expectedToken
|
|
if got != want {
|
|
http.Error(w, `{"error":"unauthorized: bad bearer"}`, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
}
|
|
if r.URL.Path != "/keygen" {
|
|
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
f.calls.Add(1)
|
|
body, _ := io.ReadAll(r.Body)
|
|
f.lastReqBody.Store(body)
|
|
|
|
if f.keygenFails.Load() {
|
|
code := f.keygenCode
|
|
if code == 0 {
|
|
code = http.StatusServiceUnavailable
|
|
}
|
|
w.WriteHeader(code)
|
|
if len(f.keygenBody) > 0 {
|
|
_, _ = w.Write(f.keygenBody)
|
|
} else {
|
|
_, _ = w.Write([]byte(`{"error":"peers not ready","result_type":"error"}`))
|
|
}
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if len(f.keygenBody) > 0 {
|
|
_, _ = w.Write(f.keygenBody)
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"wallet_id": "bridge-it-1",
|
|
"ecdsa_pub_key": "04aabb",
|
|
"eddsa_pub_key": "0001",
|
|
"eth_address": "0x1111111111111111111111111111111111111111",
|
|
"btc_address": "tb1qfakeaddress",
|
|
"sol_address": "SolFake111",
|
|
"result_type": "success",
|
|
})
|
|
}))
|
|
t.Cleanup(f.server.Close)
|
|
return f
|
|
}
|
|
|
|
func (f *fakeKeygenUpstream) URL() string { return f.server.URL }
|
|
func (f *fakeKeygenUpstream) LastReqBody() []byte {
|
|
if v := f.lastReqBody.Load(); v != nil {
|
|
return v.([]byte)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// postBridgeKeygen drives one /keygen call against the proxy and
|
|
// returns the raw status + body (the proxy passes body through
|
|
// verbatim, so we don't decode into a typed struct).
|
|
func postBridgeKeygen(t *testing.T, p *Proxy, body []byte) (int, []byte) {
|
|
t.Helper()
|
|
r := httptest.NewRequest(http.MethodPost, "/keygen", bytes.NewReader(body))
|
|
r.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
p.handleKeygen(w, r)
|
|
out, _ := io.ReadAll(w.Body)
|
|
return w.Code, out
|
|
}
|
|
|
|
// =============================================================================
|
|
// /keygen passthrough — happy path
|
|
// =============================================================================
|
|
|
|
func TestProxy_KeygenPassthroughSuccess(t *testing.T) {
|
|
keygenFake := newFakeKeygenUpstream(t, "kg-token")
|
|
signFake := newFakeMpcd(t, "sign-token")
|
|
|
|
p := newProxyAgainst(signFake)
|
|
p.KeygenUpstreamURL = keygenFake.URL()
|
|
p.KeygenUpstreamToken = "kg-token"
|
|
|
|
reqBody := []byte(`{"org_id":"bridge","wallet_id":""}`)
|
|
status, body := postBridgeKeygen(t, p, reqBody)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", status, body)
|
|
}
|
|
if !strings.Contains(string(body), `"result_type":"success"`) {
|
|
t.Errorf("upstream body not forwarded: %s", body)
|
|
}
|
|
if !strings.Contains(string(body), `"eth_address":"0x1111111111111111111111111111111111111111"`) {
|
|
t.Errorf("expected eth address in body, got %s", body)
|
|
}
|
|
if keygenFake.calls.Load() != 1 {
|
|
t.Errorf("expected 1 keygen upstream call, got %d", keygenFake.calls.Load())
|
|
}
|
|
// Body forwarded verbatim — the bridge passes org_id, we should see it.
|
|
if got := keygenFake.LastReqBody(); string(got) != string(reqBody) {
|
|
t.Errorf("upstream saw body %q, want %q", got, reqBody)
|
|
}
|
|
// Sign upstream untouched.
|
|
if signFake.sessionCalls.Load() != 0 || signFake.signCalls.Load() != 0 {
|
|
t.Errorf("/keygen must not call sign upstream")
|
|
}
|
|
if got := p.Stats(); got.KeygenSuccess != 1 || got.KeygenRequests != 1 {
|
|
t.Errorf("stats=%+v", got)
|
|
}
|
|
}
|
|
|
|
// TestProxy_KeygenFallsBackToSignUpstream confirms that when
|
|
// KeygenUpstreamURL is empty, /keygen targets UpstreamURL/keygen with
|
|
// the sign upstream's token.
|
|
func TestProxy_KeygenFallsBackToSignUpstream(t *testing.T) {
|
|
// Build a single upstream that handles both /keygen AND the
|
|
// sign endpoints — simulates an mpcd internal API serving both.
|
|
keygenFake := newFakeKeygenUpstream(t, "shared-token")
|
|
p := &Proxy{
|
|
UpstreamURL: keygenFake.URL(),
|
|
UpstreamToken: "shared-token",
|
|
// KeygenUpstreamURL intentionally left empty.
|
|
SessionTTL: 30 * time.Second,
|
|
HTTPClient: &http.Client{Timeout: 5 * time.Second},
|
|
}
|
|
|
|
status, body := postBridgeKeygen(t, p, []byte(`{"org_id":"bridge","wallet_id":""}`))
|
|
if status != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", status, body)
|
|
}
|
|
if keygenFake.calls.Load() != 1 {
|
|
t.Errorf("expected fallback to hit sign upstream's /keygen, got %d calls",
|
|
keygenFake.calls.Load())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// /keygen passthrough — failure forwarding
|
|
// =============================================================================
|
|
|
|
func TestProxy_KeygenForwardsUpstreamErrorVerbatim(t *testing.T) {
|
|
keygenFake := newFakeKeygenUpstream(t, "kg-token")
|
|
keygenFake.keygenFails.Store(true)
|
|
keygenFake.keygenCode = http.StatusServiceUnavailable
|
|
keygenFake.keygenBody = []byte(`{"error":"peers not ready","result_type":"error"}`)
|
|
|
|
p := &Proxy{
|
|
UpstreamURL: "ignored-in-this-test",
|
|
UpstreamToken: "ignored",
|
|
KeygenUpstreamURL: keygenFake.URL(),
|
|
KeygenUpstreamToken: "kg-token",
|
|
HTTPClient: &http.Client{Timeout: 5 * time.Second},
|
|
}
|
|
|
|
status, body := postBridgeKeygen(t, p, []byte(`{"org_id":"bridge","wallet_id":""}`))
|
|
if status != http.StatusServiceUnavailable {
|
|
t.Fatalf("status=%d want 503 (upstream forwarded verbatim)", status)
|
|
}
|
|
if !strings.Contains(string(body), `"peers not ready"`) {
|
|
t.Errorf("upstream error body not forwarded: %s", body)
|
|
}
|
|
if got := p.Stats(); got.KeygenFailures != 1 {
|
|
t.Errorf("stats=%+v want KeygenFailures=1", got)
|
|
}
|
|
}
|
|
|
|
func TestProxy_KeygenRejectsGET(t *testing.T) {
|
|
keygenFake := newFakeKeygenUpstream(t, "kg-token")
|
|
p := &Proxy{
|
|
UpstreamURL: "x",
|
|
KeygenUpstreamURL: keygenFake.URL(),
|
|
KeygenUpstreamToken: "kg-token",
|
|
}
|
|
r := httptest.NewRequest(http.MethodGet, "/keygen", nil)
|
|
w := httptest.NewRecorder()
|
|
p.handleKeygen(w, r)
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("status=%d want 405", w.Code)
|
|
}
|
|
if keygenFake.calls.Load() != 0 {
|
|
t.Errorf("upstream was called on rejected method")
|
|
}
|
|
}
|
|
|
|
func TestProxy_Keygen503WhenNoUpstream(t *testing.T) {
|
|
p := &Proxy{} // no URLs at all
|
|
status, body := postBridgeKeygen(t, p, []byte(`{"org_id":"bridge"}`))
|
|
if status != http.StatusServiceUnavailable {
|
|
t.Fatalf("status=%d want 503", status)
|
|
}
|
|
if !strings.Contains(string(body), "keygen upstream URL missing") {
|
|
t.Errorf("body should explain missing config: %s", body)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// /sign passthrough mode
|
|
// =============================================================================
|
|
|
|
// fakePassthroughSigner serves POST /sign in the bridge wire shape
|
|
// (mirrors mpcd's post-0ac96d6 internal API). Used to verify the
|
|
// proxy faithfully forwards both request body and response body.
|
|
type fakePassthroughSigner struct {
|
|
server *httptest.Server
|
|
lastReqBody atomic.Value
|
|
calls atomic.Uint64
|
|
tok string
|
|
}
|
|
|
|
func newFakePassthroughSigner(t *testing.T, tok string) *fakePassthroughSigner {
|
|
t.Helper()
|
|
f := &fakePassthroughSigner{tok: tok}
|
|
f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/sign" {
|
|
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
if f.tok != "" && r.Header.Get("Authorization") != "Bearer "+f.tok {
|
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
f.calls.Add(1)
|
|
body, _ := io.ReadAll(r.Body)
|
|
f.lastReqBody.Store(body)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"wallet_id": "w-pt",
|
|
"signature": "0xdeadbeef",
|
|
"session_id": "internal-session-1",
|
|
"result_type": "success",
|
|
})
|
|
}))
|
|
t.Cleanup(f.server.Close)
|
|
return f
|
|
}
|
|
|
|
func TestProxy_SignPassthroughMode(t *testing.T) {
|
|
upstream := newFakePassthroughSigner(t, "modern-mpcd-tok")
|
|
p := &Proxy{
|
|
UpstreamURL: upstream.server.URL,
|
|
UpstreamToken: "modern-mpcd-tok",
|
|
SignMode: SignModePassthrough,
|
|
HTTPClient: &http.Client{Timeout: 5 * time.Second},
|
|
}
|
|
|
|
status, resp := postBridgeSign(t, p, bridgeSignReq{
|
|
OrgID: "bridge",
|
|
WalletID: "w-pt",
|
|
Message: "0xab12",
|
|
})
|
|
if status != http.StatusOK {
|
|
t.Fatalf("status=%d resp=%+v", status, resp)
|
|
}
|
|
if resp.Signature != "0xdeadbeef" {
|
|
t.Errorf("signature=%q want 0xdeadbeef", resp.Signature)
|
|
}
|
|
if resp.SessionID != "internal-session-1" {
|
|
t.Errorf("session_id=%q want internal-session-1", resp.SessionID)
|
|
}
|
|
if resp.ResultType != "success" {
|
|
t.Errorf("result_type=%q want success", resp.ResultType)
|
|
}
|
|
if upstream.calls.Load() != 1 {
|
|
t.Errorf("expected 1 upstream call, got %d", upstream.calls.Load())
|
|
}
|
|
// Body forwarded verbatim — the upstream sees the org_id field
|
|
// that translate mode wouldn't pass through.
|
|
got := upstream.lastReqBody.Load().([]byte)
|
|
if !strings.Contains(string(got), `"org_id":"bridge"`) {
|
|
t.Errorf("upstream didn't see verbatim body (missing org_id): %s", got)
|
|
}
|
|
if !strings.Contains(string(got), `"message":"0xab12"`) {
|
|
t.Errorf("upstream didn't see the message field: %s", got)
|
|
}
|
|
if got := p.Stats(); got.SignSuccess != 1 {
|
|
t.Errorf("stats=%+v", got)
|
|
}
|
|
}
|
|
|
|
func TestProxy_SignPassthroughForwardsUpstreamError(t *testing.T) {
|
|
// Custom upstream that returns a bridge-shape error.
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/sign" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
_, _ = w.Write([]byte(`{"error":"no signing quorum","result_type":"error"}`))
|
|
return
|
|
}
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := &Proxy{
|
|
UpstreamURL: srv.URL,
|
|
UpstreamToken: "x",
|
|
SignMode: SignModePassthrough,
|
|
}
|
|
|
|
status, resp := postBridgeSign(t, p, bridgeSignReq{
|
|
WalletID: "w",
|
|
Message: "0xab",
|
|
})
|
|
if status != http.StatusServiceUnavailable {
|
|
t.Fatalf("status=%d want 503 (upstream forwarded)", status)
|
|
}
|
|
if !strings.Contains(resp.Error, "no signing quorum") {
|
|
t.Errorf("upstream error not preserved: %+v", resp)
|
|
}
|
|
if got := p.Stats(); got.SignFailures != 1 {
|
|
t.Errorf("stats=%+v want SignFailures=1", got)
|
|
}
|
|
}
|
|
|
|
// In passthrough mode the proxy must NOT touch the dashboard's
|
|
// sessions endpoint — otherwise we'd be doing extra work and
|
|
// imposing dashboard auth on a path that should be internal-API-only.
|
|
func TestProxy_SignPassthroughDoesNotCallSessionsEndpoint(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.Contains(r.URL.Path, "/sessions") {
|
|
t.Errorf("passthrough hit /sessions: %s", r.URL.Path)
|
|
}
|
|
if r.URL.Path == "/sign" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"signature":"0xab","session_id":"s","result_type":"success"}`))
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := &Proxy{UpstreamURL: srv.URL, UpstreamToken: "tok", SignMode: SignModePassthrough}
|
|
status, _ := postBridgeSign(t, p, bridgeSignReq{WalletID: "w", Message: "0xab"})
|
|
if status != http.StatusOK {
|
|
t.Fatalf("status=%d want 200", status)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// httpErr helper
|
|
// =============================================================================
|
|
|
|
// httpErr is a small error helper for concurrent test failures.
|
|
type httpErr int
|
|
|
|
func (e httpErr) Error() string { return "status " + itoa(int(e)) }
|
|
|
|
func itoa(n int) string {
|
|
if n == 0 {
|
|
return "0"
|
|
}
|
|
out := ""
|
|
neg := false
|
|
if n < 0 {
|
|
neg = true
|
|
n = -n
|
|
}
|
|
for n > 0 {
|
|
out = string(rune('0'+n%10)) + out
|
|
n /= 10
|
|
}
|
|
if neg {
|
|
out = "-" + out
|
|
}
|
|
return out
|
|
}
|