fx build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline failed

This commit is contained in:
Stephan D
2025-11-08 00:30:29 +01:00
parent 590fad0071
commit 49b86efecb
165 changed files with 9466 additions and 0 deletions

View File

@@ -0,0 +1,103 @@
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) reportTokenNotFound() http.HandlerFunc {
return response.NotFound(a.logger, a.Name(), "No account found associated with given verifcation token")
}
func (a *AccountAPI) sendWelcomeEmail(account *model.Account) error {
if err := a.producer.SendMessage(an.AccountCreated(a.Name(), *account.GetID())); 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)
}
// Get the account
// accnt, err := a.db.GetByEmail(ctx, paramGetter(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)
// }
accnt, err := paramGetter(r.Context(), 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)
}
if accnt.VerifyToken == "" {
a.logger.Debug("Verification token is empty", zap.Error(err), mzap.StorableRef(u))
return a.reportTokenNotFound()
}
// Send welcome email
if err = a.sendWelcomeEmail(accnt); err != nil {
a.logger.Warn("Failed to send verification email",
zap.Error(err), mzap.StorableRef(u), 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,123 @@
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/mservice"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.mongodb.org/mongo-driver/bson/primitive"
"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, primitive.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.accService.DeleteAll(ctx, &org, account.ID); err != nil {
a.logger.Error("Failed to delete all data", 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) (primitive.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,45 @@
package accountapiimp
import (
"errors"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"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()
user, err := a.db.GetByToken(ctx, token)
if errors.Is(err, merrors.ErrNoData) {
a.logger.Debug("Verification token not found", zap.Error(err))
return a.reportTokenNotFound()
}
if err != nil {
a.logger.Warn("Failed to fetch account", zap.Error(err))
return response.Internal(a.logger, a.Name(), err)
}
// Delete verification token to confirm account
user.VerifyToken = ""
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)
}
// 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/bson/primitive"
"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, primitive.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,82 @@
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
if err := a.accService.UpdateLogin(ctx, &acc, u.Login); 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); 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
if err := a.accService.ResetPassword(ctx, user); 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, user.ResetPasswordToken); 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)
}
// Validate reset token
if user.ResetPasswordToken == "" {
a.logger.Debug("No reset token found for user", mzap.StorableRef(&user))
return response.BadRequest(a.logger, a.Name(), "no_reset_token", "No password reset token found for this user")
}
if user.ResetPasswordToken != token {
a.logger.Debug("Reset token mismatch", mzap.StorableRef(&user))
return response.BadRequest(a.logger, a.Name(), "invalid_token", "Invalid or expired reset token")
}
// 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)
}
if req.Password == "" {
a.logger.Debug("New password is empty")
return response.BadRequest(a.logger, a.Name(), "empty_password", "New password cannot be empty")
}
// 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
user.ResetPasswordToken = "" // Clear the token after use
// 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,361 @@
package accountapiimp
import (
"testing"
"github.com/tech/sendico/pkg/model"
"github.com/stretchr/testify/assert"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// TestPasswordResetTokenGeneration tests the token generation logic
func TestPasswordResetTokenGeneration(t *testing.T) {
// Test that ResetPassword service method generates a token
account := &model.Account{
AccountPublic: model.AccountPublic{
AccountBase: model.AccountBase{
Describable: model.Describable{
Name: "Test User",
},
},
UserDataBase: model.UserDataBase{
Login: "test@example.com",
},
},
}
// Initially no reset token
assert.Empty(t, account.ResetPasswordToken, "Account should not have reset token initially")
// Simulate what ResetPassword service method does
account.ResetPasswordToken = "generated-token-123"
assert.NotEmpty(t, account.ResetPasswordToken, "Reset token should be generated")
assert.Equal(t, "generated-token-123", account.ResetPasswordToken, "Reset token should match generated value")
}
// TestPasswordResetTokenValidation tests token validation logic
func TestPasswordResetTokenValidation(t *testing.T) {
tests := []struct {
name string
storedToken string
providedToken string
shouldBeValid bool
}{
{
name: "ValidToken_ShouldMatch",
storedToken: "valid-token-123",
providedToken: "valid-token-123",
shouldBeValid: true,
},
{
name: "InvalidToken_ShouldNotMatch",
storedToken: "valid-token-123",
providedToken: "invalid-token-456",
shouldBeValid: false,
},
{
name: "EmptyStoredToken_ShouldBeInvalid",
storedToken: "",
providedToken: "any-token",
shouldBeValid: false,
},
{
name: "EmptyProvidedToken_ShouldBeInvalid",
storedToken: "valid-token-123",
providedToken: "",
shouldBeValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
account := &model.Account{
AccountPublic: model.AccountPublic{
AccountBase: model.AccountBase{
Describable: model.Describable{
Name: "Test User",
},
},
UserDataBase: model.UserDataBase{
Login: "test@example.com",
},
},
ResetPasswordToken: tt.storedToken,
}
// Test token validation logic (what the resetPassword handler does)
isValid := account.ResetPasswordToken != "" && account.ResetPasswordToken == tt.providedToken
assert.Equal(t, tt.shouldBeValid, isValid, "Token validation should match expected result")
})
}
}
// 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 := primitive.NewObjectID()
assert.NotEqual(t, primitive.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,124 @@
package accountapiimp
import (
"context"
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/domainprovider"
"github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"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/bson/primitive"
"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
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 primitive.ObjectID
accService accountservice.AccountService
}
func (a *AccountAPI) Name() mservice.Type {
return mservice.Accounts
}
func (a *AccountAPI) Finish(ctx context.Context) error {
return a.avatars.Finish(ctx)
}
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
}
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().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
return p, nil
}

