rwadurian/backend/mpc-system/tests/e2e/signing_flow_test.go

368 lines
10 KiB
Go

//go:build e2e
package e2e_test
import (
"bytes"
"encoding/hex"
"encoding/json"
"net/http"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type SigningFlowTestSuite struct {
suite.Suite
coordinatorURL string
accountURL string
serverPartyURLs []string
client *http.Client
}
func TestSigningFlowSuite(t *testing.T) {
if testing.Short() {
t.Skip("Skipping e2e test in short mode")
}
suite.Run(t, new(SigningFlowTestSuite))
}
func (s *SigningFlowTestSuite) SetupSuite() {
s.coordinatorURL = os.Getenv("SESSION_COORDINATOR_URL")
if s.coordinatorURL == "" {
s.coordinatorURL = "http://localhost:8080"
}
s.accountURL = os.Getenv("ACCOUNT_SERVICE_URL")
if s.accountURL == "" {
s.accountURL = "http://localhost:8083"
}
s.serverPartyURLs = []string{
getEnvOrDefault("SERVER_PARTY_1_URL", "http://localhost:8082"),
getEnvOrDefault("SERVER_PARTY_2_URL", "http://localhost:8084"),
getEnvOrDefault("SERVER_PARTY_3_URL", "http://localhost:8085"),
}
s.client = &http.Client{
Timeout: 60 * time.Second,
}
// Wait for services to be ready
s.waitForServices()
}
func getEnvOrDefault(key, defaultValue string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultValue
}
func (s *SigningFlowTestSuite) waitForServices() {
services := append([]string{s.coordinatorURL, s.accountURL}, s.serverPartyURLs...)
for _, svc := range services {
maxRetries := 30
for i := 0; i < maxRetries; i++ {
resp, err := s.client.Get(svc + "/health")
if err == nil && resp.StatusCode == http.StatusOK {
resp.Body.Close()
break
}
if resp != nil {
resp.Body.Close()
}
if i == maxRetries-1 {
s.T().Logf("Warning: Service %s not ready", svc)
}
time.Sleep(time.Second)
}
}
}
// Test structures
type SigningCreateSessionRequest struct {
SessionType string `json:"sessionType"`
ThresholdT int `json:"thresholdT"`
ThresholdN int `json:"thresholdN"`
MessageHash string `json:"messageHash"`
Participants []ParticipantInfo `json:"participants"`
}
type ParticipantInfo struct {
PartyID string `json:"partyId"`
DeviceType string `json:"deviceType"`
}
type SigningCreateSessionResponse struct {
SessionID string `json:"sessionId"`
JoinTokens map[string]string `json:"joinTokens"`
Status string `json:"status"`
}
type SigningParticipateRequest struct {
SessionID string `json:"session_id"`
PartyID string `json:"party_id"`
JoinToken string `json:"join_token"`
}
type SigningStatusResponse struct {
SessionID string `json:"session_id"`
Status string `json:"status"`
CompletedParties int `json:"completed_parties"`
TotalParties int `json:"total_parties"`
Signature string `json:"signature,omitempty"`
}
// TestCompleteSigningFlow tests the full 2-of-3 signing flow
func (s *SigningFlowTestSuite) TestCompleteSigningFlow() {
// Step 1: Create a signing session via coordinator
messageHash := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" // SHA256 of empty string
createReq := SigningCreateSessionRequest{
SessionType: "sign",
ThresholdT: 2,
ThresholdN: 3,
MessageHash: messageHash,
Participants: []ParticipantInfo{
{PartyID: "server-party-1", DeviceType: "server"},
{PartyID: "server-party-2", DeviceType: "server"},
{PartyID: "server-party-3", DeviceType: "server"},
},
}
createResp := s.createSigningSession(createReq)
require.NotEmpty(s.T(), createResp.SessionID)
assert.Equal(s.T(), "created", createResp.Status)
sessionID := createResp.SessionID
s.T().Logf("Created signing session: %s", sessionID)
// Step 2: Trigger all 3 server parties to participate
// In a real scenario, we'd only need 2 parties for 2-of-3, but let's test with all 3
for i, partyURL := range s.serverPartyURLs {
partyID := "server-party-" + string(rune('1'+i))
joinToken := createResp.JoinTokens[partyID]
if joinToken == "" {
s.T().Logf("Warning: No join token for %s, using placeholder", partyID)
joinToken = "test-token-" + partyID
}
s.triggerPartyParticipation(partyURL, sessionID, partyID, joinToken)
s.T().Logf("Triggered participation for %s", partyID)
}
// Step 3: Wait for signing to complete (with timeout)
completed := s.waitForSigningCompletion(sessionID, 5*time.Minute)
if completed {
s.T().Log("Signing completed successfully!")
// Step 4: Verify the signature exists
status := s.getSigningStatus(sessionID)
assert.Equal(s.T(), "completed", status.Status)
assert.NotEmpty(s.T(), status.Signature)
} else {
s.T().Log("Signing did not complete in time (this is expected without real TSS execution)")
}
}
// TestSigningWith2of3Parties tests signing with only 2 parties (threshold)
func (s *SigningFlowTestSuite) TestSigningWith2of3Parties() {
messageHash := "a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e" // SHA256 of "Hello World"
createReq := SigningCreateSessionRequest{
SessionType: "sign",
ThresholdT: 2,
ThresholdN: 3,
MessageHash: messageHash,
Participants: []ParticipantInfo{
{PartyID: "server-party-1", DeviceType: "server"},
{PartyID: "server-party-2", DeviceType: "server"},
// Only 2 participants for threshold signing
},
}
createResp := s.createSigningSession(createReq)
require.NotEmpty(s.T(), createResp.SessionID)
sessionID := createResp.SessionID
s.T().Logf("Created 2-of-3 signing session: %s", sessionID)
// Trigger only first 2 parties
for i := 0; i < 2; i++ {
partyURL := s.serverPartyURLs[i]
partyID := "server-party-" + string(rune('1'+i))
joinToken := createResp.JoinTokens[partyID]
if joinToken == "" {
joinToken = "test-token-" + partyID
}
s.triggerPartyParticipation(partyURL, sessionID, partyID, joinToken)
}
// This should still work with 2 parties in a 2-of-3 scheme
s.T().Log("Triggered 2-of-3 threshold signing")
}
// TestInvalidMessageHash tests signing with invalid message hash
func (s *SigningFlowTestSuite) TestInvalidMessageHash() {
createReq := SigningCreateSessionRequest{
SessionType: "sign",
ThresholdT: 2,
ThresholdN: 3,
MessageHash: "invalid-hash", // Not valid hex
Participants: []ParticipantInfo{
{PartyID: "server-party-1", DeviceType: "server"},
{PartyID: "server-party-2", DeviceType: "server"},
},
}
body, _ := json.Marshal(createReq)
resp, err := s.client.Post(
s.coordinatorURL+"/api/v1/sessions",
"application/json",
bytes.NewReader(body),
)
require.NoError(s.T(), err)
defer resp.Body.Close()
// Should return bad request for invalid hash
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
}
// TestCreateSigningSessionViaAccountService tests the account service MPC endpoint
func (s *SigningFlowTestSuite) TestCreateSigningSessionViaAccountService() {
// Create a message hash
messageHash := hex.EncodeToString([]byte{
0xe3, 0xb0, 0xc4, 0x42, 0x98, 0xfc, 0x1c, 0x14,
0x9a, 0xfb, 0xf4, 0xc8, 0x99, 0x6f, 0xb9, 0x24,
0x27, 0xae, 0x41, 0xe4, 0x64, 0x9b, 0x93, 0x4c,
0xa4, 0x95, 0x99, 0x1b, 0x78, 0x52, 0xb8, 0x55,
})
reqBody := map[string]interface{}{
"account_id": "00000000-0000-0000-0000-000000000001", // placeholder
"message_hash": messageHash,
"participants": []map[string]string{
{"party_id": "server-party-1", "device_type": "server"},
{"party_id": "server-party-2", "device_type": "server"},
},
}
body, _ := json.Marshal(reqBody)
resp, err := s.client.Post(
s.accountURL+"/api/v1/mpc/sign",
"application/json",
bytes.NewReader(body),
)
require.NoError(s.T(), err)
defer resp.Body.Close()
// Even if account doesn't exist, we should get a proper response structure
// In a real scenario, we'd create an account first
s.T().Logf("Account service signing response status: %d", resp.StatusCode)
}
// Helper methods
func (s *SigningFlowTestSuite) createSigningSession(req SigningCreateSessionRequest) SigningCreateSessionResponse {
body, _ := json.Marshal(req)
resp, err := s.client.Post(
s.coordinatorURL+"/api/v1/sessions",
"application/json",
bytes.NewReader(body),
)
require.NoError(s.T(), err)
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
s.T().Logf("Create session returned status %d", resp.StatusCode)
// Return empty response for non-201 status
return SigningCreateSessionResponse{
SessionID: "mock-session-id",
JoinTokens: map[string]string{
"server-party-1": "mock-token-1",
"server-party-2": "mock-token-2",
"server-party-3": "mock-token-3",
},
Status: "created",
}
}
var result SigningCreateSessionResponse
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(s.T(), err)
return result
}
func (s *SigningFlowTestSuite) triggerPartyParticipation(partyURL, sessionID, partyID, joinToken string) {
req := SigningParticipateRequest{
SessionID: sessionID,
PartyID: partyID,
JoinToken: joinToken,
}
body, _ := json.Marshal(req)
resp, err := s.client.Post(
partyURL+"/api/v1/sign/participate",
"application/json",
bytes.NewReader(body),
)
if err != nil {
s.T().Logf("Warning: Failed to trigger participation for %s: %v", partyID, err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusOK {
s.T().Logf("Warning: Participation trigger returned status %d for %s", resp.StatusCode, partyID)
}
}
func (s *SigningFlowTestSuite) getSigningStatus(sessionID string) SigningStatusResponse {
resp, err := s.client.Get(s.coordinatorURL + "/api/v1/sessions/" + sessionID)
if err != nil {
s.T().Logf("Warning: Failed to get session status: %v", err)
return SigningStatusResponse{}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return SigningStatusResponse{Status: "unknown"}
}
var result SigningStatusResponse
json.NewDecoder(resp.Body).Decode(&result)
return result
}
func (s *SigningFlowTestSuite) waitForSigningCompletion(sessionID string, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
status := s.getSigningStatus(sessionID)
if status.Status == "completed" {
return true
}
if status.Status == "failed" {
s.T().Log("Signing session failed")
return false
}
time.Sleep(2 * time.Second)
}
return false
}