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

568 lines
16 KiB
Go

//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)
}