View File

@@ -0,0 +1,176 @@
package accountapiimp
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"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/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
func (a *AccountAPI) createAnonymousAccount(ctx context.Context, org *model.Organization, sr *srequest.Signup) error {
anonymousUser := &model.Account{
AccountPublic: model.AccountPublic{
AccountBase: model.AccountBase{
Describable: sr.AnonymousUser,
},
UserDataBase: sr.Account.UserDataBase,
},
}
r, err := a.pmanager.Role().Create(ctx, org.ID, &sr.AnonymousRole)
if err != nil {
a.logger.Warn("Failed to create anonymous role", zap.Error(err))
return err
}
if err := a.accService.CreateAccount(ctx, org, anonymousUser, r.ID); err != nil {
a.logger.Warn("Failed to create account", zap.Error(err), zap.String("login", anonymousUser.Login))
return err
}
return nil
}
func (a *AccountAPI) createOrg(ctx context.Context, sr *srequest.Signup, permissionRef primitive.ObjectID) (*model.Organization, error) {
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()))
}
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
PermissionBound: model.PermissionBound{
PermissionRef: permissionRef,
},
Describable: model.Describable{
Name: sr.OrganizationName,
},
TimeZone: sr.OrganizationTimeZone,
},
Members: []primitive.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.BadRequest(a.logger, a.Name(), "", err.Error())
}
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)
}
if err := a.executeSignupTransaction(r.Context(), &sr, newAccount); err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
a.logger.Info("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.Info("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); err != nil {
a.logger.Warn("Failed to send welcome email", zap.Error(err), mzap.StorableRef(newAccount))
}
return sresponse.SignUp(a.logger, newAccount)
}
func (a *AccountAPI) executeSignupTransaction(ctxt context.Context, sr *srequest.Signup, newAccount *model.Account) error {
_, err := a.tf.CreateTransaction().Execute(ctxt, func(ctx context.Context) (any, error) {
return a.signupTransactionBody(ctx, sr, newAccount)
})
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
}
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
}
if err := a.grantAllPermissions(ctx, org.ID, roleDescription.ID, newAccount); err != nil {
return nil, err
}
if err := a.accService.CreateAccount(ctx, org, newAccount, roleDescription.ID); err != nil {
a.logger.Warn("Failed to create account", zap.Error(err), zap.String("login", newAccount.Login))
return nil, err
}
if err := a.createAnonymousAccount(ctx, org, sr); err != nil {
return nil, err
}
return nil, nil
}
func (a *AccountAPI) grantAllPermissions(ctx context.Context, organizationRef primitive.ObjectID, roleID primitive.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
}
actions := []model.Action{model.ActionCreate, model.ActionRead, model.ActionUpdate, model.ActionDelete}
for _, policy := range policies {
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
}
return nil
}

View File

