+signup +email availability check
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/bump_version Pipeline failed
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful

This commit is contained in:
Stephan D
2025-11-17 18:00:38 +01:00
parent 4c64a8d6e6
commit 1ab7f2e7d3
13 changed files with 496 additions and 68 deletions

View File

@@ -2,7 +2,12 @@ package accountapiimp
import (
"context"
"fmt"
"os"
"strings"
"time"
chaingatewayclient "github.com/tech/sendico/chain/gateway/client"
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db/account"
@@ -14,6 +19,7 @@ import (
"github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1"
"github.com/tech/sendico/server/interface/accountservice"
eapi "github.com/tech/sendico/server/interface/api"
"github.com/tech/sendico/server/interface/services/fileservice"
@@ -39,6 +45,13 @@ type AccountAPI struct {
tph mutil.ParamHelper
accountsPermissionRef primitive.ObjectID
accService accountservice.AccountService
chainGateway chainWalletClient
chainAsset *gatewayv1.Asset
}
type chainWalletClient interface {
CreateManagedWallet(ctx context.Context, req *gatewayv1.CreateManagedWalletRequest) (*gatewayv1.CreateManagedWalletResponse, error)
Close() error
}
func (a *AccountAPI) Name() mservice.Type {
@@ -46,7 +59,15 @@ func (a *AccountAPI) Name() mservice.Type {
}
func (a *AccountAPI) Finish(ctx context.Context) error {
return a.avatars.Finish(ctx)
if err := a.avatars.Finish(ctx); err != nil {
return err
}
if a.chainGateway != nil {
if err := a.chainGateway.Close(); err != nil {
a.logger.Warn("Failed to close chain gateway client", zap.Error(err))
}
}
return nil
}
func CreateAPI(a eapi.API) (*AccountAPI, error) {
@@ -86,6 +107,7 @@ func CreateAPI(a eapi.API) (*AccountAPI, error) {
// Account related api endpoints
a.Register().Handler(mservice.Accounts, "/signup", api.Post, p.signup)
a.Register().Handler(mservice.Accounts, "/signup/availability", api.Get, p.signupAvailability)
a.Register().AccountHandler(mservice.Accounts, "", api.Put, p.updateProfile)
a.Register().AccountHandler(mservice.Accounts, "", api.Get, p.getProfile)
@@ -120,5 +142,74 @@ func CreateAPI(a eapi.API) (*AccountAPI, error) {
}
p.accountsPermissionRef = accountsPolicy.ID
if err := p.initChainGateway(a.Config()); err != nil {
p.logger.Error("Failed to initialize chain gateway client", zap.Error(err))
return nil, err
}
return p, nil
}
func (a *AccountAPI) initChainGateway(cfg *eapi.Config) error {
if cfg == nil || cfg.ChainGateway == nil {
return fmt.Errorf("chain gateway configuration is not provided")
}
address := strings.TrimSpace(os.Getenv(cfg.ChainGateway.AddressEnv))
if address == "" {
return fmt.Errorf("chain gateway address env %s is empty", cfg.ChainGateway.AddressEnv)
}
clientCfg := chaingatewayclient.Config{
Address: address,
DialTimeout: time.Duration(cfg.ChainGateway.DialTimeoutSeconds) * time.Second,
CallTimeout: time.Duration(cfg.ChainGateway.CallTimeoutSeconds) * time.Second,
Insecure: cfg.ChainGateway.Insecure,
}
client, err := chaingatewayclient.New(context.Background(), clientCfg)
if err != nil {
return err
}
asset, err := buildGatewayAsset(cfg.ChainGateway.DefaultAsset)
if err != nil {
_ = client.Close()
return err
}
a.chainGateway = client
a.chainAsset = asset
return nil
}
func buildGatewayAsset(cfg eapi.ChainGatewayAssetConfig) (*gatewayv1.Asset, error) {
chain, err := parseChainNetwork(cfg.Chain)
if err != nil {
return nil, err
}
tokenSymbol := strings.TrimSpace(cfg.TokenSymbol)
if tokenSymbol == "" {
return nil, fmt.Errorf("chain gateway token symbol is required")
}
return &gatewayv1.Asset{
Chain: chain,
TokenSymbol: strings.ToUpper(tokenSymbol),
ContractAddress: strings.ToLower(strings.TrimSpace(cfg.ContractAddress)),
}, nil
}
func parseChainNetwork(value string) (gatewayv1.ChainNetwork, error) {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "ETHEREUM_MAINNET", "CHAIN_NETWORK_ETHEREUM_MAINNET":
return gatewayv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, nil
case "ARBITRUM_ONE", "CHAIN_NETWORK_ARBITRUM_ONE":
return gatewayv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, nil
case "OTHER_EVM", "CHAIN_NETWORK_OTHER_EVM":
return gatewayv1.ChainNetwork_CHAIN_NETWORK_OTHER_EVM, nil
case "", "CHAIN_NETWORK_UNSPECIFIED":
return gatewayv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, fmt.Errorf("chain network must be specified")
default:
return gatewayv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, fmt.Errorf("unsupported chain network %s", value)
}
}

View File

@@ -6,13 +6,16 @@ import (
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/mutil/mzap"
gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.mongodb.org/mongo-driver/bson/primitive"
@@ -41,6 +44,10 @@ func (a *AccountAPI) createAnonymousAccount(ctx context.Context, org *model.Orga
}
func (a *AccountAPI) createOrg(ctx context.Context, sr *srequest.Signup, permissionRef primitive.ObjectID) (*model.Organization, error) {
name := strings.TrimSpace(sr.Organization.Name)
if name == "" {
return nil, merrors.InvalidArgument("organization name must not be empty")
}
if _, err := time.LoadLocation(sr.OrganizationTimeZone); err != nil {
return nil, merrors.DataConflict(fmt.Sprintf("invalid time zone '%s' provided, error %s", sr.OrganizationTimeZone, err.Error()))
}
@@ -51,7 +58,8 @@ func (a *AccountAPI) createOrg(ctx context.Context, sr *srequest.Signup, permiss
PermissionRef: permissionRef,
},
Describable: model.Describable{
Name: sr.OrganizationName,
Name: name,
Description: sr.Organization.Description,
},
TimeZone: sr.OrganizationTimeZone,
},
@@ -74,6 +82,18 @@ func (a *AccountAPI) signup(r *http.Request) http.HandlerFunc {
return response.BadRequest(a.logger, a.Name(), "", err.Error())
}
sr.Account.Login = strings.ToLower(strings.TrimSpace(sr.Account.Login))
if err := a.ensureLoginAvailable(r.Context(), sr.Account.Login); err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
return response.DataConflict(a.logger, "user_already_registered", "User has already been registered")
}
if errors.Is(err, merrors.ErrInvalidArg) {
return response.BadPayload(a.logger, a.Name(), err)
}
a.logger.Warn("Failed to validate login availability", zap.Error(err), zap.String("login", sr.Account.Login))
return response.Internal(a.logger, a.Name(), err)
}
newAccount := sr.Account.ToAccount()
if res := a.accService.ValidateAccount(newAccount); res != nil {
a.logger.Warn("Invalid signup account received", zap.Error(res), zap.String("account", newAccount.Login))
@@ -96,6 +116,26 @@ func (a *AccountAPI) signup(r *http.Request) http.HandlerFunc {
return sresponse.SignUp(a.logger, newAccount)
}
func (a *AccountAPI) signupAvailability(r *http.Request) http.HandlerFunc {
login := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("login")))
if login == "" {
return response.BadRequest(a.logger, a.Name(), "missing_login", "login query parameter is required")
}
err := a.ensureLoginAvailable(r.Context(), login)
switch {
case err == nil:
return sresponse.SignUpAvailability(a.logger, login, true)
case errors.Is(err, merrors.ErrDataConflict):
return sresponse.SignUpAvailability(a.logger, login, false)
case errors.Is(err, merrors.ErrInvalidArg):
return response.BadPayload(a.logger, a.Name(), err)
default:
a.logger.Warn("Failed to check login availability", zap.Error(err), zap.String("login", login))
return response.Internal(a.logger, a.Name(), err)
}
}
func (a *AccountAPI) executeSignupTransaction(ctxt context.Context, sr *srequest.Signup, newAccount *model.Account) error {
_, err := a.tf.CreateTransaction().Execute(ctxt, func(ctx context.Context) (any, error) {
return a.signupTransactionBody(ctx, sr, newAccount)
@@ -116,6 +156,10 @@ func (a *AccountAPI) signupTransactionBody(ctx context.Context, sr *srequest.Sig
return nil, err
}
if err := a.openOrgWallet(ctx, org, sr); err != nil {
return nil, err
}
roleDescription, err := a.pmanager.Role().Create(ctx, org.ID, &sr.OwnerRole)
if err != nil {
a.logger.Warn("Failed to create owner role", zap.Error(err), zap.String("login", newAccount.Login))
@@ -146,8 +190,21 @@ func (a *AccountAPI) grantAllPermissions(ctx context.Context, organizationRef pr
return err
}
required := map[mservice.Type]bool{
mservice.Organizations: false,
mservice.Accounts: false,
mservice.LedgerAccounts: false,
}
actions := []model.Action{model.ActionCreate, model.ActionRead, model.ActionUpdate, model.ActionDelete}
for _, policy := range policies {
if policy.ResourceTypes != nil {
for _, resource := range *policy.ResourceTypes {
if _, ok := required[resource]; ok {
required[resource] = true
}
}
}
for _, action := range actions {
a.logger.Debug("Adding permission", mzap.StorableRef(&policy), zap.String("action", string(action)),
mzap.ObjRef("role_ref", roleID), mzap.ObjRef("policy_ref", policy.ID), mzap.ObjRef("organization_ref", organizationRef))
@@ -172,5 +229,55 @@ func (a *AccountAPI) grantAllPermissions(ctx context.Context, organizationRef pr
return err
}
for resource, granted := range required {
if !granted {
a.logger.Warn("Required policy description not found for signup permissions", zap.String("resource", string(resource)))
}
}
return nil
}
func (a *AccountAPI) ensureLoginAvailable(ctx context.Context, login string) error {
if strings.TrimSpace(login) == "" {
return merrors.InvalidArgument("login must not be empty")
}
if _, err := a.db.GetByEmail(ctx, login); err != nil {
if errors.Is(err, merrors.ErrNoData) {
return nil
}
a.logger.Warn("Failed to lookup account by login", zap.Error(err), zap.String("login", login))
return err
}
return merrors.DataConflict("account already exists")
}
func (a *AccountAPI) openOrgWallet(ctx context.Context, org *model.Organization, sr *srequest.Signup) error {
if a.chainGateway == nil || a.chainAsset == nil {
a.logger.Warn("Chain gateway client not configured, skipping wallet creation", mzap.StorableRef(org))
return merrors.Internal("chain gateway client is not configured")
}
asset := *a.chainAsset
req := &gatewayv1.CreateManagedWalletRequest{
IdempotencyKey: uuid.NewString(),
OrganizationRef: org.ID.Hex(),
OwnerRef: org.ID.Hex(),
Asset: &asset,
Metadata: map[string]string{
"source": "signup",
"login": sr.Account.Login,
},
}
resp, err := a.chainGateway.CreateManagedWallet(ctx, req)
if err != nil {
a.logger.Warn("Failed to create managed wallet for organization", zap.Error(err), mzap.StorableRef(org))
return err
}
if resp == nil || resp.Wallet == nil || strings.TrimSpace(resp.Wallet.WalletRef) == "" {
return merrors.Internal("chain gateway returned empty wallet reference")
}
a.logger.Info("Managed wallet created for organization", mzap.StorableRef(org), zap.String("wallet_ref", resp.Wallet.WalletRef))
return nil
}

View File

@@ -65,9 +65,13 @@ func TestSignupRequestSerialization(t *testing.T) {
},
Password: "TestPassword123!",
},
Name: "Test User",
Describable: model.Describable{
Name: "Test User",
},
},
Organization: model.Describable{
Name: "Test Organization",
},
OrganizationName: "Test Organization",
OrganizationTimeZone: "UTC",
AnonymousUser: model.Describable{
Name: "Anonymous User",
@@ -93,7 +97,7 @@ func TestSignupRequestSerialization(t *testing.T) {
// Verify data integrity
assert.Equal(t, signupRequest.Account.Login, retrieved.Account.Login)
assert.Equal(t, signupRequest.Account.Name, retrieved.Account.Name)
assert.Equal(t, signupRequest.OrganizationName, retrieved.OrganizationName)
assert.Equal(t, signupRequest.Organization.Name, retrieved.Organization.Name)
assert.Equal(t, signupRequest.OrganizationTimeZone, retrieved.OrganizationTimeZone)
})
@@ -109,9 +113,13 @@ func TestSignupHTTPSerialization(t *testing.T) {
},
Password: "TestPassword123!",
},
Name: "Test User",
Describable: model.Describable{
Name: "Test User",
},
},
Organization: model.Describable{
Name: "Test Organization",
},
OrganizationName: "Test Organization",
OrganizationTimeZone: "UTC",
AnonymousUser: model.Describable{
Name: "Anonymous User",
@@ -141,13 +149,13 @@ func TestSignupHTTPSerialization(t *testing.T) {
// Verify parsing
assert.Equal(t, signupRequest.Account.Login, parsedRequest.Account.Login)
assert.Equal(t, signupRequest.Account.Name, parsedRequest.Account.Name)
assert.Equal(t, signupRequest.OrganizationName, parsedRequest.OrganizationName)
assert.Equal(t, signupRequest.Organization.Name, parsedRequest.Organization.Name)
})
t.Run("UnicodeCharacters", func(t *testing.T) {
unicodeRequest := signupRequest
unicodeRequest.Account.Name = "Test 用户 Üser"
unicodeRequest.OrganizationName = "测试 Organization"
unicodeRequest.Organization.Name = "测试 Organization"
// Serialize to JSON
reqBody, err := json.Marshal(unicodeRequest)
@@ -160,7 +168,7 @@ func TestSignupHTTPSerialization(t *testing.T) {
// Verify unicode characters are preserved
assert.Equal(t, "Test 用户 Üser", parsedRequest.Account.Name)
assert.Equal(t, "测试 Organization", parsedRequest.OrganizationName)
assert.Equal(t, "测试 Organization", parsedRequest.Organization.Name)
})
t.Run("InvalidJSONRequest", func(t *testing.T) {
@@ -184,7 +192,9 @@ func TestAccountDataConversion(t *testing.T) {
},
Password: "TestPassword123!",
},
Name: "Test User",
Describable: model.Describable{
Name: "Test User",
},
}
t.Run("ToAccount", func(t *testing.T) {

View File

@@ -1,12 +1,18 @@
package accountapiimp
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/tech/sendico/pkg/db/repository/builder"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/server/interface/api/srequest"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
// Helper function to create string pointers
@@ -60,9 +66,13 @@ func TestCreateValidSignupRequest(t *testing.T) {
},
Password: "TestPassword123!",
},
Name: "Test User",
Describable: model.Describable{
Name: "Test User",
},
},
Organization: model.Describable{
Name: "Test Organization",
},
OrganizationName: "Test Organization",
OrganizationTimeZone: "UTC",
AnonymousUser: model.Describable{
Name: "Anonymous User",
@@ -79,7 +89,7 @@ func TestCreateValidSignupRequest(t *testing.T) {
assert.Equal(t, "test@example.com", request.Account.Login)
assert.Equal(t, "TestPassword123!", request.Account.Password)
assert.Equal(t, "Test User", request.Account.Name)
assert.Equal(t, "Test Organization", request.OrganizationName)
assert.Equal(t, "Test Organization", request.Organization.Name)
assert.Equal(t, "UTC", request.OrganizationTimeZone)
}
@@ -94,9 +104,13 @@ func TestSignupRequestValidation(t *testing.T) {
},
Password: "TestPassword123!",
},
Name: "Test User",
Describable: model.Describable{
Name: "Test User",
},
},
Organization: model.Describable{
Name: "Test Organization",
},
OrganizationName: "Test Organization",
OrganizationTimeZone: "UTC",
}
@@ -104,7 +118,7 @@ func TestSignupRequestValidation(t *testing.T) {
assert.NotEmpty(t, request.Account.Login)
assert.NotEmpty(t, request.Account.Password)
assert.NotEmpty(t, request.Account.Name)
assert.NotEmpty(t, request.OrganizationName)
assert.NotEmpty(t, request.Organization.Name)
assert.NotEmpty(t, request.OrganizationTimeZone)
})
@@ -205,7 +219,9 @@ func TestAccountDataToAccount(t *testing.T) {
},
Password: "TestPassword123!",
},
Name: "Test User",
Describable: model.Describable{
Name: "Test User",
},
}
account := accountData.ToAccount()
@@ -240,3 +256,106 @@ func TestColorValidation(t *testing.T) {
})
}
}
type stubAccountDB struct {
result *model.Account
err error
}
func (s *stubAccountDB) GetByEmail(ctx context.Context, email string) (*model.Account, error) {
return s.result, s.err
}
func (s *stubAccountDB) GetByToken(ctx context.Context, email string) (*model.Account, error) {
return nil, merrors.NotImplemented("stub")
}
func (s *stubAccountDB) GetAccountsByRefs(ctx context.Context, orgRef primitive.ObjectID, refs []primitive.ObjectID) ([]model.Account, error) {
return nil, merrors.NotImplemented("stub")
}
func (s *stubAccountDB) Create(ctx context.Context, object *model.Account) error {
return merrors.NotImplemented("stub")
}
func (s *stubAccountDB) InsertMany(ctx context.Context, objects []*model.Account) error {
return merrors.NotImplemented("stub")
}
func (s *stubAccountDB) Get(ctx context.Context, objectRef primitive.ObjectID, result *model.Account) error {
return merrors.NotImplemented("stub")
}
func (s *stubAccountDB) Update(ctx context.Context, object *model.Account) error {
return merrors.NotImplemented("stub")
}
func (s *stubAccountDB) Patch(ctx context.Context, objectRef primitive.ObjectID, patch builder.Patch) error {
return merrors.NotImplemented("stub")
}
func (s *stubAccountDB) Delete(ctx context.Context, objectRef primitive.ObjectID) error {
return merrors.NotImplemented("stub")
}
func (s *stubAccountDB) DeleteMany(ctx context.Context, query builder.Query) error {
return merrors.NotImplemented("stub")
}
func (s *stubAccountDB) DeleteCascade(ctx context.Context, objectRef primitive.ObjectID) error {
return merrors.NotImplemented("stub")
}
func (s *stubAccountDB) FindOne(ctx context.Context, query builder.Query, result *model.Account) error {
return merrors.NotImplemented("stub")
}
func TestEnsureLoginAvailable(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
t.Run("available", func(t *testing.T) {
api := &AccountAPI{
logger: logger,
db: &stubAccountDB{
err: merrors.ErrNoData,
},
}
assert.NoError(t, api.ensureLoginAvailable(ctx, "new@example.com"))
})
t.Run("taken", func(t *testing.T) {
api := &AccountAPI{
logger: logger,
db: &stubAccountDB{
result: &model.Account{},
},
}
err := api.ensureLoginAvailable(ctx, "used@example.com")
assert.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrDataConflict))
})
t.Run("invalid login", func(t *testing.T) {
api := &AccountAPI{
logger: logger,
db: &stubAccountDB{
err: merrors.ErrNoData,
},
}
err := api.ensureLoginAvailable(ctx, " ")
assert.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("db error", func(t *testing.T) {
api := &AccountAPI{
logger: logger,
db: &stubAccountDB{
err: errors.New("boom"),
},
}
err := api.ensureLoginAvailable(ctx, "err@example.com")
assert.EqualError(t, err, "boom")
})
}