move api/server to api/edge/bff
This commit is contained in:
95
api/edge/bff/internal/server/accountapiimp/account.go
Executable file
95
api/edge/bff/internal/server/accountapiimp/account.go
Executable file
@@ -0,0 +1,95 @@
|
||||
package accountapiimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/db/account"
|
||||
an "github.com/tech/sendico/pkg/messaging/notifications/account"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *AccountAPI) attemptDecodeAccount(r *http.Request) (*model.Account, error) {
|
||||
var u model.Account
|
||||
return &u, json.NewDecoder(r.Body).Decode(&u)
|
||||
}
|
||||
|
||||
func (a *AccountAPI) reportUnauthorized(hint string) http.HandlerFunc {
|
||||
return response.Unauthorized(a.logger, a.Name(), hint)
|
||||
}
|
||||
|
||||
func (a *AccountAPI) reportDuplicateEmail() http.HandlerFunc {
|
||||
return response.Forbidden(a.logger, a.Name(), "duplicate_email", "email has already been registered")
|
||||
}
|
||||
|
||||
func (a *AccountAPI) reportEmailMissing() http.HandlerFunc {
|
||||
return response.BadRequest(a.logger, a.Name(), "email_missing", "email is required")
|
||||
}
|
||||
|
||||
func (a *AccountAPI) sendPasswordResetEmail(account *model.Account, resetToken string) error {
|
||||
if err := a.producer.SendMessage(an.PasswordResetRequested(a.Name(), *account.GetID(), resetToken)); err != nil {
|
||||
a.logger.Warn("Failed to send password reset notification", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AccountAPI) getProfile(_ *http.Request, u *model.Account, token *sresponse.TokenData) http.HandlerFunc {
|
||||
return sresponse.Account(a.logger, u, token)
|
||||
}
|
||||
|
||||
func (a *AccountAPI) sendWelcomeEmail(account *model.Account, token string) error {
|
||||
if err := a.producer.SendMessage(an.AccountCreated(a.Name(), *account.GetID(), token)); err != nil {
|
||||
a.logger.Warn("Failed to send account creation notification", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AccountAPI) sendVerificationMail(r *http.Request, paramGetter func(ctx context.Context, db account.DB, user *model.Account) (*model.Account, error)) http.HandlerFunc {
|
||||
// Validate user input
|
||||
u, err := a.attemptDecodeAccount(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to decide profile update", zap.Error(err))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
accnt, err := paramGetter(ctx, a.db, u)
|
||||
if err != nil || accnt == nil {
|
||||
a.logger.Warn("Failed to ger user from db with", zap.Error(err), mzap.StorableRef(u))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
token, err := a.accService.VerifyAccount(ctx, accnt)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to create verification token for account", zap.Error(err), mzap.StorableRef(accnt))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
// Send welcome email
|
||||
if err = a.sendWelcomeEmail(accnt, token); err != nil {
|
||||
a.logger.Warn("Failed to send verification email",
|
||||
zap.Error(err), mzap.StorableRef(accnt), zap.String("email", accnt.Login))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
return response.Success(a.logger)
|
||||
}
|
||||
|
||||
func getID(ctx context.Context, db account.DB, u *model.Account) (*model.Account, error) {
|
||||
var res model.Account
|
||||
return &res, db.Get(ctx, *u.GetID(), &res)
|
||||
}
|
||||
|
||||
func getEmail(ctx context.Context, db account.DB, u *model.Account) (*model.Account, error) {
|
||||
return db.GetByEmail(ctx, u.Login)
|
||||
}
|
||||
|
||||
func (a *AccountAPI) reportNoEmailRegistered() http.HandlerFunc {
|
||||
return response.BadRequest(a.logger, a.Name(), "email_not_registered", "no account registered with this email")
|
||||
}
|
||||
130
api/edge/bff/internal/server/accountapiimp/delete.go
Normal file
130
api/edge/bff/internal/server/accountapiimp/delete.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package accountapiimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"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"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *AccountAPI) deleteProfile(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
|
||||
ctx := r.Context()
|
||||
|
||||
// Get the current organization from the request context
|
||||
orgRef, err := a.getCurrentOrganizationRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to get current organization", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.BadRequest(a.logger, a.Name(), "invalid_organization_context", "Invalid organization context")
|
||||
}
|
||||
|
||||
// Get the organization
|
||||
var org model.Organization
|
||||
if err := a.odb.Get(ctx, *account.GetID(), orgRef, &org); err != nil {
|
||||
a.logger.Error("Failed to fetch organization", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
// Delete the account (this will check if it's the only member)
|
||||
if err := a.accService.DeleteAccount(ctx, &org, account.ID); err != nil {
|
||||
if errors.Is(err, merrors.ErrInvalidArg) {
|
||||
a.logger.Warn("Cannot delete account - validation failed", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.BadRequest(a.logger, a.Name(), "validation_failed", err.Error())
|
||||
}
|
||||
a.logger.Error("Failed to delete account", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
a.logger.Info("Account deleted successfully", mzap.StorableRef(account))
|
||||
return response.Success(a.logger)
|
||||
}
|
||||
|
||||
func (a *AccountAPI) deleteOrganization(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
|
||||
ctx := r.Context()
|
||||
|
||||
// Get the current organization from the request context
|
||||
orgRef, err := a.getCurrentOrganizationRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to get current organization", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.BadRequest(a.logger, a.Name(), "invalid_organization_context", "Invalid organization context")
|
||||
}
|
||||
|
||||
// Get the organization
|
||||
var org model.Organization
|
||||
if err := a.odb.Get(ctx, *account.GetID(), orgRef, &org); err != nil {
|
||||
a.logger.Error("Failed to fetch organization", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
// Delete the organization and all its data
|
||||
if err := a.accService.DeleteOrganization(ctx, &org); err != nil {
|
||||
a.logger.Error("Failed to delete organization", zap.Error(err), mzap.StorableRef(&org))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
a.logger.Info("Organization deleted successfully", mzap.StorableRef(&org))
|
||||
return response.Success(a.logger)
|
||||
}
|
||||
|
||||
func (a *AccountAPI) deleteAll(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
|
||||
ctx := r.Context()
|
||||
|
||||
// Get the current organization from the request context
|
||||
orgRef, err := a.getCurrentOrganizationRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to get current organization", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.BadRequest(a.logger, a.Name(), "invalid_organization_context", "Invalid organization context")
|
||||
}
|
||||
|
||||
// Get the organization
|
||||
var org model.Organization
|
||||
if err := a.odb.Get(ctx, *account.GetID(), orgRef, &org); err != nil {
|
||||
a.logger.Error("Failed to fetch organization", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
// Get organization permission reference
|
||||
var orgPolicy model.PolicyDescription
|
||||
if err := a.plcdb.GetBuiltInPolicy(ctx, mservice.Organizations, &orgPolicy); err != nil {
|
||||
a.logger.Error("Failed to fetch organization policy", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
// Check if user has permission to delete the organization
|
||||
canDelete, err := a.enf.Enforce(ctx, orgPolicy.ID, account.ID, orgRef, bson.NilObjectID, model.ActionDelete)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to check delete permission", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
if !canDelete {
|
||||
a.logger.Warn("User does not have permission to delete organization", mzap.StorableRef(account), mzap.StorableRef(&org))
|
||||
return response.AccessDenied(a.logger, a.Name(), "Insufficient permissions to delete organization")
|
||||
}
|
||||
|
||||
// Delete everything (organization + account)
|
||||
if _, err := a.tf.CreateTransaction().Execute(ctx, func(c context.Context) (any, error) {
|
||||
if err := a.accService.DeleteAll(c, &org, account.ID); err != nil {
|
||||
a.logger.Warn("Failed to delete all data", zap.Error(err), mzap.StorableRef(&org), mzap.StorableRef(account))
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
}); err != nil {
|
||||
a.logger.Warn("Failed to execute delete transaction", zap.Error(err), mzap.StorableRef(&org), mzap.StorableRef(account))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
a.logger.Info("All data deleted successfully", mzap.StorableRef(&org), mzap.StorableRef(account))
|
||||
return response.Success(a.logger)
|
||||
}
|
||||
|
||||
// Helper method to get current organization reference from request context
|
||||
func (a *AccountAPI) getCurrentOrganizationRef(r *http.Request) (bson.ObjectID, error) {
|
||||
return a.oph.GetRef(r)
|
||||
}
|
||||
49
api/edge/bff/internal/server/accountapiimp/dzone.go
Normal file
49
api/edge/bff/internal/server/accountapiimp/dzone.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package accountapiimp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *AccountAPI) dzone(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
|
||||
ctx := r.Context()
|
||||
orgs, err := a.odb.List(ctx, account.ID, nil)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to list owned organizations", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
orgsPBS := make([]model.PermissionBoundStorable, len(orgs))
|
||||
for i, org := range orgs {
|
||||
orgsPBS[i] = &org
|
||||
}
|
||||
res, err := a.enf.EnforceBatch(ctx, orgsPBS, account.ID, model.ActionDelete)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to enforce permissions", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
validOrgs := make([]model.Organization, 0, len(orgs))
|
||||
for _, org := range orgs {
|
||||
if res[org.ID] {
|
||||
validOrgs = append(validOrgs, org)
|
||||
a.logger.Debug("Organization can be deleted", mzap.StorableRef(&org), mzap.StorableRef(account))
|
||||
} else {
|
||||
a.logger.Debug("Organization does not have delete permission for account", mzap.StorableRef(&org), mzap.StorableRef(account))
|
||||
}
|
||||
}
|
||||
|
||||
return sresponse.DZone(
|
||||
a.logger,
|
||||
&model.DZone{
|
||||
CanDeleteAccount: true,
|
||||
CanDeleteCascade: len(validOrgs) > 0,
|
||||
Organizations: validOrgs,
|
||||
},
|
||||
token,
|
||||
)
|
||||
}
|
||||
61
api/edge/bff/internal/server/accountapiimp/email.go
Normal file
61
api/edge/bff/internal/server/accountapiimp/email.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package accountapiimp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *AccountAPI) verify(r *http.Request) http.HandlerFunc {
|
||||
// Validate user input
|
||||
token := mutil.GetToken(r)
|
||||
// Get user
|
||||
ctx := r.Context()
|
||||
// Delete verification token to confirm account
|
||||
t, err := a.vdb.Consume(ctx, bson.NilObjectID, model.PurposeAccountActivation, token)
|
||||
if err != nil {
|
||||
a.logger.Debug("Failed to consume verification token", zap.Error(err))
|
||||
return a.mapTokenErrorToResponse(err)
|
||||
}
|
||||
|
||||
if t.Purpose != model.PurposeAccountActivation {
|
||||
a.logger.Warn("Invalid token purpose", zap.String("expected", string(model.PurposeAccountActivation)), zap.String("actual", string(t.Purpose)))
|
||||
return response.DataConflict(a.logger, a.Name(), "Invalid token purpose")
|
||||
}
|
||||
|
||||
var user model.Account
|
||||
if err := a.db.Get(ctx, t.AccountRef, &user); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
a.logger.Debug("Verified user not found", zap.Error(err))
|
||||
return response.NotFound(a.logger, a.Name(), "User not found")
|
||||
}
|
||||
a.logger.Warn("Failed to fetch account", zap.Error(err))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
user.Status = model.AccountActive
|
||||
if err = a.db.Update(ctx, &user); err != nil {
|
||||
a.logger.Warn("Failed to save account while verifying account", zap.Error(err))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
if err := a.sendAccountVerificationCompletedNotification(&user); err != nil {
|
||||
a.logger.Warn("Failed to enqueue account verification notification", zap.Error(err), zap.String("email", user.Login))
|
||||
}
|
||||
|
||||
// TODO: Send verification confirmation email
|
||||
return response.Success(a.logger)
|
||||
}
|
||||
|
||||
func (a *AccountAPI) resendVerificationMail(r *http.Request) http.HandlerFunc {
|
||||
return a.sendVerificationMail(r, getID)
|
||||
}
|
||||
|
||||
func (a *AccountAPI) resendVerification(r *http.Request) http.HandlerFunc {
|
||||
return a.sendVerificationMail(r, getEmail)
|
||||
}
|
||||
43
api/edge/bff/internal/server/accountapiimp/employees.go
Normal file
43
api/edge/bff/internal/server/accountapiimp/employees.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package accountapiimp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *AccountAPI) getEmployees(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
|
||||
orgRef, err := a.oph.GetRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to fetch organizaiton reference", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r)))
|
||||
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
|
||||
}
|
||||
ctx := r.Context()
|
||||
res, err := a.enf.Enforce(ctx, a.accountsPermissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionRead)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to check accounts access permissions", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), mzap.StorableRef(account))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
if !res {
|
||||
a.logger.Debug("Access denied when reading organization employees", mzap.StorableRef(account))
|
||||
return response.AccessDenied(a.logger, a.Name(), "orgnizations employees read permission denied")
|
||||
}
|
||||
var org model.Organization
|
||||
if err := a.odb.Get(ctx, *account.GetID(), orgRef, &org); err != nil {
|
||||
a.logger.Warn("Failed to fetch organization", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), mzap.StorableRef(account))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
emps, err := a.db.GetAccountsByRefs(ctx, orgRef, org.Members)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to fetch organization emplpyees", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), mzap.StorableRef(account))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return sresponse.Accounts(a.logger, emps, orgRef, token)
|
||||
}
|
||||
83
api/edge/bff/internal/server/accountapiimp/empupdate.go
Normal file
83
api/edge/bff/internal/server/accountapiimp/empupdate.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package accountapiimp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"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"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *AccountAPI) updateEmployee(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
|
||||
orgRef, err := a.getCurrentOrganizationRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to get current organization", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.BadRequest(a.logger, a.Name(), "invalid_organization_context", "Invalid organization context")
|
||||
}
|
||||
|
||||
// Validate user input
|
||||
var u model.AccountPublic
|
||||
if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
|
||||
a.logger.Warn("Failed to decide profile update", zap.Error(err))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
res, err := a.enf.Enforce(ctx, a.accountsPermissionRef, account.ID, orgRef, u.ID, model.ActionUpdate)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to check employee update permission", zap.Error(err), mzap.StorableRef(account), mzap.ObjRef("employee_ref", u.ID))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
if !res {
|
||||
a.logger.Debug("Permission deined for employee update", mzap.StorableRef(account), mzap.ObjRef("employee_ref", u.ID))
|
||||
return response.Auto(a.logger, a.Name(), merrors.AccessDenied(mservice.Accounts, string(model.ActionUpdate), u.ID))
|
||||
}
|
||||
|
||||
if u.Login == "" {
|
||||
a.logger.Debug("No email in request")
|
||||
return a.reportEmailMissing()
|
||||
}
|
||||
if u.Name == "" {
|
||||
a.logger.Debug("No name in request")
|
||||
return response.BadRequest(a.logger, a.Name(), "name_missing", "name is required")
|
||||
}
|
||||
|
||||
var acc model.Account
|
||||
if err := a.db.Get(ctx, u.ID, &acc); err != nil {
|
||||
a.logger.Warn("Failed to fetch employee account", zap.Error(err), mzap.ObjRef("employee_ref", u.ID))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
if acc.Login != u.Login {
|
||||
// Change email address
|
||||
verificationToken, err := a.accService.UpdateLogin(ctx, &acc, u.Login)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
a.logger.Debug("Duplicate login, denying change...", zap.Error(err), mzap.ObjRef("employee_ref", u.ID))
|
||||
return a.reportDuplicateEmail()
|
||||
}
|
||||
a.logger.Warn("Error while updating login", zap.Error(err), mzap.ObjRef("employee_ref", u.ID))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
// Send verification email
|
||||
if err = a.sendWelcomeEmail(&acc, verificationToken); err != nil {
|
||||
a.logger.Warn("Failed to send verification email", zap.Error(err), mzap.StorableRef(&acc))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
} else {
|
||||
// Save the user
|
||||
acc.AccountPublic = u
|
||||
if err = a.db.Update(ctx, &acc); err != nil {
|
||||
a.logger.Warn("Failed to save account", zap.Error(err), mzap.StorableRef(&acc))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
return sresponse.Account(a.logger, &acc, token)
|
||||
}
|
||||
196
api/edge/bff/internal/server/accountapiimp/password.go
Normal file
196
api/edge/bff/internal/server/accountapiimp/password.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package accountapiimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"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/mutil/mzap"
|
||||
"github.com/tech/sendico/server/interface/api/srequest"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *AccountAPI) checkPassword(_ *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
return sresponse.Account(a.logger, account, accessToken)
|
||||
}
|
||||
|
||||
func (a *AccountAPI) changePassword(r *http.Request, user *model.Account, token *sresponse.TokenData) http.HandlerFunc {
|
||||
// TODO: add rate check
|
||||
var pcr srequest.ChangePassword
|
||||
if err := json.NewDecoder(r.Body).Decode(&pcr); err != nil {
|
||||
a.logger.Warn("Failed to decode password change request", zap.Error(err), mzap.StorableRef(user))
|
||||
return response.BadPayload(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
if err := a.accService.ValidatePassword(pcr.New, &pcr.Old); err != nil {
|
||||
a.logger.Debug("Password validation failed", zap.Error(err), mzap.StorableRef(user))
|
||||
return sresponse.BadRPassword(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
if !user.MatchPassword(pcr.Old) {
|
||||
a.logger.Info("Old password does not match", mzap.StorableRef(user))
|
||||
return a.reportUnauthorized("old password does not match")
|
||||
}
|
||||
user.Password = pcr.New
|
||||
if err := user.HashPassword(); err != nil {
|
||||
a.logger.Warn("Failed to hash new password", zap.Error(err), mzap.StorableRef(user))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
if err := a.db.Update(ctx, user); err != nil {
|
||||
a.logger.Warn("Failed to save account", zap.Error(err), mzap.StorableRef(user))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
if err := a.rtdb.RevokeAll(ctx, *user.GetID(), pcr.DeviceID); err != nil {
|
||||
a.logger.Warn("Failed to revoke refresh tokens",
|
||||
zap.Error(err), mzap.StorableRef(user), zap.String("device_id", pcr.DeviceID))
|
||||
}
|
||||
|
||||
return sresponse.Account(a.logger, user, token)
|
||||
}
|
||||
|
||||
func (a *AccountAPI) forgotPassword(r *http.Request) http.HandlerFunc {
|
||||
var req srequest.ForgotPassword
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
a.logger.Warn("Failed to decode password change request", zap.Error(err))
|
||||
return response.BadPayload(a.logger, a.Name(), err)
|
||||
}
|
||||
if req.Login == "" {
|
||||
a.logger.Debug("Email is missing in the request")
|
||||
return a.reportEmailMissing()
|
||||
}
|
||||
|
||||
// Always use the lower case email address
|
||||
req.Login = strings.ToLower(req.Login)
|
||||
|
||||
// Get user
|
||||
ctx := r.Context()
|
||||
user, err := a.db.GetByEmail(ctx, req.Login)
|
||||
if (errors.Is(err, merrors.ErrNoData)) || (user == nil) {
|
||||
a.logger.Debug("User not found while recovering password", zap.Error(err), zap.String("email", req.Login))
|
||||
return a.reportNoEmailRegistered()
|
||||
}
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to fetch user", zap.Error(err), zap.String("email", req.Login))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
// Generate reset password token
|
||||
verificationToken, err := a.accService.ResetPassword(ctx, user)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to generate reset password token", zap.Error(err), mzap.StorableRef(user))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
// Send reset password email
|
||||
if err = a.sendPasswordResetEmail(user, verificationToken); err != nil {
|
||||
a.logger.Warn("Failed to send reset password email", zap.Error(err), mzap.StorableRef(user))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
a.logger.Info("Reset password email sent successfully", zap.String("email", user.Login))
|
||||
return response.Success(a.logger)
|
||||
}
|
||||
|
||||
func (a *AccountAPI) resetPassword(r *http.Request) http.HandlerFunc {
|
||||
ctx := r.Context()
|
||||
|
||||
// Get account reference and token from URL parameters using parameter helpers
|
||||
accountRef, err := a.aph.GetRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to get account reference", zap.Error(err), mutil.PLog(a.aph, r))
|
||||
return response.BadReference(a.logger, a.Name(), a.aph.Name(), a.aph.GetID(r), err)
|
||||
}
|
||||
|
||||
token := a.tph.GetID(r)
|
||||
if token == "" {
|
||||
a.logger.Warn("Missing token in reset password request")
|
||||
return response.BadRequest(a.logger, a.Name(), "missing_parameters", "token is required")
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
var user model.Account
|
||||
err = a.db.Get(ctx, accountRef, &user)
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
a.logger.Info("User not found for password reset", zap.String("account_ref", accountRef.Hex()))
|
||||
return response.NotFound(a.logger, a.Name(), "User not found")
|
||||
}
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to get user for password reset", zap.Error(err), zap.String("account_ref", accountRef.Hex()))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
t, err := a.vdb.Consume(ctx, accountRef, model.PurposePasswordReset, token)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to consume password reset token", zap.Error(err), zap.String("token", token))
|
||||
return a.mapTokenErrorToResponse(err)
|
||||
}
|
||||
|
||||
if t.Purpose != model.PurposePasswordReset {
|
||||
a.logger.Warn("Invalid token purpose for password reset", zap.String("expected", string(model.PurposePasswordReset)), zap.String("actual", string(t.Purpose)))
|
||||
return response.DataConflict(a.logger, a.Name(), "Invalid token purpose")
|
||||
}
|
||||
|
||||
if t.AccountRef != accountRef {
|
||||
a.logger.Warn("Token account reference does not match request account reference", zap.String("token_account_ref", t.AccountRef.Hex()), zap.String("request_account_ref", accountRef.Hex()))
|
||||
return response.DataConflict(a.logger, a.Name(), "Token does not match account")
|
||||
}
|
||||
|
||||
// Parse new password from request body
|
||||
var req srequest.ResetPassword
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
a.logger.Warn("Failed to decode reset password request", zap.Error(err))
|
||||
return response.BadPayload(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
// Validate new password
|
||||
if err := a.accService.ValidatePassword(req.Password, nil); err != nil {
|
||||
a.logger.Debug("Password validation failed", zap.Error(err), mzap.StorableRef(&user))
|
||||
return sresponse.BadRPassword(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
// Execute password reset in transaction to ensure atomicity
|
||||
if _, err := a.tf.CreateTransaction().Execute(ctx, func(ctx context.Context) (any, error) {
|
||||
return a.resetPasswordTransactionBody(ctx, &user, req.Password)
|
||||
}); err != nil {
|
||||
a.logger.Warn("Failed to execute password reset transaction", zap.Error(err), mzap.StorableRef(&user))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
a.logger.Info("Password reset successful", mzap.StorableRef(&user))
|
||||
return response.Success(a.logger)
|
||||
}
|
||||
|
||||
// resetPasswordTransactionBody contains the transaction logic for password reset
|
||||
func (a *AccountAPI) resetPasswordTransactionBody(ctx context.Context, user *model.Account, newPassword string) (any, error) {
|
||||
// Update user with new password and clear reset token
|
||||
user.Password = newPassword
|
||||
|
||||
// Hash the new password
|
||||
if err := user.HashPassword(); err != nil {
|
||||
a.logger.Warn("Failed to hash new password", zap.Error(err), mzap.StorableRef(user))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Save the updated user
|
||||
if err := a.db.Update(ctx, user); err != nil {
|
||||
a.logger.Warn("Failed to save user with new password", zap.Error(err), mzap.StorableRef(user))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Revoke all refresh tokens for this user (force re-login)
|
||||
if err := a.rtdb.RevokeAll(ctx, user.ID, ""); err != nil {
|
||||
a.logger.Warn("Failed to revoke refresh tokens after password reset", zap.Error(err), mzap.StorableRef(user))
|
||||
// Don't fail the transaction if token revocation fails, but log it
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
278
api/edge/bff/internal/server/accountapiimp/password_test.go
Normal file
278
api/edge/bff/internal/server/accountapiimp/password_test.go
Normal file
@@ -0,0 +1,278 @@
|
||||
package accountapiimp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// TestPasswordResetFlowLogic tests the logical flow without database dependencies
|
||||
func TestPasswordResetFlowLogic(t *testing.T) {
|
||||
t.Run("CompleteFlow", func(t *testing.T) {
|
||||
// Step 1: User requests password reset
|
||||
userEmail := "test@example.com"
|
||||
assert.NotEmpty(t, userEmail, "Email should not be empty")
|
||||
assert.Contains(t, userEmail, "@", "Email should contain @ symbol")
|
||||
|
||||
// Step 2: System generates reset token
|
||||
originalToken := ""
|
||||
resetToken := "generated-reset-token-123"
|
||||
assert.NotEmpty(t, resetToken, "Reset token should be generated")
|
||||
assert.NotEqual(t, originalToken, resetToken, "Reset token should be different from empty")
|
||||
|
||||
// Step 3: User clicks reset link with token
|
||||
userID := bson.NewObjectID()
|
||||
assert.NotEqual(t, bson.NilObjectID, userID, "User ID should be valid")
|
||||
|
||||
// Step 4: System validates token and updates password
|
||||
storedToken := resetToken
|
||||
providedToken := resetToken
|
||||
tokenValid := storedToken == providedToken
|
||||
assert.True(t, tokenValid, "Token should be valid")
|
||||
|
||||
// Step 5: Password gets updated and token cleared
|
||||
oldPassword := "old-password"
|
||||
newPassword := "new-password-123!"
|
||||
clearedToken := ""
|
||||
|
||||
assert.NotEqual(t, oldPassword, newPassword, "Password should be changed")
|
||||
assert.Empty(t, clearedToken, "Token should be cleared after use")
|
||||
assert.NotEqual(t, storedToken, clearedToken, "Token should be different after clearing")
|
||||
})
|
||||
|
||||
t.Run("TokenSecurity", func(t *testing.T) {
|
||||
// Test that tokens are single-use
|
||||
originalToken := "valid-token-123"
|
||||
usedToken := "" // After use, token should be cleared
|
||||
|
||||
assert.NotEmpty(t, originalToken, "Original token should exist")
|
||||
assert.Empty(t, usedToken, "Used token should be cleared")
|
||||
assert.NotEqual(t, originalToken, usedToken, "Token should be cleared after use")
|
||||
|
||||
// Test that different tokens are not equal
|
||||
token1 := "token-123"
|
||||
token2 := "token-456"
|
||||
assert.NotEqual(t, token1, token2, "Different tokens should not be equal")
|
||||
})
|
||||
}
|
||||
|
||||
// TestPasswordValidationLogic tests password complexity requirements
|
||||
func TestPasswordValidationLogic(t *testing.T) {
|
||||
t.Run("ValidPasswords", func(t *testing.T) {
|
||||
validPasswords := []string{
|
||||
"Password123!",
|
||||
"MySecurePass1@",
|
||||
"ComplexP@ssw0rd",
|
||||
}
|
||||
|
||||
for _, password := range validPasswords {
|
||||
t.Run(password, func(t *testing.T) {
|
||||
// Test minimum length
|
||||
assert.True(t, len(password) >= 8, "Password should be at least 8 characters")
|
||||
|
||||
// Test for at least one digit
|
||||
hasDigit := false
|
||||
for _, char := range password {
|
||||
if char >= '0' && char <= '9' {
|
||||
hasDigit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, hasDigit, "Password should contain at least one digit")
|
||||
|
||||
// Test for at least one uppercase letter
|
||||
hasUpper := false
|
||||
for _, char := range password {
|
||||
if char >= 'A' && char <= 'Z' {
|
||||
hasUpper = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, hasUpper, "Password should contain at least one uppercase letter")
|
||||
|
||||
// Test for at least one lowercase letter
|
||||
hasLower := false
|
||||
for _, char := range password {
|
||||
if char >= 'a' && char <= 'z' {
|
||||
hasLower = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, hasLower, "Password should contain at least one lowercase letter")
|
||||
|
||||
// Test for at least one special character
|
||||
hasSpecial := false
|
||||
specialChars := "!@#$%^&*()_+-=[]{}|;:,.<>?"
|
||||
for _, char := range password {
|
||||
for _, special := range specialChars {
|
||||
if char == special {
|
||||
hasSpecial = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasSpecial {
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, hasSpecial, "Password should contain at least one special character")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("InvalidPasswords", func(t *testing.T) {
|
||||
invalidPasswords := []string{
|
||||
"", // Empty
|
||||
"short", // Too short
|
||||
"nouppercase1!", // No uppercase
|
||||
"NOLOWERCASE1!", // No lowercase
|
||||
"NoNumbers!", // No numbers
|
||||
"NoSpecial1", // No special characters
|
||||
}
|
||||
|
||||
for _, password := range invalidPasswords {
|
||||
t.Run(password, func(t *testing.T) {
|
||||
// Test that invalid passwords fail at least one requirement
|
||||
isValid := true
|
||||
|
||||
// Check length
|
||||
if len(password) < 8 {
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Check for digit
|
||||
hasDigit := false
|
||||
for _, char := range password {
|
||||
if char >= '0' && char <= '9' {
|
||||
hasDigit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasDigit {
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Check for uppercase
|
||||
hasUpper := false
|
||||
for _, char := range password {
|
||||
if char >= 'A' && char <= 'Z' {
|
||||
hasUpper = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasUpper {
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Check for lowercase
|
||||
hasLower := false
|
||||
for _, char := range password {
|
||||
if char >= 'a' && char <= 'z' {
|
||||
hasLower = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasLower {
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Check for special character
|
||||
hasSpecial := false
|
||||
specialChars := "!@#$%^&*()_+-=[]{}|;:,.<>?"
|
||||
for _, char := range password {
|
||||
for _, special := range specialChars {
|
||||
if char == special {
|
||||
hasSpecial = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasSpecial {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasSpecial {
|
||||
isValid = false
|
||||
}
|
||||
|
||||
assert.False(t, isValid, "Invalid password should fail validation")
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestEmailValidationLogic tests email format validation
|
||||
func TestEmailValidationLogic(t *testing.T) {
|
||||
t.Run("ValidEmails", func(t *testing.T) {
|
||||
validEmails := []string{
|
||||
"test@example.com",
|
||||
"user.name@domain.org",
|
||||
"user+tag@example.co.uk",
|
||||
"test123@domain.com",
|
||||
}
|
||||
|
||||
for _, email := range validEmails {
|
||||
t.Run(email, func(t *testing.T) {
|
||||
// Basic email validation logic
|
||||
hasAt := false
|
||||
hasDot := false
|
||||
atIndex := -1
|
||||
dotIndex := -1
|
||||
|
||||
for i, char := range email {
|
||||
if char == '@' {
|
||||
hasAt = true
|
||||
atIndex = i
|
||||
}
|
||||
if char == '.' {
|
||||
hasDot = true
|
||||
dotIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, hasAt, "Valid email should contain @")
|
||||
assert.True(t, hasDot, "Valid email should contain .")
|
||||
assert.True(t, atIndex > 0, "Valid email should have @ not at start")
|
||||
assert.True(t, dotIndex > atIndex, "Valid email should have . after @")
|
||||
assert.True(t, len(email) > atIndex+1, "Valid email should have domain after @")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("InvalidEmails", func(t *testing.T) {
|
||||
invalidEmails := []string{
|
||||
"", // Empty
|
||||
"noat.com", // No @
|
||||
"test@nodot", // No .
|
||||
"@nodomain.com", // No local part
|
||||
"test@.com", // No domain
|
||||
"test.com@", // No domain after @
|
||||
}
|
||||
|
||||
for _, email := range invalidEmails {
|
||||
t.Run(email, func(t *testing.T) {
|
||||
// Basic email validation logic
|
||||
hasAt := false
|
||||
hasDot := false
|
||||
atIndex := -1
|
||||
dotIndex := -1
|
||||
|
||||
for i, char := range email {
|
||||
if char == '@' {
|
||||
hasAt = true
|
||||
atIndex = i
|
||||
}
|
||||
if char == '.' {
|
||||
hasDot = true
|
||||
dotIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
// Invalid emails should fail at least one requirement
|
||||
domainAfterDot := len(email) > dotIndex+1
|
||||
domainAfterAt := len(email) > atIndex+1
|
||||
isValid := hasAt && hasDot && atIndex > 0 && dotIndex > atIndex && domainAfterAt && domainAfterDot && (dotIndex-atIndex) > 1
|
||||
assert.False(t, isValid, "Invalid email should fail validation")
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
278
api/edge/bff/internal/server/accountapiimp/service.go
Normal file
278
api/edge/bff/internal/server/accountapiimp/service.go
Normal file
@@ -0,0 +1,278 @@
|
||||
package accountapiimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
trongatewayclient "github.com/tech/sendico/gateway/tron/client"
|
||||
ledgerclient "github.com/tech/sendico/ledger/client"
|
||||
api "github.com/tech/sendico/pkg/api/http"
|
||||
"github.com/tech/sendico/pkg/auth"
|
||||
"github.com/tech/sendico/pkg/db/account"
|
||||
"github.com/tech/sendico/pkg/db/organization"
|
||||
"github.com/tech/sendico/pkg/db/policy"
|
||||
"github.com/tech/sendico/pkg/db/refreshtokens"
|
||||
"github.com/tech/sendico/pkg/db/transaction"
|
||||
"github.com/tech/sendico/pkg/db/verification"
|
||||
"github.com/tech/sendico/pkg/domainprovider"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/messaging"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
"github.com/tech/sendico/server/interface/accountservice"
|
||||
eapi "github.com/tech/sendico/server/interface/api"
|
||||
"github.com/tech/sendico/server/interface/services/fileservice"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type AccountAPI struct {
|
||||
logger mlogger.Logger
|
||||
db account.DB
|
||||
odb organization.DB
|
||||
tf transaction.Factory
|
||||
rtdb refreshtokens.DB
|
||||
plcdb policy.DB
|
||||
vdb verification.DB
|
||||
domain domainprovider.DomainProvider
|
||||
avatars mservice.MicroService
|
||||
producer messaging.Producer
|
||||
pmanager auth.Manager
|
||||
enf auth.Enforcer
|
||||
oph mutil.ParamHelper
|
||||
aph mutil.ParamHelper
|
||||
tph mutil.ParamHelper
|
||||
accountsPermissionRef bson.ObjectID
|
||||
accService accountservice.AccountService
|
||||
chainGateway chainWalletClient
|
||||
ledgerClient ledgerAccountClient
|
||||
chainAsset *chainv1.Asset
|
||||
}
|
||||
|
||||
type chainWalletClient interface {
|
||||
CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type ledgerAccountClient interface {
|
||||
CreateAccount(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
func (a *AccountAPI) Name() mservice.Type {
|
||||
return mservice.Accounts
|
||||
}
|
||||
|
||||
func (a *AccountAPI) Finish(ctx context.Context) error {
|
||||
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))
|
||||
}
|
||||
}
|
||||
if a.ledgerClient != nil {
|
||||
if err := a.ledgerClient.Close(); err != nil {
|
||||
a.logger.Warn("Failed to close ledger client", zap.Error(err))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateAPI(a eapi.API) (*AccountAPI, error) {
|
||||
p := new(AccountAPI)
|
||||
p.logger = a.Logger().Named(p.Name())
|
||||
var err error
|
||||
if p.db, err = a.DBFactory().NewAccountDB(); err != nil {
|
||||
p.logger.Error("Failed to create accounts database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if p.rtdb, err = a.DBFactory().NewRefreshTokensDB(); err != nil {
|
||||
p.logger.Error("Failed to create refresh tokens database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if p.odb, err = a.DBFactory().NewOrganizationDB(); err != nil {
|
||||
p.logger.Error("Failed to create organizations database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if p.plcdb, err = a.DBFactory().NewPoliciesDB(); err != nil {
|
||||
p.logger.Error("Failed to create policies database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if p.vdb, err = a.DBFactory().NewVerificationsDB(); err != nil {
|
||||
p.logger.Error("Failed to create verification database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.domain = a.DomainProvider()
|
||||
p.producer = a.Register().Messaging().Producer()
|
||||
p.tf = a.DBFactory().TransactionFactory()
|
||||
p.pmanager = a.Permissions().Manager()
|
||||
p.enf = a.Permissions().Enforcer()
|
||||
p.oph = mutil.CreatePH(mservice.Organizations)
|
||||
p.aph = mutil.CreatePH(mservice.Accounts)
|
||||
p.tph = mutil.CreatePH("token")
|
||||
|
||||
if p.accService, err = accountservice.NewAccountService(p.logger, a.DBFactory(), p.enf, p.pmanager.Role(), &a.Config().Mw.Password); err != nil {
|
||||
p.logger.Error("Failed to create account manager", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
a.Register().AccountHandler(mservice.Accounts, p.oph.AddRef("/employee"), api.Put, p.updateEmployee)
|
||||
|
||||
a.Register().AccountHandler(mservice.Accounts, "/dzone", api.Get, p.dzone)
|
||||
a.Register().AccountHandler(mservice.Accounts, p.oph.AddRef("/profile"), api.Delete, p.deleteProfile)
|
||||
a.Register().AccountHandler(mservice.Accounts, p.oph.AddRef("/organization"), api.Delete, p.deleteOrganization)
|
||||
a.Register().AccountHandler(mservice.Accounts, p.oph.AddRef("/all"), api.Delete, p.deleteAll)
|
||||
|
||||
a.Register().AccountHandler(mservice.Accounts, p.oph.AddRef("/list"), api.Get, p.getEmployees)
|
||||
|
||||
a.Register().AccountHandler(mservice.Accounts, "/password", api.Post, p.checkPassword)
|
||||
a.Register().AccountHandler(mservice.Accounts, "/password", api.Patch, p.changePassword)
|
||||
a.Register().Handler(mservice.Accounts, "/password", api.Put, p.forgotPassword)
|
||||
a.Register().Handler(mservice.Accounts, p.tph.AddRef(p.aph.AddRef("/password/reset")), api.Post, p.resetPassword)
|
||||
|
||||
a.Register().Handler(mservice.Accounts, mutil.AddToken("/verify"), api.Get, p.verify)
|
||||
a.Register().Handler(mservice.Accounts, "/email", api.Post, p.resendVerificationMail)
|
||||
a.Register().Handler(mservice.Accounts, "/email", api.Put, p.resendVerification)
|
||||
|
||||
if p.avatars, err = fileservice.CreateAPI(a, p.Name()); err != nil {
|
||||
p.logger.Error("Failed to create image server", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accountsPolicy, err := a.Permissions().GetPolicyDescription(context.Background(), mservice.Accounts)
|
||||
if err != nil {
|
||||
p.logger.Warn("Failed to fetch account permission policy description", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
p.accountsPermissionRef = accountsPolicy.ID
|
||||
|
||||
cfg := a.Config()
|
||||
if cfg == nil {
|
||||
p.logger.Error("Failed to fetch service configuration")
|
||||
return nil, merrors.InvalidArgument("No configuration provided")
|
||||
}
|
||||
if err := p.initChainGateway(cfg.ChainGateway); err != nil {
|
||||
p.logger.Error("Failed to initialize chain gateway client", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if err := p.initLedgerClient(cfg.Ledger); err != nil {
|
||||
p.logger.Error("Failed to initialize ledger client", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (a *AccountAPI) initChainGateway(cfg *eapi.ChainGatewayConfig) error {
|
||||
if cfg == nil {
|
||||
return merrors.InvalidArgument("chain gateway configuration is not provided")
|
||||
}
|
||||
|
||||
cfg.Address = strings.TrimSpace(cfg.Address)
|
||||
if cfg.Address == "" {
|
||||
cfg.Address = strings.TrimSpace(os.Getenv(cfg.AddressEnv))
|
||||
}
|
||||
if cfg.Address == "" {
|
||||
return merrors.InvalidArgument(fmt.Sprintf("chain gateway address is not specified and address env %s is empty", cfg.AddressEnv))
|
||||
}
|
||||
|
||||
clientCfg := trongatewayclient.Config{
|
||||
Address: cfg.Address,
|
||||
DialTimeout: time.Duration(cfg.DialTimeoutSeconds) * time.Second,
|
||||
CallTimeout: time.Duration(cfg.CallTimeoutSeconds) * time.Second,
|
||||
Insecure: cfg.Insecure,
|
||||
}
|
||||
|
||||
client, err := trongatewayclient.New(context.Background(), clientCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
asset, err := buildGatewayAsset(cfg.DefaultAsset)
|
||||
if err != nil {
|
||||
_ = client.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
a.chainGateway = client
|
||||
a.chainAsset = asset
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AccountAPI) initLedgerClient(cfg *eapi.LedgerConfig) error {
|
||||
if cfg == nil {
|
||||
return merrors.InvalidArgument("ledger configuration is not provided")
|
||||
}
|
||||
|
||||
address := strings.TrimSpace(cfg.Address)
|
||||
if address == "" {
|
||||
address = strings.TrimSpace(os.Getenv(cfg.AddressEnv))
|
||||
}
|
||||
if address == "" {
|
||||
return merrors.InvalidArgument(fmt.Sprintf("ledger address is not specified and address env %s is empty", cfg.AddressEnv))
|
||||
}
|
||||
|
||||
clientCfg := ledgerclient.Config{
|
||||
Address: address,
|
||||
DialTimeout: time.Duration(cfg.DialTimeoutSeconds) * time.Second,
|
||||
CallTimeout: time.Duration(cfg.CallTimeoutSeconds) * time.Second,
|
||||
Insecure: cfg.Insecure,
|
||||
}
|
||||
|
||||
client, err := ledgerclient.New(context.Background(), clientCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
a.ledgerClient = client
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildGatewayAsset(cfg eapi.ChainGatewayAssetConfig) (*chainv1.Asset, error) {
|
||||
chain, err := parseChainNetwork(cfg.Chain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokenSymbol := strings.TrimSpace(cfg.TokenSymbol)
|
||||
if tokenSymbol == "" {
|
||||
return nil, merrors.InvalidArgument("chain gateway token symbol is required")
|
||||
}
|
||||
return &chainv1.Asset{
|
||||
Chain: chain,
|
||||
TokenSymbol: strings.ToUpper(tokenSymbol),
|
||||
ContractAddress: strings.TrimSpace(cfg.ContractAddress),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseChainNetwork(value string) (chainv1.ChainNetwork, error) {
|
||||
switch strings.ToUpper(strings.TrimSpace(value)) {
|
||||
case "ETHEREUM_MAINNET", "CHAIN_NETWORK_ETHEREUM_MAINNET":
|
||||
return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, nil
|
||||
case "ARBITRUM_ONE", "CHAIN_NETWORK_ARBITRUM_ONE":
|
||||
return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, nil
|
||||
case "TRON_MAINNET", "CHAIN_NETWORK_TRON_MAINNET":
|
||||
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, nil
|
||||
case "TRON_NILE", "CHAIN_NETWORK_TRON_NILE":
|
||||
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE, nil
|
||||
case "", "CHAIN_NETWORK_UNSPECIFIED":
|
||||
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, merrors.InvalidArgument("chain network must be specified")
|
||||
default:
|
||||
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, merrors.InvalidArgument(fmt.Sprintf("unsupported chain network %s", value))
|
||||
}
|
||||
}
|
||||
19
api/edge/bff/internal/server/accountapiimp/service_test.go
Normal file
19
api/edge/bff/internal/server/accountapiimp/service_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package accountapiimp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
eapi "github.com/tech/sendico/server/interface/api"
|
||||
)
|
||||
|
||||
func TestBuildGatewayAsset_PreservesContractAddressCase(t *testing.T) {
|
||||
asset, err := buildGatewayAsset(eapi.ChainGatewayAssetConfig{
|
||||
Chain: "TRON_MAINNET",
|
||||
TokenSymbol: "usdt",
|
||||
ContractAddress: "TR7NhQjeKQxGTCi8q8ZY4pL8otSzgjLj6T",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "USDT", asset.GetTokenSymbol())
|
||||
require.Equal(t, "TR7NhQjeKQxGTCi8q8ZY4pL8otSzgjLj6T", asset.GetContractAddress())
|
||||
}
|
||||
382
api/edge/bff/internal/server/accountapiimp/signup.go
Normal file
382
api/edge/bff/internal/server/accountapiimp/signup.go
Normal file
@@ -0,0 +1,382 @@
|
||||
package accountapiimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
snotifications "github.com/tech/sendico/pkg/messaging/notifications/site"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
"github.com/tech/sendico/server/interface/api/srequest"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *AccountAPI) createOrg(ctx context.Context, sr *srequest.Signup, permissionRef bson.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()))
|
||||
}
|
||||
|
||||
// explicitly set org ref for permission related checks as unprotected template implementation
|
||||
// is not aware of permisssions and won't set org
|
||||
orgRef := bson.NewObjectID()
|
||||
org := &model.Organization{
|
||||
OrganizationBase: model.OrganizationBase{
|
||||
PermissionBound: model.PermissionBound{
|
||||
Base: storable.Base{
|
||||
ID: orgRef,
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
PermissionRef: permissionRef,
|
||||
OrganizationBoundBase: model.OrganizationBoundBase{
|
||||
OrganizationRef: orgRef,
|
||||
},
|
||||
},
|
||||
Describable: model.Describable{
|
||||
Name: name,
|
||||
Description: sr.Organization.Description,
|
||||
},
|
||||
TimeZone: sr.OrganizationTimeZone,
|
||||
},
|
||||
Members: []bson.ObjectID{},
|
||||
}
|
||||
if err := a.odb.Unprotected().Create(ctx, org); err != nil {
|
||||
a.logger.Warn("Failed to create organization", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return org, nil
|
||||
}
|
||||
|
||||
// signupHandler handles user sign up
|
||||
func (a *AccountAPI) signup(r *http.Request) http.HandlerFunc {
|
||||
// Validate user input
|
||||
var sr srequest.Signup
|
||||
if err := json.NewDecoder(r.Body).Decode(&sr); err != nil {
|
||||
a.logger.Warn("Failed to decode signup request", zap.Error(err))
|
||||
return response.BadPayload(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
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))
|
||||
return response.BadPayload(a.logger, a.Name(), res)
|
||||
}
|
||||
|
||||
verificationToken, err := a.executeSignupTransaction(r.Context(), &sr, newAccount)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
a.logger.Warn("Failed to register account", zap.Error(err), zap.String("login", newAccount.Login))
|
||||
return response.DataConflict(a.logger, "user_already_registered", "User has already been registered")
|
||||
}
|
||||
a.logger.Warn("Failed to create new user", zap.Error(err), zap.String("login", newAccount.Login))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
if err := a.sendWelcomeEmail(newAccount, verificationToken); err != nil {
|
||||
a.logger.Warn("Failed to send welcome email", zap.Error(err), mzap.StorableRef(newAccount))
|
||||
}
|
||||
if err := a.sendSignupNotification(newAccount, &sr); err != nil {
|
||||
a.logger.Warn("Failed to enqueue signup notification", zap.Error(err), zap.String("login", newAccount.Login))
|
||||
}
|
||||
|
||||
return sresponse.SignUp(a.logger, newAccount)
|
||||
}
|
||||
|
||||
func (a *AccountAPI) sendSignupNotification(account *model.Account, request *srequest.Signup) error {
|
||||
if account == nil || request == nil {
|
||||
return merrors.InvalidArgument("signup notification payload is empty")
|
||||
}
|
||||
|
||||
signupNotification := &model.ContactRequest{
|
||||
Name: accountNotificationName(account),
|
||||
Email: strings.TrimSpace(account.Login),
|
||||
Company: strings.TrimSpace(request.Organization.Name),
|
||||
Topic: model.ContactRequestTopicSignupCompleted,
|
||||
}
|
||||
|
||||
return a.producer.SendMessage(snotifications.ContactRequest(a.Name(), signupNotification))
|
||||
}
|
||||
|
||||
func (a *AccountAPI) sendAccountVerificationCompletedNotification(account *model.Account) error {
|
||||
if account == nil {
|
||||
return merrors.InvalidArgument("account verification notification payload is empty", "account")
|
||||
}
|
||||
|
||||
notification := &model.ContactRequest{
|
||||
Name: accountNotificationName(account),
|
||||
Email: strings.TrimSpace(account.Login),
|
||||
Topic: model.ContactRequestTopicAccountVerificationCompleted,
|
||||
}
|
||||
|
||||
return a.producer.SendMessage(snotifications.ContactRequest(a.Name(), notification))
|
||||
}
|
||||
|
||||
func accountNotificationName(account *model.Account) string {
|
||||
if account == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(strings.Join([]string{
|
||||
strings.TrimSpace(account.Name),
|
||||
strings.TrimSpace(account.LastName),
|
||||
}, " "))
|
||||
}
|
||||
|
||||
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) (string, error) {
|
||||
res, err := a.tf.CreateTransaction().Execute(ctxt, func(ctx context.Context) (any, error) {
|
||||
return a.signupTransactionBody(ctx, sr, newAccount)
|
||||
})
|
||||
if token, ok := res.(string); token != "" || ok {
|
||||
return token, err
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
func (a *AccountAPI) signupTransactionBody(ctx context.Context, sr *srequest.Signup, newAccount *model.Account) (any, error) {
|
||||
var orgPolicy model.PolicyDescription
|
||||
if err := a.plcdb.GetBuiltInPolicy(ctx, mservice.Organizations, &orgPolicy); err != nil {
|
||||
a.logger.Warn("Failed to fetch built-in organization policy", zap.Error(err), zap.String("login", newAccount.Login))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
org, err := a.createOrg(ctx, sr, orgPolicy.ID)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to create organization", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
a.logger.Info("Organization created", mzap.StorableRef(org), zap.String("account", sr.Account.Login))
|
||||
|
||||
if err := a.openOrgWallet(ctx, org, sr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.logger.Info("Organization wallet created", mzap.StorableRef(org), zap.String("account", sr.Account.Login))
|
||||
if err := a.openOrgLedgerAccount(ctx, org, sr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.logger.Info("Organization ledger account created", mzap.StorableRef(org), zap.String("account", sr.Account.Login))
|
||||
|
||||
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))
|
||||
return nil, err
|
||||
}
|
||||
a.logger.Info("Organization owner role created", mzap.StorableRef(org), zap.String("account", sr.Account.Login))
|
||||
|
||||
if err := a.grantAllPermissions(ctx, org.ID, roleDescription.ID, newAccount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.logger.Info("Organization owner role permissions granted", mzap.StorableRef(org), zap.String("account", sr.Account.Login))
|
||||
|
||||
token, err := a.accService.CreateAccount(ctx, org, newAccount, roleDescription.ID)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to create account", zap.Error(err), zap.String("login", newAccount.Login))
|
||||
return nil, err
|
||||
}
|
||||
a.logger.Info("Organization owner account registered", mzap.StorableRef(org), zap.String("account", sr.Account.Login))
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (a *AccountAPI) grantAllPermissions(ctx context.Context, organizationRef bson.ObjectID, roleID bson.ObjectID, newAccount *model.Account) error {
|
||||
om := a.pmanager.Permission()
|
||||
policies, err := a.plcdb.All(ctx, organizationRef)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to fetch permissions", zap.Error(err), mzap.StorableRef(newAccount))
|
||||
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))
|
||||
policy := model.RolePolicy{
|
||||
Policy: model.Policy{
|
||||
OrganizationRef: organizationRef,
|
||||
DescriptionRef: policy.ID,
|
||||
ObjectRef: nil, // all objects are affected
|
||||
Effect: model.ActionEffect{Action: action, Effect: model.EffectAllow},
|
||||
},
|
||||
RoleDescriptionRef: roleID,
|
||||
}
|
||||
if err := om.GrantToRole(ctx, &policy); err != nil {
|
||||
a.logger.Warn("Failed to grant permission", zap.Error(err), mzap.StorableRef(newAccount))
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := om.Save(); err != nil {
|
||||
a.logger.Warn("Failed to save permissions", zap.Error(err), mzap.StorableRef(newAccount))
|
||||
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")
|
||||
}
|
||||
req := &chainv1.CreateManagedWalletRequest{
|
||||
IdempotencyKey: uuid.NewString(),
|
||||
OrganizationRef: org.ID.Hex(),
|
||||
Describable: &describablev1.Describable{
|
||||
Name: sr.CryptoWallet.Name,
|
||||
Description: sr.CryptoWallet.Description,
|
||||
},
|
||||
Asset: a.chainAsset,
|
||||
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
|
||||
}
|
||||
|
||||
func (a *AccountAPI) openOrgLedgerAccount(ctx context.Context, org *model.Organization, sr *srequest.Signup) error {
|
||||
if a.ledgerClient == nil {
|
||||
a.logger.Warn("Ledger client not configured, skipping ledger account creation", mzap.StorableRef(org))
|
||||
return merrors.Internal("ledger client is not configured")
|
||||
}
|
||||
if a.chainAsset == nil {
|
||||
return merrors.Internal("chain gateway default asset is not configured")
|
||||
}
|
||||
|
||||
// TODO: remove hardcode
|
||||
currency := "RUB"
|
||||
|
||||
var describable *describablev1.Describable
|
||||
name := strings.TrimSpace(sr.LedgerWallet.Name)
|
||||
var description *string
|
||||
if sr.LedgerWallet.Description != nil {
|
||||
trimmed := strings.TrimSpace(*sr.LedgerWallet.Description)
|
||||
if trimmed != "" {
|
||||
description = &trimmed
|
||||
}
|
||||
}
|
||||
if name != "" || description != nil {
|
||||
describable = &describablev1.Describable{
|
||||
Name: name,
|
||||
Description: description,
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := a.ledgerClient.CreateAccount(ctx, &ledgerv1.CreateAccountRequest{
|
||||
OrganizationRef: org.ID.Hex(),
|
||||
AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_ASSET,
|
||||
Currency: currency,
|
||||
Status: ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE,
|
||||
Role: ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING,
|
||||
Metadata: map[string]string{
|
||||
"source": "signup",
|
||||
"login": sr.Account.Login,
|
||||
},
|
||||
Describable: describable,
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to create ledger account for organization", zap.Error(err), mzap.StorableRef(org))
|
||||
return err
|
||||
}
|
||||
if resp == nil || resp.GetAccount() == nil || strings.TrimSpace(resp.GetAccount().GetLedgerAccountRef()) == "" {
|
||||
return merrors.Internal("ledger returned empty account reference")
|
||||
}
|
||||
|
||||
a.logger.Info("Ledger account created for organization", mzap.StorableRef(org), zap.String("ledger_account_ref", resp.GetAccount().GetLedgerAccountRef()))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package accountapiimp_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/server/interface/api/srequest"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// TestSignupRequestSerialization tests JSON marshaling/unmarshaling with real MongoDB
|
||||
func TestSignupRequestSerialization(t *testing.T) {
|
||||
if os.Getenv("RUN_DOCKER_TESTS") == "" {
|
||||
t.Skip("skipping: docker-dependent integration test (set RUN_DOCKER_TESTS=1 to enable)")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
mongoContainer, err := mongodb.Run(ctx,
|
||||
"mongo:latest",
|
||||
mongodb.WithUsername("root"),
|
||||
mongodb.WithPassword("password"),
|
||||
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
|
||||
)
|
||||
require.NoError(t, err, "failed to start MongoDB container")
|
||||
defer func() {
|
||||
err := mongoContainer.Terminate(ctx)
|
||||
require.NoError(t, err, "failed to terminate MongoDB container")
|
||||
}()
|
||||
|
||||
mongoURI, err := mongoContainer.ConnectionString(ctx)
|
||||
require.NoError(t, err, "failed to get MongoDB connection string")
|
||||
|
||||
clientOptions := options.Client().ApplyURI(mongoURI)
|
||||
client, err := mongo.Connect(clientOptions)
|
||||
require.NoError(t, err, "failed to connect to MongoDB")
|
||||
defer func() {
|
||||
err := client.Disconnect(ctx)
|
||||
require.NoError(t, err, "failed to disconnect from MongoDB")
|
||||
}()
|
||||
|
||||
db := client.Database("test_signup")
|
||||
collection := db.Collection("signup_requests")
|
||||
|
||||
t.Run("StoreAndRetrieveSignupRequest", func(t *testing.T) {
|
||||
signupRequest := srequest.Signup{
|
||||
Account: model.AccountData{
|
||||
LoginData: model.LoginData{
|
||||
UserDataBase: model.UserDataBase{
|
||||
Login: "test@example.com",
|
||||
},
|
||||
Password: "TestPassword123!",
|
||||
},
|
||||
Describable: model.Describable{
|
||||
Name: "Test User",
|
||||
},
|
||||
},
|
||||
Organization: model.Describable{
|
||||
Name: "Test Organization",
|
||||
},
|
||||
OrganizationTimeZone: "UTC",
|
||||
OwnerRole: model.Describable{
|
||||
Name: "Owner",
|
||||
},
|
||||
}
|
||||
|
||||
// Store in MongoDB
|
||||
result, err := collection.InsertOne(ctx, signupRequest)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result.InsertedID)
|
||||
|
||||
// Retrieve from MongoDB
|
||||
var retrieved srequest.Signup
|
||||
err = collection.FindOne(ctx, map[string]interface{}{"_id": result.InsertedID}).Decode(&retrieved)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 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.Organization.Name, retrieved.Organization.Name)
|
||||
assert.Equal(t, signupRequest.OrganizationTimeZone, retrieved.OrganizationTimeZone)
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
// TestSignupHTTPSerialization tests HTTP request/response serialization
|
||||
func TestSignupHTTPSerialization(t *testing.T) {
|
||||
signupRequest := srequest.Signup{
|
||||
Account: model.AccountData{
|
||||
LoginData: model.LoginData{
|
||||
UserDataBase: model.UserDataBase{
|
||||
Login: "test@example.com",
|
||||
},
|
||||
Password: "TestPassword123!",
|
||||
},
|
||||
Describable: model.Describable{
|
||||
Name: "Test User",
|
||||
},
|
||||
},
|
||||
Organization: model.Describable{
|
||||
Name: "Test Organization",
|
||||
},
|
||||
OrganizationTimeZone: "UTC",
|
||||
OwnerRole: model.Describable{
|
||||
Name: "Owner",
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("ValidJSONRequest", func(t *testing.T) {
|
||||
// Serialize to JSON
|
||||
reqBody, err := json.Marshal(signupRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create HTTP request
|
||||
req := httptest.NewRequest(http.MethodPost, "/signup", bytes.NewBuffer(reqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Parse the request body
|
||||
var parsedRequest srequest.Signup
|
||||
err = json.NewDecoder(req.Body).Decode(&parsedRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify parsing
|
||||
assert.Equal(t, signupRequest.Account.Login, parsedRequest.Account.Login)
|
||||
assert.Equal(t, signupRequest.Account.Name, parsedRequest.Account.Name)
|
||||
assert.Equal(t, signupRequest.Organization.Name, parsedRequest.Organization.Name)
|
||||
})
|
||||
|
||||
t.Run("UnicodeCharacters", func(t *testing.T) {
|
||||
unicodeRequest := signupRequest
|
||||
unicodeRequest.Account.Name = "Test 用户 Üser"
|
||||
unicodeRequest.Organization.Name = "测试 Organization"
|
||||
|
||||
// Serialize to JSON
|
||||
reqBody, err := json.Marshal(unicodeRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Parse back
|
||||
var parsedRequest srequest.Signup
|
||||
err = json.Unmarshal(reqBody, &parsedRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify unicode characters are preserved
|
||||
assert.Equal(t, "Test 用户 Üser", parsedRequest.Account.Name)
|
||||
assert.Equal(t, "测试 Organization", parsedRequest.Organization.Name)
|
||||
})
|
||||
|
||||
t.Run("InvalidJSONRequest", func(t *testing.T) {
|
||||
invalidJSON := `{"account": {"login": "test@example.com", "password": "invalid json structure`
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/signup", bytes.NewBufferString(invalidJSON))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
var parsedRequest srequest.Signup
|
||||
err := json.NewDecoder(req.Body).Decode(&parsedRequest)
|
||||
assert.Error(t, err, "Should fail to parse invalid JSON")
|
||||
})
|
||||
}
|
||||
|
||||
// TestAccountDataConversion tests conversion between request and model types
|
||||
func TestAccountDataConversion(t *testing.T) {
|
||||
accountData := model.AccountData{
|
||||
LoginData: model.LoginData{
|
||||
UserDataBase: model.UserDataBase{
|
||||
Login: "test@example.com",
|
||||
},
|
||||
Password: "TestPassword123!",
|
||||
},
|
||||
Describable: model.Describable{
|
||||
Name: "Test User",
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("ToAccount", func(t *testing.T) {
|
||||
account := accountData.ToAccount()
|
||||
|
||||
assert.Equal(t, accountData.Login, account.Login)
|
||||
assert.Equal(t, accountData.Password, account.Password)
|
||||
assert.Equal(t, accountData.Name, account.Name)
|
||||
|
||||
// Verify the account has proper structure
|
||||
assert.NotNil(t, account)
|
||||
assert.IsType(t, &model.Account{}, account)
|
||||
})
|
||||
|
||||
t.Run("PasswordHandling", func(t *testing.T) {
|
||||
account := accountData.ToAccount()
|
||||
|
||||
// Original password should be preserved before validation
|
||||
assert.Equal(t, "TestPassword123!", account.Password)
|
||||
|
||||
// Verify password is not empty
|
||||
assert.NotEmpty(t, account.Password)
|
||||
})
|
||||
}
|
||||
116
api/edge/bff/internal/server/accountapiimp/signup_ledger_test.go
Normal file
116
api/edge/bff/internal/server/accountapiimp/signup_ledger_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package accountapiimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
"github.com/tech/sendico/server/interface/api/srequest"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type stubLedgerAccountClient struct {
|
||||
createReq *ledgerv1.CreateAccountRequest
|
||||
createResp *ledgerv1.CreateAccountResponse
|
||||
createErr error
|
||||
}
|
||||
|
||||
func (s *stubLedgerAccountClient) CreateAccount(_ context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) {
|
||||
s.createReq = req
|
||||
return s.createResp, s.createErr
|
||||
}
|
||||
|
||||
func (s *stubLedgerAccountClient) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestOpenOrgLedgerAccount(t *testing.T) {
|
||||
t.Run("creates operating ledger account", func(t *testing.T) {
|
||||
desc := " Main org ledger account "
|
||||
sr := &srequest.Signup{
|
||||
Account: model.AccountData{
|
||||
LoginData: model.LoginData{
|
||||
UserDataBase: model.UserDataBase{
|
||||
Login: "owner@example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
LedgerWallet: model.Describable{
|
||||
Name: " Primary Ledger ",
|
||||
Description: &desc,
|
||||
},
|
||||
}
|
||||
|
||||
org := &model.Organization{}
|
||||
org.SetID(bson.NewObjectID())
|
||||
|
||||
ledgerStub := &stubLedgerAccountClient{
|
||||
createResp: &ledgerv1.CreateAccountResponse{
|
||||
Account: &ledgerv1.LedgerAccount{LedgerAccountRef: bson.NewObjectID().Hex()},
|
||||
},
|
||||
}
|
||||
api := &AccountAPI{
|
||||
logger: zap.NewNop(),
|
||||
ledgerClient: ledgerStub,
|
||||
chainAsset: &chainv1.Asset{
|
||||
TokenSymbol: " usdt ",
|
||||
},
|
||||
}
|
||||
|
||||
err := api.openOrgLedgerAccount(context.Background(), org, sr)
|
||||
assert.NoError(t, err)
|
||||
if assert.NotNil(t, ledgerStub.createReq) {
|
||||
assert.Equal(t, org.ID.Hex(), ledgerStub.createReq.GetOrganizationRef())
|
||||
assert.Equal(t, "RUB", ledgerStub.createReq.GetCurrency())
|
||||
assert.Equal(t, ledgerv1.AccountType_ACCOUNT_TYPE_ASSET, ledgerStub.createReq.GetAccountType())
|
||||
assert.Equal(t, ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE, ledgerStub.createReq.GetStatus())
|
||||
assert.Equal(t, ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING, ledgerStub.createReq.GetRole())
|
||||
assert.Equal(t, map[string]string{
|
||||
"source": "signup",
|
||||
"login": "owner@example.com",
|
||||
}, ledgerStub.createReq.GetMetadata())
|
||||
if assert.NotNil(t, ledgerStub.createReq.GetDescribable()) {
|
||||
assert.Equal(t, "Primary Ledger", ledgerStub.createReq.GetDescribable().GetName())
|
||||
if assert.NotNil(t, ledgerStub.createReq.GetDescribable().Description) {
|
||||
assert.Equal(t, "Main org ledger account", ledgerStub.createReq.GetDescribable().GetDescription())
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fails when ledger client is missing", func(t *testing.T) {
|
||||
api := &AccountAPI{
|
||||
logger: zap.NewNop(),
|
||||
chainAsset: &chainv1.Asset{
|
||||
TokenSymbol: "USDT",
|
||||
},
|
||||
}
|
||||
|
||||
err := api.openOrgLedgerAccount(context.Background(), &model.Organization{}, &srequest.Signup{})
|
||||
assert.Error(t, err)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInternal))
|
||||
})
|
||||
|
||||
t.Run("fails when ledger response has empty reference", func(t *testing.T) {
|
||||
ledgerStub := &stubLedgerAccountClient{
|
||||
createResp: &ledgerv1.CreateAccountResponse{},
|
||||
}
|
||||
api := &AccountAPI{
|
||||
logger: zap.NewNop(),
|
||||
ledgerClient: ledgerStub,
|
||||
chainAsset: &chainv1.Asset{
|
||||
TokenSymbol: "USDT",
|
||||
},
|
||||
}
|
||||
|
||||
err := api.openOrgLedgerAccount(context.Background(), &model.Organization{}, &srequest.Signup{})
|
||||
assert.Error(t, err)
|
||||
assert.True(t, errors.Is(err, merrors.ErrInternal))
|
||||
})
|
||||
}
|
||||
355
api/edge/bff/internal/server/accountapiimp/signup_test.go
Normal file
355
api/edge/bff/internal/server/accountapiimp/signup_test.go
Normal file
@@ -0,0 +1,355 @@
|
||||
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/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Helper function to create string pointers
|
||||
func stringPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
// TestTimezoneValidation tests timezone validation logic separately
|
||||
func TestTimezoneValidation(t *testing.T) {
|
||||
t.Run("ValidTimezones", func(t *testing.T) {
|
||||
validTimezones := []string{
|
||||
"UTC",
|
||||
"America/New_York",
|
||||
"Europe/London",
|
||||
"Asia/Tokyo",
|
||||
"Australia/Sydney",
|
||||
}
|
||||
|
||||
for _, tz := range validTimezones {
|
||||
t.Run(tz, func(t *testing.T) {
|
||||
_, err := time.LoadLocation(tz)
|
||||
assert.NoError(t, err, "Timezone %s should be valid", tz)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("InvalidTimezones", func(t *testing.T) {
|
||||
invalidTimezones := []string{
|
||||
"Invalid/Timezone",
|
||||
"Not/A/Timezone",
|
||||
"BadTimezone",
|
||||
"America/NotACity",
|
||||
}
|
||||
|
||||
for _, tz := range invalidTimezones {
|
||||
t.Run(tz, func(t *testing.T) {
|
||||
_, err := time.LoadLocation(tz)
|
||||
assert.Error(t, err, "Timezone %s should be invalid", tz)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCreateValidSignupRequest tests the helper function for creating valid requests
|
||||
func TestCreateValidSignupRequest(t *testing.T) {
|
||||
request := srequest.Signup{
|
||||
Account: model.AccountData{
|
||||
LoginData: model.LoginData{
|
||||
UserDataBase: model.UserDataBase{
|
||||
Login: "test@example.com",
|
||||
},
|
||||
Password: "TestPassword123!",
|
||||
},
|
||||
Describable: model.Describable{
|
||||
Name: "Test User",
|
||||
},
|
||||
},
|
||||
Organization: model.Describable{
|
||||
Name: "Test Organization",
|
||||
},
|
||||
OrganizationTimeZone: "UTC",
|
||||
OwnerRole: model.Describable{
|
||||
Name: "Owner",
|
||||
},
|
||||
}
|
||||
|
||||
// Validate the request structure
|
||||
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.Organization.Name)
|
||||
assert.Equal(t, "UTC", request.OrganizationTimeZone)
|
||||
}
|
||||
|
||||
// TestSignupRequestValidation tests various signup request validation scenarios
|
||||
func TestSignupRequestValidation(t *testing.T) {
|
||||
t.Run("ValidRequest", func(t *testing.T) {
|
||||
request := srequest.Signup{
|
||||
Account: model.AccountData{
|
||||
LoginData: model.LoginData{
|
||||
UserDataBase: model.UserDataBase{
|
||||
Login: "test@example.com",
|
||||
},
|
||||
Password: "TestPassword123!",
|
||||
},
|
||||
Describable: model.Describable{
|
||||
Name: "Test User",
|
||||
},
|
||||
},
|
||||
Organization: model.Describable{
|
||||
Name: "Test Organization",
|
||||
},
|
||||
OrganizationTimeZone: "UTC",
|
||||
}
|
||||
|
||||
// Basic validation - all required fields present
|
||||
assert.NotEmpty(t, request.Account.Login)
|
||||
assert.NotEmpty(t, request.Account.Password)
|
||||
assert.NotEmpty(t, request.Account.Name)
|
||||
assert.NotEmpty(t, request.Organization.Name)
|
||||
assert.NotEmpty(t, request.OrganizationTimeZone)
|
||||
})
|
||||
|
||||
t.Run("EmailFormats", func(t *testing.T) {
|
||||
validEmails := []string{
|
||||
"test@example.com",
|
||||
"user.name@example.com",
|
||||
"user+tag@example.org",
|
||||
"test123@domain.co.uk",
|
||||
}
|
||||
|
||||
for _, email := range validEmails {
|
||||
t.Run(email, func(t *testing.T) {
|
||||
request := srequest.Signup{
|
||||
Account: model.AccountData{
|
||||
LoginData: model.LoginData{
|
||||
UserDataBase: model.UserDataBase{
|
||||
Login: email,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
assert.Equal(t, email, request.Account.Login)
|
||||
assert.Contains(t, email, "@")
|
||||
assert.Contains(t, email, ".")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PasswordComplexity", func(t *testing.T) {
|
||||
passwordTests := []struct {
|
||||
name string
|
||||
password string
|
||||
valid bool
|
||||
}{
|
||||
{"Strong", "TestPassword123!", true},
|
||||
{"WithNumbers", "MyPass123!", true},
|
||||
{"WithSymbols", "Complex@Pass1", true},
|
||||
{"TooShort", "Test1!", false},
|
||||
{"NoNumbers", "TestPassword!", false},
|
||||
{"NoSymbols", "TestPassword123", false},
|
||||
{"NoUppercase", "testpassword123!", false},
|
||||
{"NoLowercase", "TESTPASSWORD123!", false},
|
||||
}
|
||||
|
||||
for _, tt := range passwordTests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
request := srequest.Signup{
|
||||
Account: model.AccountData{
|
||||
LoginData: model.LoginData{
|
||||
Password: tt.password,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Basic structure validation
|
||||
assert.Equal(t, tt.password, request.Account.Password)
|
||||
|
||||
if tt.valid {
|
||||
assert.True(t, len(tt.password) >= 8, "Password should be at least 8 characters")
|
||||
} else {
|
||||
// For invalid passwords, at least one condition should fail
|
||||
hasDigit := false
|
||||
hasUpper := false
|
||||
hasLower := false
|
||||
hasSpecial := false
|
||||
|
||||
for _, char := range tt.password {
|
||||
switch {
|
||||
case char >= '0' && char <= '9':
|
||||
hasDigit = true
|
||||
case char >= 'A' && char <= 'Z':
|
||||
hasUpper = true
|
||||
case char >= 'a' && char <= 'z':
|
||||
hasLower = true
|
||||
case char >= '!' && char <= '/' || char >= ':' && char <= '@':
|
||||
hasSpecial = true
|
||||
}
|
||||
}
|
||||
|
||||
// At least one requirement should fail for invalid passwords
|
||||
if len(tt.password) >= 8 {
|
||||
assert.False(t, hasDigit && hasUpper && hasLower && hasSpecial,
|
||||
"Password %s should fail at least one requirement", tt.password)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestAccountDataToAccount tests the ToAccount method
|
||||
func TestAccountDataToAccount(t *testing.T) {
|
||||
accountData := model.AccountData{
|
||||
LoginData: model.LoginData{
|
||||
UserDataBase: model.UserDataBase{
|
||||
Login: "test@example.com",
|
||||
},
|
||||
Password: "TestPassword123!",
|
||||
},
|
||||
Describable: model.Describable{
|
||||
Name: "Test User",
|
||||
},
|
||||
}
|
||||
|
||||
account := accountData.ToAccount()
|
||||
|
||||
assert.Equal(t, accountData.Login, account.Login)
|
||||
assert.Equal(t, accountData.Password, account.Password)
|
||||
assert.Equal(t, accountData.Name, account.Name)
|
||||
|
||||
// Verify the account has proper structure
|
||||
assert.NotNil(t, account)
|
||||
assert.IsType(t, &model.Account{}, account)
|
||||
}
|
||||
|
||||
// TestColorValidation tests that colors are properly formatted
|
||||
func TestColorValidation(t *testing.T) {
|
||||
validColors := []string{
|
||||
"#FF0000", // Red
|
||||
"#00FF00", // Green
|
||||
"#0000FF", // Blue
|
||||
"#FFFFFF", // White
|
||||
"#000000", // Black
|
||||
"#FF8000", // Orange
|
||||
}
|
||||
|
||||
for _, color := range validColors {
|
||||
t.Run(color, func(t *testing.T) {
|
||||
colorPtr := stringPtr(color)
|
||||
assert.NotNil(t, colorPtr)
|
||||
assert.Equal(t, color, *colorPtr)
|
||||
assert.True(t, len(color) == 7, "Color should be 7 characters long")
|
||||
assert.True(t, color[0] == '#', "Color should start with #")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 bson.ObjectID, refs []bson.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 bson.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 bson.ObjectID, patch builder.Patch) error {
|
||||
return merrors.NotImplemented("stub")
|
||||
}
|
||||
|
||||
func (s *stubAccountDB) Delete(ctx context.Context, objectRef bson.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 bson.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")
|
||||
})
|
||||
}
|
||||
31
api/edge/bff/internal/server/accountapiimp/token.go
Normal file
31
api/edge/bff/internal/server/accountapiimp/token.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package accountapiimp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/db/verification"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *AccountAPI) mapTokenErrorToResponse(err error) http.HandlerFunc {
|
||||
if errors.Is(err, verification.ErrTokenNotFound) {
|
||||
a.logger.Debug("Verification token not found during consume", zap.Error(err))
|
||||
return response.NotFound(a.logger, a.Name(), "No account found associated with given verifcation token")
|
||||
}
|
||||
if errors.Is(err, verification.ErrTokenExpired) {
|
||||
a.logger.Debug("Verification token expired during consume", zap.Error(err))
|
||||
return response.Gone(a.logger, a.Name(), "token_expired", "verification token has expired")
|
||||
}
|
||||
if errors.Is(err, verification.ErrTokenAlreadyUsed) {
|
||||
a.logger.Debug("Verification token already used during consume", zap.Error(err))
|
||||
return response.DataConflict(a.logger, a.Name(), "verification token has already been used")
|
||||
}
|
||||
if err != nil {
|
||||
a.logger.Warn("Uenxpected error during token verification", zap.Error(err))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
a.logger.Debug("No token verification error found")
|
||||
return response.Success(a.logger)
|
||||
}
|
||||
61
api/edge/bff/internal/server/accountapiimp/update.go
Normal file
61
api/edge/bff/internal/server/accountapiimp/update.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package accountapiimp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"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/mutil/mzap"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *AccountAPI) updateProfile(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
|
||||
ctx := r.Context()
|
||||
// Validate user input
|
||||
u, err := a.attemptDecodeAccount(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to decide profile update", zap.Error(err))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
if u.Login == "" {
|
||||
a.logger.Debug("No email in request")
|
||||
return a.reportEmailMissing()
|
||||
}
|
||||
if u.Name == "" {
|
||||
a.logger.Debug("No name in request")
|
||||
return response.BadRequest(a.logger, a.Name(), "name_missing", "name is required")
|
||||
}
|
||||
|
||||
if account.Login != u.Login {
|
||||
// Change email address
|
||||
verificationToken, err := a.accService.UpdateLogin(ctx, account, u.Login)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
a.logger.Debug("Duplicate login, denying change...", zap.Error(err), mzap.StorableRef(u))
|
||||
return a.reportDuplicateEmail()
|
||||
}
|
||||
a.logger.Warn("Error while updating login", zap.Error(err), mzap.StorableRef(u))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
// Send verification email
|
||||
if err = a.sendWelcomeEmail(account, verificationToken); err != nil {
|
||||
a.logger.Warn("Failed to send verification email", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
} else {
|
||||
// Save the user
|
||||
u.Password = account.Password
|
||||
u.Status = account.Status
|
||||
if err = a.db.Update(ctx, u); err != nil {
|
||||
a.logger.Warn("Failed to save account", zap.Error(err), mzap.StorableRef(u))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
return sresponse.Account(a.logger, u, token)
|
||||
}
|
||||
Reference in New Issue
Block a user