@@ -0,0 +1,249 @@
package accountapiimp_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"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/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
// Helper function to create string pointers
func stringPtr(s string) *string {
return &s
}
// TestSignupRequestSerialization tests JSON marshaling/unmarshaling with real MongoDB
func TestSignupRequestSerialization(t *testing.T) {
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(ctx, 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!",
},
Name: "Test User",
},
OrganizationName: "Test Organization",
OrganizationTimeZone: "UTC",
DefaultPriorityGroup: srequest.CreatePriorityGroup{
Description: model.Describable{
Name: "Default Priority Group",
},
Priorities: []model.Colorable{
{
Describable: model.Describable{Name: "High"},
Color: stringPtr("#FF0000"),
},
{
Describable: model.Describable{Name: "Medium"},
Color: stringPtr("#FFFF00"),
},
{
Describable: model.Describable{Name: "Low"},
Color: stringPtr("#00FF00"),
},
},
},
AnonymousUser: model.Describable{
Name: "Anonymous User",
},
OwnerRole: model.Describable{
Name: "Owner",
},
AnonymousRole: model.Describable{
Name: "Anonymous",
},
}
// 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.OrganizationName, retrieved.OrganizationName)
assert.Equal(t, signupRequest.OrganizationTimeZone, retrieved.OrganizationTimeZone)
assert.Equal(t, len(signupRequest.DefaultPriorityGroup.Priorities), len(retrieved.DefaultPriorityGroup.Priorities))
// Verify priorities
for i, priority := range signupRequest.DefaultPriorityGroup.Priorities {
assert.Equal(t, priority.Name, retrieved.DefaultPriorityGroup.Priorities[i].Name)
if priority.Color != nil && retrieved.DefaultPriorityGroup.Priorities[i].Color != nil {
assert.Equal(t, *priority.Color, *retrieved.DefaultPriorityGroup.Priorities[i].Color)
}
}
})
}
// 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!",
},
Name: "Test User",
},
OrganizationName: "Test Organization",
OrganizationTimeZone: "UTC",
DefaultPriorityGroup: srequest.CreatePriorityGroup{
Description: model.Describable{
Name: "Default Priority Group",
},
Priorities: []model.Colorable{
{
Describable: model.Describable{Name: "High"},
Color: stringPtr("#FF0000"),
},
},
},
AnonymousUser: model.Describable{
Name: "Anonymous User",
},
OwnerRole: model.Describable{
Name: "Owner",
},
AnonymousRole: model.Describable{
Name: "Anonymous",
},
}
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.OrganizationName, parsedRequest.OrganizationName)
})
t.Run("UnicodeCharacters", func(t *testing.T) {
unicodeRequest := signupRequest
unicodeRequest.Account.Name = "Test 用户 Üser"
unicodeRequest.OrganizationName = "测试 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.OrganizationName)
})
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!",
},
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,311 @@
package accountapiimp
import (
"testing"
"time"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/stretchr/testify/assert"
)
// 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!",
},
Name: "Test User",
},
OrganizationName: "Test Organization",
OrganizationTimeZone: "UTC",
DefaultPriorityGroup: srequest.CreatePriorityGroup{
Description: model.Describable{
Name: "Default Priority Group",
},
Priorities: []model.Colorable{
{
Describable: model.Describable{Name: "High"},
Color: stringPtr("#FF0000"),
},
{
Describable: model.Describable{Name: "Medium"},
Color: stringPtr("#FFFF00"),
},
{
Describable: model.Describable{Name: "Low"},
Color: stringPtr("#00FF00"),
},
},
},
AnonymousUser: model.Describable{
Name: "Anonymous User",
},
OwnerRole: model.Describable{
Name: "Owner",
},
AnonymousRole: model.Describable{
Name: "Anonymous",
},
}
// 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.OrganizationName)
assert.Equal(t, "UTC", request.OrganizationTimeZone)
assert.Equal(t, "Default Priority Group", request.DefaultPriorityGroup.Description.Name)
assert.Len(t, request.DefaultPriorityGroup.Priorities, 3)
assert.Equal(t, "High", request.DefaultPriorityGroup.Priorities[0].Name)
assert.Equal(t, "#FF0000", *request.DefaultPriorityGroup.Priorities[0].Color)
}
// 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!",
},
Name: "Test User",
},
OrganizationName: "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.OrganizationName)
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)
}
}
})
}
})
}
// TestPriorityGroupCreation tests the priority group structure
func TestPriorityGroupCreation(t *testing.T) {
priorityGroup := srequest.CreatePriorityGroup{
Description: model.Describable{
Name: "Test Priority Group",
},
Priorities: []model.Colorable{
{
Describable: model.Describable{Name: "Critical"},
Color: stringPtr("#FF0000"),
},
{
Describable: model.Describable{Name: "High"},
Color: stringPtr("#FF8000"),
},
{
Describable: model.Describable{Name: "Medium"},
Color: stringPtr("#FFFF00"),
},
{
Describable: model.Describable{Name: "Low"},
Color: stringPtr("#00FF00"),
},
},
}
assert.Equal(t, "Test Priority Group", priorityGroup.Description.Name)
assert.Len(t, priorityGroup.Priorities, 4)
// Test each priority
expectedPriorities := []struct {
name string
color string
}{
{"Critical", "#FF0000"},
{"High", "#FF8000"},
{"Medium", "#FFFF00"},
{"Low", "#00FF00"},
}
for i, expected := range expectedPriorities {
assert.Equal(t, expected.name, priorityGroup.Priorities[i].Name)
assert.Equal(t, expected.color, *priorityGroup.Priorities[i].Color)
}
}
// 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!",
},
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 #")
})
}
}

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
if err := a.accService.UpdateLogin(ctx, account, u.Login); 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); 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.ResetPasswordToken = account.ResetPasswordToken
u.VerifyToken = account.VerifyToken
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)
}