//go:build e2e package e2e_test import ( "bytes" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "encoding/hex" "encoding/json" "net/http" "os" "testing" "time" "github.com/google/uuid" "github.com/rwadurian/mpc-system/pkg/crypto" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) type AccountFlowTestSuite struct { suite.Suite baseURL string client *http.Client } func TestAccountFlowSuite(t *testing.T) { if testing.Short() { t.Skip("Skipping e2e test in short mode") } suite.Run(t, new(AccountFlowTestSuite)) } func (s *AccountFlowTestSuite) SetupSuite() { s.baseURL = os.Getenv("ACCOUNT_SERVICE_URL") if s.baseURL == "" { s.baseURL = "http://localhost:8083" } s.client = &http.Client{ Timeout: 30 * time.Second, } s.waitForService() } func (s *AccountFlowTestSuite) waitForService() { maxRetries := 30 for i := 0; i < maxRetries; i++ { resp, err := s.client.Get(s.baseURL + "/health") if err == nil && resp.StatusCode == http.StatusOK { resp.Body.Close() return } if resp != nil { resp.Body.Close() } time.Sleep(time.Second) } s.T().Fatal("Account service not ready after waiting") } type AccountCreateRequest struct { Username string `json:"username"` Email string `json:"email"` Phone *string `json:"phone"` PublicKey string `json:"publicKey"` KeygenSessionID string `json:"keygenSessionId"` ThresholdN int `json:"thresholdN"` ThresholdT int `json:"thresholdT"` Shares []ShareInput `json:"shares"` } type ShareInput struct { ShareType string `json:"shareType"` PartyID string `json:"partyId"` PartyIndex int `json:"partyIndex"` DeviceType *string `json:"deviceType"` DeviceID *string `json:"deviceId"` } type AccountResponse struct { Account struct { ID string `json:"id"` Username string `json:"username"` Email string `json:"email"` Phone *string `json:"phone"` ThresholdN int `json:"thresholdN"` ThresholdT int `json:"thresholdT"` Status string `json:"status"` KeygenSessionID string `json:"keygenSessionId"` } `json:"account"` Shares []struct { ID string `json:"id"` ShareType string `json:"shareType"` PartyID string `json:"partyId"` PartyIndex int `json:"partyIndex"` DeviceType *string `json:"deviceType"` DeviceID *string `json:"deviceId"` IsActive bool `json:"isActive"` } `json:"shares"` } type ChallengeResponse struct { ChallengeID string `json:"challengeId"` Challenge string `json:"challenge"` ExpiresAt string `json:"expiresAt"` } type LoginResponse struct { Account struct { ID string `json:"id"` Username string `json:"username"` } `json:"account"` AccessToken string `json:"accessToken"` RefreshToken string `json:"refreshToken"` } func (s *AccountFlowTestSuite) TestCompleteAccountFlow() { // Generate a test keypair privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(s.T(), err) publicKeyBytes := crypto.MarshalPublicKey(&privateKey.PublicKey) // Step 1: Create account uniqueID := uuid.New().String()[:8] phone := "+1234567890" deviceType := "iOS" deviceID := "test_device_001" createReq := AccountCreateRequest{ Username: "e2e_test_user_" + uniqueID, Email: "e2e_test_" + uniqueID + "@example.com", Phone: &phone, PublicKey: hex.EncodeToString(publicKeyBytes), KeygenSessionID: uuid.New().String(), ThresholdN: 3, ThresholdT: 2, Shares: []ShareInput{ { ShareType: "user_device", PartyID: "party_user_" + uniqueID, PartyIndex: 0, DeviceType: &deviceType, DeviceID: &deviceID, }, { ShareType: "server", PartyID: "party_server_" + uniqueID, PartyIndex: 1, }, { ShareType: "recovery", PartyID: "party_recovery_" + uniqueID, PartyIndex: 2, }, }, } accountResp := s.createAccount(createReq) require.NotEmpty(s.T(), accountResp.Account.ID) assert.Equal(s.T(), createReq.Username, accountResp.Account.Username) assert.Equal(s.T(), createReq.Email, accountResp.Account.Email) assert.Equal(s.T(), "active", accountResp.Account.Status) assert.Len(s.T(), accountResp.Shares, 3) accountID := accountResp.Account.ID // Step 2: Get account by ID retrievedAccount := s.getAccount(accountID) assert.Equal(s.T(), accountID, retrievedAccount.Account.ID) // Step 3: Get account shares shares := s.getAccountShares(accountID) assert.Len(s.T(), shares, 3) // Step 4: Generate login challenge challengeResp := s.generateChallenge(createReq.Username) require.NotEmpty(s.T(), challengeResp.ChallengeID) require.NotEmpty(s.T(), challengeResp.Challenge) // Step 5: Sign challenge and login challengeBytes, _ := hex.DecodeString(challengeResp.Challenge) signature, err := crypto.SignMessage(privateKey, challengeBytes) require.NoError(s.T(), err) loginResp := s.login(createReq.Username, challengeResp.Challenge, hex.EncodeToString(signature)) require.NotEmpty(s.T(), loginResp.AccessToken) require.NotEmpty(s.T(), loginResp.RefreshToken) // Step 6: Refresh token newTokens := s.refreshToken(loginResp.RefreshToken) require.NotEmpty(s.T(), newTokens.AccessToken) // Step 7: Update account newPhone := "+9876543210" s.updateAccount(accountID, &newPhone) updatedAccount := s.getAccount(accountID) assert.Equal(s.T(), newPhone, *updatedAccount.Account.Phone) // Step 8: Deactivate a share if len(shares) > 0 { shareID := shares[0].ID s.deactivateShare(accountID, shareID) updatedShares := s.getAccountShares(accountID) for _, share := range updatedShares { if share.ID == shareID { assert.False(s.T(), share.IsActive) } } } } func (s *AccountFlowTestSuite) TestAccountRecoveryFlow() { // Generate keypairs oldPrivateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) oldPublicKeyBytes := crypto.MarshalPublicKey(&oldPrivateKey.PublicKey) newPrivateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) newPublicKeyBytes := crypto.MarshalPublicKey(&newPrivateKey.PublicKey) // Create account uniqueID := uuid.New().String()[:8] createReq := AccountCreateRequest{ Username: "e2e_recovery_user_" + uniqueID, Email: "e2e_recovery_" + uniqueID + "@example.com", PublicKey: hex.EncodeToString(oldPublicKeyBytes), KeygenSessionID: uuid.New().String(), ThresholdN: 3, ThresholdT: 2, Shares: []ShareInput{ {ShareType: "user_device", PartyID: "party_user_" + uniqueID, PartyIndex: 0}, {ShareType: "server", PartyID: "party_server_" + uniqueID, PartyIndex: 1}, {ShareType: "recovery", PartyID: "party_recovery_" + uniqueID, PartyIndex: 2}, }, } accountResp := s.createAccount(createReq) accountID := accountResp.Account.ID // Step 1: Initiate recovery oldShareType := "user_device" recoveryResp := s.initiateRecovery(accountID, "device_lost", &oldShareType) require.NotEmpty(s.T(), recoveryResp.RecoverySessionID) recoverySessionID := recoveryResp.RecoverySessionID // Step 2: Check recovery status recoveryStatus := s.getRecoveryStatus(recoverySessionID) assert.Equal(s.T(), "requested", recoveryStatus.Status) // Step 3: Complete recovery with new keys newKeygenSessionID := uuid.New().String() s.completeRecovery(recoverySessionID, hex.EncodeToString(newPublicKeyBytes), newKeygenSessionID, []ShareInput{ {ShareType: "user_device", PartyID: "new_party_user_" + uniqueID, PartyIndex: 0}, {ShareType: "server", PartyID: "new_party_server_" + uniqueID, PartyIndex: 1}, {ShareType: "recovery", PartyID: "new_party_recovery_" + uniqueID, PartyIndex: 2}, }) // Step 4: Verify account is active again updatedAccount := s.getAccount(accountID) assert.Equal(s.T(), "active", updatedAccount.Account.Status) } func (s *AccountFlowTestSuite) TestDuplicateUsername() { uniqueID := uuid.New().String()[:8] privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) publicKeyBytes := crypto.MarshalPublicKey(&privateKey.PublicKey) createReq := AccountCreateRequest{ Username: "e2e_duplicate_" + uniqueID, Email: "e2e_dup1_" + uniqueID + "@example.com", PublicKey: hex.EncodeToString(publicKeyBytes), KeygenSessionID: uuid.New().String(), ThresholdN: 2, ThresholdT: 2, Shares: []ShareInput{ {ShareType: "user_device", PartyID: "party1", PartyIndex: 0}, {ShareType: "server", PartyID: "party2", PartyIndex: 1}, }, } // First account should succeed s.createAccount(createReq) // Second account with same username should fail createReq.Email = "e2e_dup2_" + uniqueID + "@example.com" body, _ := json.Marshal(createReq) resp, err := s.client.Post( s.baseURL+"/api/v1/accounts", "application/json", bytes.NewReader(body), ) require.NoError(s.T(), err) defer resp.Body.Close() assert.Equal(s.T(), http.StatusInternalServerError, resp.StatusCode) // Duplicate error } func (s *AccountFlowTestSuite) TestInvalidLogin() { // Try to login with non-existent user challengeResp := s.generateChallenge("nonexistent_user_xyz") // Even if challenge is generated, login should fail resp, err := s.client.Post( s.baseURL+"/api/v1/auth/login", "application/json", bytes.NewReader([]byte(`{"username":"nonexistent_user_xyz","challenge":"abc","signature":"def"}`)), ) require.NoError(s.T(), err) defer resp.Body.Close() assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode) _ = challengeResp // suppress unused variable warning } // Helper methods func (s *AccountFlowTestSuite) createAccount(req AccountCreateRequest) AccountResponse { body, _ := json.Marshal(req) resp, err := s.client.Post( s.baseURL+"/api/v1/accounts", "application/json", bytes.NewReader(body), ) require.NoError(s.T(), err) defer resp.Body.Close() require.Equal(s.T(), http.StatusCreated, resp.StatusCode) var result AccountResponse err = json.NewDecoder(resp.Body).Decode(&result) require.NoError(s.T(), err) return result } func (s *AccountFlowTestSuite) getAccount(accountID string) AccountResponse { resp, err := s.client.Get(s.baseURL + "/api/v1/accounts/" + accountID) require.NoError(s.T(), err) defer resp.Body.Close() require.Equal(s.T(), http.StatusOK, resp.StatusCode) var result AccountResponse err = json.NewDecoder(resp.Body).Decode(&result) require.NoError(s.T(), err) return result } func (s *AccountFlowTestSuite) getAccountShares(accountID string) []struct { ID string `json:"id"` ShareType string `json:"shareType"` PartyID string `json:"partyId"` PartyIndex int `json:"partyIndex"` DeviceType *string `json:"deviceType"` DeviceID *string `json:"deviceId"` IsActive bool `json:"isActive"` } { resp, err := s.client.Get(s.baseURL + "/api/v1/accounts/" + accountID + "/shares") require.NoError(s.T(), err) defer resp.Body.Close() require.Equal(s.T(), http.StatusOK, resp.StatusCode) var result struct { Shares []struct { ID string `json:"id"` ShareType string `json:"shareType"` PartyID string `json:"partyId"` PartyIndex int `json:"partyIndex"` DeviceType *string `json:"deviceType"` DeviceID *string `json:"deviceId"` IsActive bool `json:"isActive"` } `json:"shares"` } err = json.NewDecoder(resp.Body).Decode(&result) require.NoError(s.T(), err) return result.Shares } func (s *AccountFlowTestSuite) generateChallenge(username string) ChallengeResponse { req := map[string]string{"username": username} body, _ := json.Marshal(req) resp, err := s.client.Post( s.baseURL+"/api/v1/auth/challenge", "application/json", bytes.NewReader(body), ) require.NoError(s.T(), err) defer resp.Body.Close() require.Equal(s.T(), http.StatusOK, resp.StatusCode) var result ChallengeResponse err = json.NewDecoder(resp.Body).Decode(&result) require.NoError(s.T(), err) return result } func (s *AccountFlowTestSuite) login(username, challenge, signature string) LoginResponse { req := map[string]string{ "username": username, "challenge": challenge, "signature": signature, } body, _ := json.Marshal(req) resp, err := s.client.Post( s.baseURL+"/api/v1/auth/login", "application/json", bytes.NewReader(body), ) require.NoError(s.T(), err) defer resp.Body.Close() require.Equal(s.T(), http.StatusOK, resp.StatusCode) var result LoginResponse err = json.NewDecoder(resp.Body).Decode(&result) require.NoError(s.T(), err) return result } func (s *AccountFlowTestSuite) refreshToken(refreshToken string) struct { AccessToken string `json:"accessToken"` RefreshToken string `json:"refreshToken"` } { req := map[string]string{"refreshToken": refreshToken} body, _ := json.Marshal(req) resp, err := s.client.Post( s.baseURL+"/api/v1/auth/refresh", "application/json", bytes.NewReader(body), ) require.NoError(s.T(), err) defer resp.Body.Close() require.Equal(s.T(), http.StatusOK, resp.StatusCode) var result struct { AccessToken string `json:"accessToken"` RefreshToken string `json:"refreshToken"` } err = json.NewDecoder(resp.Body).Decode(&result) require.NoError(s.T(), err) return result } func (s *AccountFlowTestSuite) updateAccount(accountID string, phone *string) { req := map[string]*string{"phone": phone} body, _ := json.Marshal(req) httpReq, _ := http.NewRequest( http.MethodPut, s.baseURL+"/api/v1/accounts/"+accountID, bytes.NewReader(body), ) httpReq.Header.Set("Content-Type", "application/json") resp, err := s.client.Do(httpReq) require.NoError(s.T(), err) defer resp.Body.Close() require.Equal(s.T(), http.StatusOK, resp.StatusCode) } func (s *AccountFlowTestSuite) deactivateShare(accountID, shareID string) { httpReq, _ := http.NewRequest( http.MethodDelete, s.baseURL+"/api/v1/accounts/"+accountID+"/shares/"+shareID, nil, ) resp, err := s.client.Do(httpReq) require.NoError(s.T(), err) defer resp.Body.Close() require.Equal(s.T(), http.StatusOK, resp.StatusCode) } func (s *AccountFlowTestSuite) initiateRecovery(accountID, recoveryType string, oldShareType *string) struct { RecoverySessionID string `json:"recoverySessionId"` } { req := map[string]interface{}{ "accountId": accountID, "recoveryType": recoveryType, } if oldShareType != nil { req["oldShareType"] = *oldShareType } body, _ := json.Marshal(req) resp, err := s.client.Post( s.baseURL+"/api/v1/recovery", "application/json", bytes.NewReader(body), ) require.NoError(s.T(), err) defer resp.Body.Close() require.Equal(s.T(), http.StatusCreated, resp.StatusCode) var result struct { RecoverySession struct { ID string `json:"id"` } `json:"recoverySession"` } err = json.NewDecoder(resp.Body).Decode(&result) require.NoError(s.T(), err) return struct { RecoverySessionID string `json:"recoverySessionId"` }{ RecoverySessionID: result.RecoverySession.ID, } } func (s *AccountFlowTestSuite) getRecoveryStatus(recoverySessionID string) struct { Status string `json:"status"` } { resp, err := s.client.Get(s.baseURL + "/api/v1/recovery/" + recoverySessionID) require.NoError(s.T(), err) defer resp.Body.Close() require.Equal(s.T(), http.StatusOK, resp.StatusCode) var result struct { Status string `json:"status"` } err = json.NewDecoder(resp.Body).Decode(&result) require.NoError(s.T(), err) return result } func (s *AccountFlowTestSuite) completeRecovery(recoverySessionID, newPublicKey, newKeygenSessionID string, newShares []ShareInput) { req := map[string]interface{}{ "newPublicKey": newPublicKey, "newKeygenSessionId": newKeygenSessionID, "newShares": newShares, } body, _ := json.Marshal(req) resp, err := s.client.Post( s.baseURL+"/api/v1/recovery/"+recoverySessionID+"/complete", "application/json", bytes.NewReader(body), ) require.NoError(s.T(), err) defer resp.Body.Close() require.Equal(s.T(), http.StatusOK, resp.StatusCode) }