568 lines
16 KiB
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)
|
|
}
|