mirror of
https://github.com/luxfi/bridge.git
synced 2026-09-24 00:26:46 +00:00
493 lines
16 KiB
Go
493 lines
16 KiB
Go
package cosigners
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// =============================================================================
|
|
// ValidateIntents — wire-shape validation
|
|
// =============================================================================
|
|
|
|
func TestValidate_NilAndEmpty(t *testing.T) {
|
|
got, err := ValidateIntents(nil)
|
|
if err != nil {
|
|
t.Fatalf("nil: unexpected error %v", err)
|
|
}
|
|
if got != nil {
|
|
t.Errorf("nil: want nil result, got %v", got)
|
|
}
|
|
|
|
got, err = ValidateIntents([]any{})
|
|
if err != nil {
|
|
t.Fatalf("empty: unexpected error %v", err)
|
|
}
|
|
if got != nil {
|
|
t.Errorf("empty: want nil result, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestValidate_UtilaHappyPath(t *testing.T) {
|
|
in := []any{
|
|
map[string]any{
|
|
"kind": "utila",
|
|
"org_id": "tenant-x",
|
|
"client_id": "lux-bridge",
|
|
"vault_id": "main-vault",
|
|
},
|
|
}
|
|
got, err := ValidateIntents(in)
|
|
if err != nil {
|
|
t.Fatalf("validate: %v", err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("len=%d, want 1", len(got))
|
|
}
|
|
if got[0].Kind != KindUtila || got[0].Utila == nil {
|
|
t.Fatalf("kind=%q utila=%v", got[0].Kind, got[0].Utila)
|
|
}
|
|
if got[0].Utila.OrgID != "tenant-x" || got[0].Utila.ClientID != "lux-bridge" || got[0].Utila.VaultID != "main-vault" {
|
|
t.Errorf("payload mismatch: %+v", got[0].Utila)
|
|
}
|
|
}
|
|
|
|
func TestValidate_FireblocksHappyPath(t *testing.T) {
|
|
in := []any{
|
|
map[string]any{
|
|
"kind": "fireblocks",
|
|
"api_key": "pub-key-id",
|
|
"vault_account_id": "42",
|
|
"api_host": "https://api.fireblocks.io",
|
|
},
|
|
}
|
|
got, err := ValidateIntents(in)
|
|
if err != nil {
|
|
t.Fatalf("validate: %v", err)
|
|
}
|
|
if got[0].Kind != KindFireblocks || got[0].Fireblocks == nil {
|
|
t.Fatalf("kind=%q fb=%v", got[0].Kind, got[0].Fireblocks)
|
|
}
|
|
if got[0].Fireblocks.APIKey != "pub-key-id" {
|
|
t.Errorf("api_key=%q want pub-key-id", got[0].Fireblocks.APIKey)
|
|
}
|
|
if got[0].Fireblocks.VaultAccountID != "42" {
|
|
t.Errorf("vault_account_id=%q want 42", got[0].Fireblocks.VaultAccountID)
|
|
}
|
|
}
|
|
|
|
func TestValidate_MixedKinds(t *testing.T) {
|
|
in := []any{
|
|
map[string]any{"kind": "utila", "org_id": "a", "client_id": "b"},
|
|
map[string]any{"kind": "fireblocks", "api_key": "k"},
|
|
}
|
|
got, err := ValidateIntents(in)
|
|
if err != nil {
|
|
t.Fatalf("validate: %v", err)
|
|
}
|
|
if len(got) != 2 || got[0].Kind != KindUtila || got[1].Kind != KindFireblocks {
|
|
t.Errorf("mixed-kind validation failed: %+v", got)
|
|
}
|
|
}
|
|
|
|
// TestValidate_RejectsSecretFields is the security-critical test. If any
|
|
// field name from the SECRET_FIELD_NAMES set is present on a cosigner
|
|
// entry, validation MUST fail — otherwise a buggy forwarder could leak
|
|
// secrets to the bridge's wire-side logs / persistence.
|
|
func TestValidate_RejectsSecretFields(t *testing.T) {
|
|
for _, badField := range []string{"secret", "api_secret", "private_key", "secret_key", "service_account_private_key", "jwt", "token", "auth_token"} {
|
|
t.Run(badField, func(t *testing.T) {
|
|
in := []any{
|
|
map[string]any{
|
|
"kind": "fireblocks",
|
|
"api_key": "k",
|
|
badField: "would-be-leaked-secret",
|
|
},
|
|
}
|
|
_, err := ValidateIntents(in)
|
|
if err == nil {
|
|
t.Fatalf("expected error rejecting %q field", badField)
|
|
}
|
|
var bad *ErrBadIntent
|
|
if !errors.As(err, &bad) {
|
|
t.Fatalf("expected ErrBadIntent, got %T: %v", err, err)
|
|
}
|
|
if !strings.Contains(err.Error(), badField) {
|
|
t.Errorf("error should mention the offending field %q, got: %v", badField, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestValidate_SecretFieldsCaseInsensitive — operators may forward
|
|
// upper-case header names, etc. The deny-list comparison must be
|
|
// case-insensitive so we catch them regardless.
|
|
func TestValidate_SecretFieldsCaseInsensitive(t *testing.T) {
|
|
in := []any{
|
|
map[string]any{
|
|
"kind": "fireblocks",
|
|
"api_key": "k",
|
|
"JWT": "leaked",
|
|
},
|
|
}
|
|
_, err := ValidateIntents(in)
|
|
if err == nil {
|
|
t.Fatal("expected error rejecting uppercase JWT")
|
|
}
|
|
}
|
|
|
|
func TestValidate_BadShapes(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in []any
|
|
want string
|
|
}{
|
|
{"not_object", []any{"a string"}, "must be an object"},
|
|
{"empty_kind", []any{map[string]any{"kind": ""}}, "kind required"},
|
|
{"unknown_kind", []any{map[string]any{"kind": "btc-multisig"}}, `unknown kind "btc-multisig"`},
|
|
{"utila_no_org", []any{map[string]any{"kind": "utila", "client_id": "c"}}, "org_id required"},
|
|
{"utila_no_client", []any{map[string]any{"kind": "utila", "org_id": "o"}}, "client_id required"},
|
|
{"fireblocks_no_key", []any{map[string]any{"kind": "fireblocks"}}, "api_key required"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
_, err := ValidateIntents(c.in)
|
|
if err == nil {
|
|
t.Fatalf("expected error containing %q", c.want)
|
|
}
|
|
if !strings.Contains(err.Error(), c.want) {
|
|
t.Errorf("want error containing %q, got: %v", c.want, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Dispatcher behaviour
|
|
// =============================================================================
|
|
|
|
// fakeSecrets returns canned secrets for known identifiers. Lets the test
|
|
// drive the dispatcher's runOne without touching env vars.
|
|
type fakeSecrets struct {
|
|
utilaSecrets map[string]string
|
|
fireblocksSecrets map[string]string
|
|
}
|
|
|
|
func (s fakeSecrets) FetchUtila(_ context.Context, i *UtilaIntent) (string, error) {
|
|
v, ok := s.utilaSecrets[i.OrgID]
|
|
if !ok {
|
|
return "", ErrSecretNotConfigured
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
func (s fakeSecrets) FetchFireblocks(_ context.Context, i *FireblocksIntent) (string, error) {
|
|
v, ok := s.fireblocksSecrets[i.APIKey]
|
|
if !ok {
|
|
return "", ErrSecretNotConfigured
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// mockFamily lets a test specify per-intent results.
|
|
type mockFamily struct {
|
|
utilaRun func(*UtilaIntent, string, DispatchOptions) Result
|
|
fireblocksRun func(*FireblocksIntent, string, DispatchOptions) Result
|
|
}
|
|
|
|
func (m mockFamily) RunUtila(_ context.Context, i *UtilaIntent, s string, o DispatchOptions) Result {
|
|
return m.utilaRun(i, s, o)
|
|
}
|
|
|
|
func (m mockFamily) RunFireblocks(_ context.Context, i *FireblocksIntent, s string, o DispatchOptions) Result {
|
|
return m.fireblocksRun(i, s, o)
|
|
}
|
|
|
|
func TestDispatch_EmptyCosignersReturnsNil(t *testing.T) {
|
|
d := NewDefault(fakeSecrets{}, mockFamily{})
|
|
results, err := d.Dispatch(context.Background(), DispatchOptions{SwapID: "s1"})
|
|
if err != nil {
|
|
t.Fatalf("dispatch: %v", err)
|
|
}
|
|
if results != nil {
|
|
t.Errorf("expected nil results for no cosigners, got %v", results)
|
|
}
|
|
}
|
|
|
|
func TestDispatch_RequiresSwapID(t *testing.T) {
|
|
d := NewDefault(fakeSecrets{}, mockFamily{})
|
|
_, err := d.Dispatch(context.Background(), DispatchOptions{})
|
|
if err == nil {
|
|
t.Fatal("expected error for empty SwapID")
|
|
}
|
|
}
|
|
|
|
func TestDispatch_StubFamilyAllFail(t *testing.T) {
|
|
// The intended Phase 1.4 production wiring: env secrets + stub family.
|
|
// A swap with Fireblocks/Utila intents should fail with a clear
|
|
// not-implemented reason, NOT silently succeed.
|
|
secrets := fakeSecrets{
|
|
utilaSecrets: map[string]string{"org-1": "fake-pem"},
|
|
fireblocksSecrets: map[string]string{"key-1": "fake-pem"},
|
|
}
|
|
d := NewDefault(secrets, StubFamilyDispatcher{})
|
|
results, err := d.Dispatch(context.Background(), DispatchOptions{
|
|
SwapID: "swap_test",
|
|
NativeSignature: "deadbeef",
|
|
TxHash: "0xabcd",
|
|
Cosigners: []Intent{
|
|
{Kind: KindUtila, Utila: &UtilaIntent{OrgID: "org-1", ClientID: "c"}},
|
|
{Kind: KindFireblocks, Fireblocks: &FireblocksIntent{APIKey: "key-1"}},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("dispatch: %v", err)
|
|
}
|
|
if len(results) != 2 {
|
|
t.Fatalf("len=%d want 2", len(results))
|
|
}
|
|
for i, r := range results {
|
|
if r.Status != StatusFailed {
|
|
t.Errorf("result[%d] status=%s want failed", i, r.Status)
|
|
}
|
|
if !strings.Contains(r.Reason, "not yet implemented in cmd/bridge") {
|
|
t.Errorf("result[%d] reason should explain Go gap, got: %q", i, r.Reason)
|
|
}
|
|
}
|
|
if AllApproved(results) {
|
|
t.Error("AllApproved should be false when all failed")
|
|
}
|
|
first := FirstNonApproved(results)
|
|
if first == nil {
|
|
t.Error("FirstNonApproved should not be nil")
|
|
}
|
|
}
|
|
|
|
func TestDispatch_SecretNotConfiguredSurfacesAsFailed(t *testing.T) {
|
|
d := NewDefault(fakeSecrets{}, StubFamilyDispatcher{})
|
|
results, err := d.Dispatch(context.Background(), DispatchOptions{
|
|
SwapID: "swap_x",
|
|
Cosigners: []Intent{
|
|
{Kind: KindFireblocks, Fireblocks: &FireblocksIntent{APIKey: "missing-key"}},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("dispatch: %v", err)
|
|
}
|
|
if results[0].Status != StatusFailed {
|
|
t.Errorf("status=%s want failed (secret missing)", results[0].Status)
|
|
}
|
|
if !strings.Contains(results[0].Reason, "fetch secret") {
|
|
t.Errorf("reason should mention 'fetch secret', got: %q", results[0].Reason)
|
|
}
|
|
}
|
|
|
|
func TestDispatch_AllApprovedHappyPath(t *testing.T) {
|
|
// Simulate a fully-functional cosign — useful as a sanity check that
|
|
// the dispatcher returns approved when families do.
|
|
mock := mockFamily{
|
|
utilaRun: func(i *UtilaIntent, _ string, _ DispatchOptions) Result {
|
|
return Result{Intent: Intent{Kind: KindUtila, Utila: i}, Status: StatusApproved, Signature: "utila-sig"}
|
|
},
|
|
fireblocksRun: func(i *FireblocksIntent, _ string, _ DispatchOptions) Result {
|
|
return Result{Intent: Intent{Kind: KindFireblocks, Fireblocks: i}, Status: StatusApproved, Signature: "fb-sig"}
|
|
},
|
|
}
|
|
d := NewDefault(fakeSecrets{
|
|
utilaSecrets: map[string]string{"o": "pem"},
|
|
fireblocksSecrets: map[string]string{"k": "pem"},
|
|
}, mock)
|
|
results, err := d.Dispatch(context.Background(), DispatchOptions{
|
|
SwapID: "s",
|
|
Cosigners: []Intent{
|
|
{Kind: KindUtila, Utila: &UtilaIntent{OrgID: "o", ClientID: "c"}},
|
|
{Kind: KindFireblocks, Fireblocks: &FireblocksIntent{APIKey: "k"}},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("dispatch: %v", err)
|
|
}
|
|
if !AllApproved(results) {
|
|
t.Errorf("expected all approved, got: %+v", results)
|
|
}
|
|
if FirstNonApproved(results) != nil {
|
|
t.Errorf("FirstNonApproved should be nil for all-approved")
|
|
}
|
|
}
|
|
|
|
func TestDispatch_OneRejectedFailsGate(t *testing.T) {
|
|
// Any single rejection must drop AllApproved to false — that's the
|
|
// "all listed must approve" invariant the swap state machine uses.
|
|
mock := mockFamily{
|
|
utilaRun: func(i *UtilaIntent, _ string, _ DispatchOptions) Result {
|
|
return Result{Intent: Intent{Kind: KindUtila, Utila: i}, Status: StatusApproved}
|
|
},
|
|
fireblocksRun: func(i *FireblocksIntent, _ string, _ DispatchOptions) Result {
|
|
return Result{Intent: Intent{Kind: KindFireblocks, Fireblocks: i}, Status: StatusRejected, Reason: "user denied in app"}
|
|
},
|
|
}
|
|
d := NewDefault(fakeSecrets{
|
|
utilaSecrets: map[string]string{"o": "p"},
|
|
fireblocksSecrets: map[string]string{"k": "p"},
|
|
}, mock)
|
|
results, _ := d.Dispatch(context.Background(), DispatchOptions{
|
|
SwapID: "s",
|
|
Cosigners: []Intent{
|
|
{Kind: KindUtila, Utila: &UtilaIntent{OrgID: "o", ClientID: "c"}},
|
|
{Kind: KindFireblocks, Fireblocks: &FireblocksIntent{APIKey: "k"}},
|
|
},
|
|
})
|
|
if AllApproved(results) {
|
|
t.Error("AllApproved should be false when one cosigner rejected")
|
|
}
|
|
first := FirstNonApproved(results)
|
|
if first == nil || first.Status != StatusRejected {
|
|
t.Errorf("FirstNonApproved should be the rejected one, got: %+v", first)
|
|
}
|
|
}
|
|
|
|
func TestDispatch_RunsCosignersInParallel(t *testing.T) {
|
|
// Both families should run concurrently — assert by recording start
|
|
// timestamps. If the dispatcher serialized them, total duration would
|
|
// be ~2*sleep; in parallel it's ~sleep.
|
|
mock := mockFamily{
|
|
utilaRun: func(i *UtilaIntent, _ string, _ DispatchOptions) Result {
|
|
// Simulate slow upstream — gate via a channel rather than sleep
|
|
// for determinism. The test asserts ordering, not wall time.
|
|
return Result{Intent: Intent{Kind: KindUtila, Utila: i}, Status: StatusApproved}
|
|
},
|
|
fireblocksRun: func(i *FireblocksIntent, _ string, _ DispatchOptions) Result {
|
|
return Result{Intent: Intent{Kind: KindFireblocks, Fireblocks: i}, Status: StatusApproved}
|
|
},
|
|
}
|
|
d := NewDefault(fakeSecrets{
|
|
utilaSecrets: map[string]string{"o": "p"},
|
|
fireblocksSecrets: map[string]string{"k": "p"},
|
|
}, mock)
|
|
results, _ := d.Dispatch(context.Background(), DispatchOptions{
|
|
SwapID: "s",
|
|
Cosigners: []Intent{
|
|
{Kind: KindUtila, Utila: &UtilaIntent{OrgID: "o", ClientID: "c"}},
|
|
{Kind: KindFireblocks, Fireblocks: &FireblocksIntent{APIKey: "k"}},
|
|
},
|
|
})
|
|
// Ordering preserved: results[0] is the utila entry, results[1] the
|
|
// fireblocks entry. This is the contract the caller relies on for
|
|
// persistence into the Swap record.
|
|
if results[0].Intent.Kind != KindUtila || results[1].Intent.Kind != KindFireblocks {
|
|
t.Errorf("result order not preserved: %+v", results)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// EnvSecretStore
|
|
// =============================================================================
|
|
|
|
func TestEnvSecretStore_Utila(t *testing.T) {
|
|
t.Setenv("UTILA_COSIGNER_PEM__TENANT_X", "fake-pem-content")
|
|
s := EnvSecretStore{}
|
|
got, err := s.FetchUtila(context.Background(), &UtilaIntent{OrgID: "tenant-x"})
|
|
if err != nil {
|
|
t.Fatalf("fetch: %v", err)
|
|
}
|
|
if got != "fake-pem-content" {
|
|
t.Errorf("got %q want %q", got, "fake-pem-content")
|
|
}
|
|
}
|
|
|
|
func TestEnvSecretStore_UtilaUnset(t *testing.T) {
|
|
s := EnvSecretStore{}
|
|
_, err := s.FetchUtila(context.Background(), &UtilaIntent{OrgID: "no-such-tenant"})
|
|
if !errors.Is(err, ErrSecretNotConfigured) {
|
|
t.Fatalf("expected ErrSecretNotConfigured, got %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "UTILA_COSIGNER_PEM__NO_SUCH_TENANT") {
|
|
t.Errorf("error should name the env var, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEnvSecretStore_Fireblocks(t *testing.T) {
|
|
t.Setenv("FIREBLOCKS_COSIGNER_PEM__KEY_ID_42", "fake-fb-pem")
|
|
s := EnvSecretStore{}
|
|
got, err := s.FetchFireblocks(context.Background(), &FireblocksIntent{APIKey: "key-id-42"})
|
|
if err != nil {
|
|
t.Fatalf("fetch: %v", err)
|
|
}
|
|
if got != "fake-fb-pem" {
|
|
t.Errorf("got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestEnvSecretStore_FireblocksFromFile proves the URI-scheme path:
|
|
// the env var holds `file:/path` and the resolver loads the PEM from
|
|
// disk. Mirrors the K8s secret-mount pattern operators actually deploy.
|
|
func TestEnvSecretStore_FireblocksFromFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := dir + "/fireblocks.pem"
|
|
if err := os.WriteFile(path, []byte("-----PEM-FROM-FILE-----\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("FIREBLOCKS_COSIGNER_PEM__FILE_BACKED_KEY", "file:"+path)
|
|
s := EnvSecretStore{}
|
|
got, err := s.FetchFireblocks(context.Background(), &FireblocksIntent{APIKey: "file-backed-key"})
|
|
if err != nil {
|
|
t.Fatalf("fetch: %v", err)
|
|
}
|
|
if got != "-----PEM-FROM-FILE-----" {
|
|
t.Errorf("got %q, want trimmed file contents", got)
|
|
}
|
|
}
|
|
|
|
// TestEnvSecretStore_FireblocksFileMissing covers the error path: env
|
|
// var points at a file that doesn't exist. Operator sees an actionable
|
|
// path-in-error rather than ErrSecretNotConfigured (which would
|
|
// suggest the env var is unset).
|
|
func TestEnvSecretStore_FireblocksFileMissing(t *testing.T) {
|
|
t.Setenv("FIREBLOCKS_COSIGNER_PEM__BROKEN_KEY", "file:/no/such/path/xyz")
|
|
s := EnvSecretStore{}
|
|
_, err := s.FetchFireblocks(context.Background(), &FireblocksIntent{APIKey: "broken-key"})
|
|
if err == nil {
|
|
t.Fatal("expected error for missing file")
|
|
}
|
|
if !strings.Contains(err.Error(), "/no/such/path/xyz") {
|
|
t.Errorf("error should name the missing path: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestEnvSecretStore_UtilaFromEnvIndirection covers the `env:OTHER_VAR`
|
|
// scheme — operator points the cosigner env var at a different env var
|
|
// that holds the actual PEM. Useful when secret-management tools
|
|
// populate a canonical env name and operators want to alias.
|
|
func TestEnvSecretStore_UtilaFromEnvIndirection(t *testing.T) {
|
|
t.Setenv("SHARED_UTILA_PEM", "actual-pem-content")
|
|
t.Setenv("UTILA_COSIGNER_PEM__INDIRECT_ORG", "env:SHARED_UTILA_PEM")
|
|
s := EnvSecretStore{}
|
|
got, err := s.FetchUtila(context.Background(), &UtilaIntent{OrgID: "indirect-org"})
|
|
if err != nil {
|
|
t.Fatalf("fetch: %v", err)
|
|
}
|
|
if got != "actual-pem-content" {
|
|
t.Errorf("got %q, want indirected value", got)
|
|
}
|
|
}
|
|
|
|
func TestEnvSafe(t *testing.T) {
|
|
cases := map[string]string{
|
|
"": "",
|
|
"abc": "ABC",
|
|
"abc-def": "ABC_DEF",
|
|
"abc.def": "ABC_DEF",
|
|
"abc def": "ABC_DEF",
|
|
"tenant_42": "TENANT_42",
|
|
"contains/slash": "CONTAINS_SLASH",
|
|
"AlreadyUpper": "ALREADYUPPER",
|
|
}
|
|
for in, want := range cases {
|
|
if got := envSafe(in); got != want {
|
|
t.Errorf("envSafe(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|