move api/server to api/edge/bff

This commit is contained in:
Stephan D
2026-02-28 00:39:20 +01:00
parent 34182af3b8
commit 98db0e4e9e
248 changed files with 406 additions and 18 deletions

View 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")
}

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

View 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,
)
}

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

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

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

View 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
}

View 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")
})
}
})
}

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

View 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())
}

View 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
}

View File

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

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

View 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")
})
}

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

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