Remove platform tenancy from SXCHNGE customer BFF (#34)

This commit is contained in:
Stephan D committed 2026-09-12 14:00:14 +02:00
1 parent 7062672a87
commit 981e84a4b4
244 files changed
+651 -17130

No files matched your search

@@ -0,0 +1,130 @@
package accountserviceimp
import (
"context"
"net/url"
"testing"
"time"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
edgemongo "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/mongo"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/verification"
"git.sendico.io/tech/sxchnge/bff/pkg/db"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func customer(login string) *model.Account {
return (&model.AccountData{LoginData: model.LoginData{UserDataBase: model.UserDataBase{Login: login}, Password: "CustomerPassword1!"}, Describable: model.Describable{Name: "Customer"}}).ToAccount()
}
// Uses the actual copied repositories against an empty SXCHNGE database.
// There is deliberately no seed, organization, role, policy or Sendico connection.
func TestCustomerLifecycleWithoutTenancy(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
container, err := mongodb.Run(ctx, "mongo:8", mongodb.WithReplicaSet("rs"))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
uri, err := container.ConnectionString(ctx)
require.NoError(t, err)
endpoint, err := url.Parse(uri)
require.NoError(t, err)
query := endpoint.Query()
query.Set("directConnection", "true")
endpoint.RawQuery = query.Encode()
uri = endpoint.String()
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: model.SettingsT{"uri": uri, "database": "sxchnge_customers"}})
require.NoError(t, err)
t.Cleanup(factory.CloseConnection)
// Custom collections prove lifecycle and login storage select the same data.
config := &api.AuthStorageConfig{AccountsCollection: "customers", VerificationTokensCollection: "customer_verification", RefreshTokensCollection: "customer_sessions"}
svc, err := NewAccountService(zap.NewNop(), factory, &middleware.PasswordConfig{Check: middleware.PasswordChecks{MinLength: 12}}, config)
require.NoError(t, err)
first, second := customer("first@example.test"), customer("second@example.test")
firstToken, err := svc.CreateAccount(ctx, first)
require.NoError(t, err)
require.NotEmpty(t, firstToken)
require.Equal(t, model.AccountPendingVerification, first.Status)
require.True(t, first.MatchPassword("CustomerPassword1!"))
_, err = svc.CreateAccount(ctx, second)
require.NoError(t, err)
require.NotEqual(t, first.ID, second.ID)
// Activation tokens cannot be used as another customer's token.
_, err = svc.vdb.Consume(ctx, second.ID, model.PurposeAccountActivation, "", firstToken)
require.Error(t, err)
_, err = svc.vdb.Consume(ctx, first.ID, model.PurposeAccountActivation, "", firstToken)
require.NoError(t, err)
_, err = svc.CreateAccount(ctx, customer("first@example.test"))
require.ErrorIs(t, err, merrors.ErrDataConflict)
require.NoError(t, svc.ValidateAccount(customer("validate@example.test")))
require.ErrorIs(t, svc.ValidateAccount(nil), merrors.ErrInvalidArg)
_, err = svc.CreateAccount(ctx, nil)
require.ErrorIs(t, err, merrors.ErrInvalidArg)
require.ErrorIs(t, svc.DeleteAccount(ctx, bson.NilObjectID), merrors.ErrInvalidArg)
for _, c := range []*model.Account{first, second} {
require.NoError(t, svc.sessions.Create(ctx, &model.RefreshToken{AccountRef: &c.ID, ExpiresAt: time.Now().Add(time.Hour), ClientRefreshToken: model.ClientRefreshToken{SessionIdentifier: model.SessionIdentifier{ClientID: "web", DeviceID: "device", SessionVersion: "v1"}, RefreshToken: c.ID.Hex()}}))
}
require.NoError(t, svc.DeleteAccount(ctx, first.ID))
var stored model.Account
require.ErrorIs(t, svc.accountDB.Get(ctx, first.ID, &stored), merrors.ErrNoData)
require.NoError(t, svc.accountDB.Get(ctx, second.ID, &stored))
session := &model.SessionIdentifier{ClientID: "web", DeviceID: "device", SessionVersion: "v1"}
_, err = svc.sessions.GetSession(ctx, first.ID, session)
require.Error(t, err)
_, err = svc.sessions.GetSession(ctx, second.ID, session)
require.NoError(t, err)
// The final local customer can be deleted; there is no sole-member restriction.
require.NoError(t, svc.DeleteAccount(ctx, second.ID))
deps, err := edgemongo.ResolveDependencies(factory)
require.NoError(t, err)
collections, err := deps.Database.ListCollectionNames(ctx, bson.D{})
require.NoError(t, err)
for _, forbidden := range []string{"organizations", "roles", "policies", "policy_assignments", "invitations", "accounts"} {
require.NotContains(t, collections, forbidden)
}
// A token issuance failure must roll back the preceding customer insert.
realVerification := svc.vdb
failure := merrors.Internal("injected verification failure")
svc.vdb = &failingVerification{DB: realVerification, failure: failure}
candidate := customer("rollback@example.test")
_, err = svc.CreateAccount(ctx, candidate)
require.ErrorIs(t, err, failure)
require.True(t, candidate.ID.IsZero())
_, err = svc.accountDB.GetByEmail(ctx, candidate.Login)
require.ErrorIs(t, err, merrors.ErrNoData)
svc.vdb = realVerification
// If an outer workflow fails after token issuance, neither write may commit.
outerFailure := merrors.Internal("injected outer failure")
_, err = factory.TransactionFactory().CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
_, e := svc.CreateAccount(tx, customer("outer@example.test"))
if e != nil {
return nil, e
}
return nil, outerFailure
})
require.ErrorIs(t, err, outerFailure)
_, err = svc.accountDB.GetByEmail(ctx, "outer@example.test")
require.ErrorIs(t, err, merrors.ErrNoData)
count, err := deps.Database.Collection(config.VerificationTokensCollection).CountDocuments(ctx, bson.D{})
require.NoError(t, err)
require.EqualValues(t, 2, count)
}
type failingVerification struct {
verification.DB
failure error
}
func (f *failingVerification) Create(context.Context, *verification.Request) (string, error) {
return "", f.failure
}
@@ -1,119 +0,0 @@
package accountserviceimp
import (
"context"
"testing"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/organization"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
// fakeMembershipOrgDB answers membership from its own state, standing in for
// the transaction-scoped reads a real repository would serve.
type fakeMembershipOrgDB struct {
organization.DB
member bool
isMemberCalls int
addMemberCalls int
addedMember bson.ObjectID
}
func (db *fakeMembershipOrgDB) IsMember(_ context.Context, _, _ bson.ObjectID) (bool, error) {
db.isMemberCalls++
return db.member, nil
}
func (db *fakeMembershipOrgDB) AddMember(_ context.Context, _, accountRef bson.ObjectID) (bool, error) {
db.addMemberCalls++
db.addedMember = accountRef
db.member = true
return true, nil
}
type recordingRoleManager struct {
assigned []*model.Role
}
func (m *recordingRoleManager) Create(_ context.Context, orgRef bson.ObjectID, _ *model.Describable) (*model.RoleDescription, error) {
description := &model.RoleDescription{}
description.SetID(bson.NewObjectID())
description.OrganizationRef = orgRef
return description, nil
}
func (m *recordingRoleManager) Delete(_ context.Context, _ bson.ObjectID) error { return nil }
func (m *recordingRoleManager) Assign(_ context.Context, role *model.Role) error {
m.assigned = append(m.assigned, role)
return nil
}
func (m *recordingRoleManager) Revoke(_ context.Context, _, _, _ bson.ObjectID) error { return nil }
func (m *recordingRoleManager) List(_ context.Context, _ bson.ObjectID) ([]model.RoleDescription, error) {
return nil, nil
}
func joinTestService(orgDB *fakeMembershipOrgDB, roles *recordingRoleManager) *service {
return &service{
logger: zap.NewNop(),
orgDB: orgDB,
roleManager: roles,
}
}
func joinTestOrganization(members ...bson.ObjectID) *model.Organization {
org := &model.Organization{Members: members}
org.SetID(bson.NewObjectID())
return org
}
func joinTestAccount() *model.Account {
account := &model.Account{}
account.SetID(bson.NewObjectID())
return account
}
// A transaction replay rolls back the database but leaves marks on the shared
// in-memory organization. Membership decisions must come from the database,
// otherwise the replay skips the role assignment and the membership write and
// commits an organization without its owner.
func TestJoinOrganizationDecidesMembershipByDatabase(t *testing.T) {
account := joinTestAccount()
// The in-memory list already carries the account: exactly the state a
// first, aborted transaction attempt leaves behind.
org := joinTestOrganization(account.ID)
orgDB := &fakeMembershipOrgDB{member: false}
roles := &recordingRoleManager{}
svc := joinTestService(orgDB, roles)
if err := svc.JoinOrganization(context.Background(), org, account, bson.NewObjectID()); err != nil {
t.Fatalf("join failed: %v", err)
}
if len(roles.assigned) != 1 {
t.Fatalf("role assignment mismatch: got=%d want=1", len(roles.assigned))
}
if orgDB.addMemberCalls != 1 || orgDB.addedMember != account.ID {
t.Fatalf("membership write mismatch: calls=%d member=%s", orgDB.addMemberCalls, orgDB.addedMember.Hex())
}
if len(org.Members) != 1 {
t.Fatalf("in-memory members must stay deduplicated: got=%v", org.Members)
}
}
func TestJoinOrganizationSkipsExistingMember(t *testing.T) {
account := joinTestAccount()
org := joinTestOrganization()
orgDB := &fakeMembershipOrgDB{member: true}
roles := &recordingRoleManager{}
svc := joinTestService(orgDB, roles)
if err := svc.JoinOrganization(context.Background(), org, account, bson.NewObjectID()); err != nil {
t.Fatalf("join failed: %v", err)
}
if len(roles.assigned) != 0 || orgDB.addMemberCalls != 0 {
t.Fatalf("an existing member must not be re-joined: roles=%d adds=%d", len(roles.assigned), orgDB.addMemberCalls)
}
}
@@ -4,19 +4,17 @@ import (
"context"
"errors"
"fmt"
"slices"
"time"
"unicode"
authstorage "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/storage"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
edgestorage "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/mongo"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/organization"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/verification"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/auth/management"
"git.sendico.io/tech/sxchnge/bff/pkg/db"
"git.sendico.io/tech/sxchnge/bff/pkg/db/account"
"git.sendico.io/tech/sxchnge/bff/pkg/db/policy"
"git.sendico.io/tech/sxchnge/bff/pkg/db/transaction"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
@@ -26,18 +24,19 @@ import (
)
type service struct {
logger mlogger.Logger
accountDB account.DB
orgDB organization.DB
enforcer auth.Enforcer
roleManager management.Role
config *middleware.PasswordConfig
logger mlogger.Logger
accountDB account.DB
config *middleware.PasswordConfig
policyDB policy.DB
vdb verification.DB
vdb verification.DB
sessions refreshtokens.DB
transactions transaction.Factory
}
func validateUserRequest(u *model.Account) error {
if u == nil {
return merrors.InvalidArgument("account is required")
}
if u.Name == "" {
return merrors.InvalidArgument("Name must not be empty")
}
@@ -99,17 +98,17 @@ func (s *service) ValidatePassword(
func (s *service) ValidateAccount(acct *model.Account) error {
if err := validateUserRequest(acct); err != nil {
s.logger.Warn("Invalid signup acccount received", zap.Error(err), zap.String("account", acct.Login))
s.logger.Warn("Invalid signup account received", zap.Error(err))
return err
}
if err := s.ValidatePassword(acct.Password, nil); err != nil {
s.logger.Warn("Password validation failed", zap.Error(err), zap.String("account", acct.Login))
s.logger.Warn("Password validation failed", zap.Error(err), mzap.Login(acct))
return err
}
if err := acct.HashPassword(); err != nil {
s.logger.Warn("Failed to hash password", zap.Error(err), zap.String("account", acct.Login))
s.logger.Warn("Failed to hash password", zap.Error(err), mzap.Login(acct))
return err
}
@@ -118,45 +117,52 @@ func (s *service) ValidateAccount(acct *model.Account) error {
func (s *service) CreateAccount(
ctx context.Context,
org *model.Organization,
acct *model.Account,
roleDescID bson.ObjectID,
) (string, error) {
if org == nil {
return "", merrors.InvalidArgument("Organization must not be nil")
if acct == nil {
return "", merrors.InvalidArgument("account is required")
}
if acct == nil || len(acct.Login) == 0 {
return "", merrors.InvalidArgument("Account must have a non-empty login")
if !acct.ID.IsZero() {
return "", merrors.InvalidArgument("new account must not have an ID")
}
if roleDescID == bson.NilObjectID {
return "", merrors.InvalidArgument("Role description must be provided")
candidate := acct.Copy()
if err := s.ValidateAccount(candidate); err != nil {
return "", err
}
// 1) Create the account
acct.Status = model.AccountPendingVerification
if err := s.accountDB.Create(ctx, acct); err != nil {
candidate.Status = model.AccountPendingVerification
// The customer and activation token either both commit or neither does.
var saved *model.Account
result, err := s.transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
saved = candidate.Copy()
if err := s.accountDB.Create(tx, saved); err != nil {
return nil, err
}
return s.VerifyAccount(tx, saved)
})
if err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
s.logger.Info("Username is already taken", zap.String("login", acct.Login))
s.logger.Info("Customer login already exists")
} else {
s.logger.Warn("Failed to signup a user", zap.Error(err), zap.String("login", acct.Login))
s.logger.Warn("Customer creation failed", zap.Error(err))
}
return "", err
}
// 2) Add to organization
if err := s.JoinOrganization(ctx, org, acct, roleDescID); err != nil {
s.logger.Warn("Failed to register new organization member", zap.Error(err), mzap.StorableRef(acct))
return "", err
token, ok := result.(string)
if !ok {
return "", merrors.Internal("unexpected account creation result")
}
// 3) Issue verification token
return s.VerifyAccount(ctx, acct)
*acct = *saved
s.logger.Info("Customer created pending verification", mzap.AccRef(acct.ID))
return token, nil
}
func (s *service) VerifyAccount(
ctx context.Context,
acct *model.Account,
) (string, error) {
if acct == nil || acct.ID.IsZero() {
return "", merrors.InvalidArgument("customer reference is required")
}
token, err := s.vdb.Create(
ctx,
verification.NewLinkRequest(*acct.GetID(), model.PurposeAccountActivation, "").
@@ -173,62 +179,22 @@ func (s *service) VerifyAccount(
func (s *service) DeleteAccount(
ctx context.Context,
org *model.Organization,
accountRef bson.ObjectID,
) error {
// Check if this is the only member in the organization
if len(org.Members) <= 1 {
s.logger.Warn("Cannot delete account - it's the only member in the organization",
mzap.AccRef(accountRef), mzap.StorableRef(org))
return merrors.InvalidArgument("Cannot delete the only member of an organization")
if accountRef.IsZero() {
return merrors.InvalidArgument("account reference is required")
}
// 1) Remove from organization
if err := s.RemoveAccountFromOrganization(ctx, org, accountRef); err != nil {
s.logger.Warn("Failed to revoke account role", zap.Error(err), mzap.AccRef(accountRef))
return err
}
// 2) Delete the account document
if err := s.accountDB.Delete(ctx, accountRef); err != nil {
s.logger.Warn("Failed to delete account", zap.Error(err), mzap.AccRef(accountRef))
return err
}
return nil
}
func (s *service) RemoveAccountFromOrganization(
ctx context.Context,
org *model.Organization,
accountRef bson.ObjectID,
) error {
if org == nil {
return merrors.InvalidArgument("Organization must not be nil")
}
roles, err := s.enforcer.GetRoles(ctx, accountRef, org.ID)
_, err := s.transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
if err := s.sessions.RevokeAll(tx, accountRef, ""); err != nil {
return nil, err
}
return nil, s.accountDB.Delete(tx, accountRef)
})
if err != nil {
s.logger.Warn("Failed to fetch account permissions", zap.Error(err), mzap.StorableRef(org),
mzap.AccRef(accountRef))
s.logger.Warn("Customer deletion failed", zap.Error(err), mzap.AccRef(accountRef))
return err
}
for _, role := range roles {
if err := s.roleManager.Revoke(ctx, role.DescriptionRef, accountRef, org.ID); err != nil {
s.logger.Warn("Failed to revoke account role", zap.Error(err),
mzap.AccRef(accountRef), mzap.ObjRef("role_ref", role.DescriptionRef))
return err
}
}
for i, member := range org.Members {
if member == accountRef {
// Remove the member by slicing it out
org.Members = append(org.Members[:i], org.Members[i+1:]...)
if err := s.orgDB.Update(ctx, accountRef, org); err != nil {
s.logger.Warn("Failed to remove member from organization", zap.Error(err), mzap.AccRef(accountRef))
return err
}
break
}
}
s.logger.Info("Customer deleted and sessions revoked", mzap.AccRef(accountRef))
return nil
}
@@ -236,6 +202,9 @@ func (s *service) ResetPassword(
ctx context.Context,
acct *model.Account,
) (string, error) {
if acct == nil || acct.ID.IsZero() {
return "", merrors.InvalidArgument("customer reference is required")
}
return s.vdb.Create(
ctx,
verification.NewOTPRequest(*acct.GetID(), model.PurposePasswordReset, "").
@@ -248,6 +217,9 @@ func (s *service) UpdateLogin(
acct *model.Account,
newLogin string,
) (string, error) {
if acct == nil || acct.ID.IsZero() {
return "", merrors.InvalidArgument("customer reference is required")
}
return s.vdb.Create(
ctx,
verification.NewOTPRequest(*acct.GetID(), model.PurposeEmailChange, newLogin).
@@ -255,196 +227,22 @@ func (s *service) UpdateLogin(
)
}
func (s *service) JoinOrganization(
ctx context.Context,
org *model.Organization,
account *model.Account,
roleDescID bson.ObjectID,
) error {
// Membership is decided by the database, not the in-memory copy: this method
// runs inside provisioning transactions whose retries replay the callback,
// and a stale in-memory list would silently skip the role assignment.
member, err := s.orgDB.IsMember(ctx, org.ID, account.ID)
if err != nil {
return err
}
if member {
s.logger.Debug("Account is already a member", mzap.StorableRef(org), mzap.StorableRef(account))
return nil
}
role := &model.Role{
DescriptionRef: roleDescID,
OrganizationRef: org.ID,
AccountRef: account.ID,
}
if err := s.roleManager.Assign(ctx, role); err != nil {
s.logger.Warn("Failed to assign role to account", zap.Error(err), mzap.StorableRef(account),
mzap.StorableRef(org), mzap.ObjRef("role_description_ref", roleDescID))
return err
}
// A point patch on purpose: replacing the whole document would clobber
// concurrent writes, most notably the provisioning lease that guards
// finalization against attempts whose lease was taken over.
if _, err := s.orgDB.AddMember(ctx, org.ID, account.ID); err != nil {
s.logger.Warn("Failed to update organization members list",
zap.Error(err), mzap.StorableRef(account), mzap.StorableRef(org))
return err
}
if !slices.Contains(org.Members, account.ID) {
org.Members = append(org.Members, account.ID)
}
return nil
}
func (s *service) deleteOrganizationRoles(ctx context.Context, orgRef bson.ObjectID) error {
return s.deleteOrganizationEntities(ctx, orgRef, "roles", "role", "role_ref", func(ctx context.Context, orgRef bson.ObjectID) ([]bson.ObjectID, error) {
roles, err := s.roleManager.List(ctx, orgRef)
if err != nil {
return nil, err
}
roleIDs := make([]bson.ObjectID, 0, len(roles))
for _, role := range roles {
roleIDs = append(roleIDs, role.ID)
}
return roleIDs, nil
}, s.roleManager.Delete)
}
func (s *service) deleteOrganizationPolicies(ctx context.Context, orgRef bson.ObjectID) error {
return s.deleteOrganizationEntities(ctx, orgRef, "policies", "policy", "policy_ref", func(ctx context.Context, orgRef bson.ObjectID) ([]bson.ObjectID, error) {
policies, err := s.policyDB.All(ctx, orgRef)
if err != nil {
return nil, err
}
policyIDs := make([]bson.ObjectID, 0, len(policies))
for _, policy := range policies {
policyIDs = append(policyIDs, policy.ID)
}
return policyIDs, nil
}, s.policyDB.Delete)
}
func (s *service) deleteOrganizationEntities(
ctx context.Context,
orgRef bson.ObjectID,
entityName string,
entityLabel string,
entityRefField string,
load func(context.Context, bson.ObjectID) ([]bson.ObjectID, error),
deleteFn func(context.Context, bson.ObjectID) error,
) error {
s.logger.Info(fmt.Sprintf("Deleting %s for organization", entityName), mzap.OrgRef(orgRef))
entityIDs, err := load(ctx, orgRef)
if err != nil && !errors.Is(err, merrors.ErrNoData) {
s.logger.Warn(fmt.Sprintf("Failed to fetch %s for deletion", entityName), zap.Error(err), mzap.OrgRef(orgRef))
return err
}
for _, entityID := range entityIDs {
if err := deleteFn(ctx, entityID); err != nil {
s.logger.Warn(fmt.Sprintf("Failed to delete %s", entityLabel), zap.Error(err), mzap.ObjRef(entityRefField, entityID))
return err
}
}
s.logger.Info(fmt.Sprintf("Successfully deleted %s", entityName), zap.Int("count", len(entityIDs)), mzap.OrgRef(orgRef))
return nil
}
func (s *service) DeleteOrganization(
ctx context.Context,
org *model.Organization,
) error {
s.logger.Info("Starting organization deletion", mzap.StorableRef(org))
// Use transaction to ensure atomicity
// 8. Delete all roles and role descriptions in the organization
if err := s.deleteOrganizationRoles(ctx, org.ID); err != nil {
return err
}
// 9. Delete all policies in the organization
if err := s.deleteOrganizationPolicies(ctx, org.ID); err != nil {
return err
}
// 10. Finally, delete the organization itself
if err := s.orgDB.Delete(ctx, bson.NilObjectID, org.ID); err != nil {
s.logger.Warn("Failed to delete organization", zap.Error(err), mzap.StorableRef(org))
return err
}
s.logger.Info("Organization deleted successfully", mzap.StorableRef(org))
return nil
}
func (s *service) DeleteAll(
ctx context.Context,
org *model.Organization,
accountRef bson.ObjectID,
) error {
s.logger.Info("Starting complete deletion (organization + account)",
mzap.StorableRef(org), mzap.AccRef(accountRef))
// 1. First delete the organization and all its data
if err := s.DeleteOrganization(ctx, org); err != nil {
return err
}
// 2. Then delete the account
if err := s.accountDB.Delete(ctx, accountRef); err != nil {
s.logger.Warn("Failed to delete account", zap.Error(err), mzap.AccRef(accountRef))
return err
}
s.logger.Info("Complete deletion successful", mzap.StorableRef(org), mzap.AccRef(accountRef))
return nil
}
// NewAccountService wires in your logger plus the three dependencies.
func NewAccountService(
l mlogger.Logger,
dbf db.Factory,
enforcer auth.Enforcer,
ra management.Role,
config *middleware.PasswordConfig,
storageConfig *api.AuthStorageConfig,
) (*service, error) {
logger := l.Named("account_service")
if config == nil {
return nil, merrors.Internal("Invalid account service configuration provides")
return nil, merrors.Internal("password configuration is required")
}
res := &service{
logger: logger,
enforcer: enforcer,
roleManager: ra,
config: config,
}
deps, err := edgestorage.ResolveDependencies(dbf)
stores, err := authstorage.OpenStores(logger, dbf, storageConfig)
if err != nil {
return nil, err
}
if res.accountDB, err = dbf.NewAccountDB(); err != nil {
logger.Error("Failed to create accounts database", zap.Error(err))
return nil, err
}
if res.orgDB, err = organization.Create(context.Background(), logger, deps.Enforcer, deps.Policies, deps.Database); err != nil {
logger.Error("Failed to create organizations database", zap.Error(err))
return nil, err
}
// Initialize database dependencies for cascade deletion
if res.policyDB, err = dbf.NewPoliciesDB(); err != nil {
logger.Error("Failed to create policies database", zap.Error(err))
return nil, err
}
if res.vdb, err = verification.Create(logger, deps.Database, deps.Transactions); err != nil {
logger.Error("Failed to create verification database", zap.Error(err))
return nil, err
}
return res, nil
return &service{logger: logger, config: config, accountDB: stores.Accounts,
vdb: stores.Verification, sessions: stores.RefreshTokens, transactions: dbf.TransactionFactory()}, nil
}
@@ -1,156 +0,0 @@
package accountserviceimp
import (
"testing"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestDeleteAccount_Validation(t *testing.T) {
t.Run("DeleteAccount_LastMemberFails", func(t *testing.T) {
orgID := bson.NewObjectID()
accountID := bson.NewObjectID()
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
Describable: model.Describable{Name: "Single Member Org"},
},
Members: []bson.ObjectID{accountID}, // Only one member
}
org.ID = orgID
// This should fail because it's the only member
err := validateDeleteAccount(org)
require.Error(t, err)
assert.Contains(t, err.Error(), "Cannot delete the only member")
})
t.Run("DeleteAccount_MultipleMembersSuccess", func(t *testing.T) {
orgID := bson.NewObjectID()
accountID := bson.NewObjectID()
otherAccountID := bson.NewObjectID()
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
Describable: model.Describable{Name: "Multi Member Org"},
},
Members: []bson.ObjectID{accountID, otherAccountID}, // Multiple members
}
org.ID = orgID
// This should succeed because there are multiple members
err := validateDeleteAccount(org)
require.NoError(t, err)
})
t.Run("DeleteAccount_EmptyMembersList", func(t *testing.T) {
orgID := bson.NewObjectID()
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
Describable: model.Describable{Name: "Empty Org"},
},
Members: []bson.ObjectID{}, // No members
}
org.ID = orgID
// This should fail because there are no members
err := validateDeleteAccount(org)
require.Error(t, err)
assert.Contains(t, err.Error(), "Cannot delete the only member")
})
}
func TestDeleteOrganization_Validation(t *testing.T) {
t.Run("DeleteOrganization_NilOrganization", func(t *testing.T) {
err := validateDeleteOrganization(nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "organization cannot be nil")
})
t.Run("DeleteOrganization_EmptyOrganization", func(t *testing.T) {
org := &model.Organization{}
err := validateDeleteOrganization(org)
require.Error(t, err)
assert.Contains(t, err.Error(), "organization ID cannot be empty")
})
t.Run("DeleteOrganization_ValidOrganization", func(t *testing.T) {
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
Describable: model.Describable{Name: "Valid Organization"},
},
}
org.ID = bson.NewObjectID()
err := validateDeleteOrganization(org)
require.NoError(t, err)
})
}
func TestDeleteAll_Validation(t *testing.T) {
t.Run("DeleteAll_NilOrganization", func(t *testing.T) {
accountID := bson.NewObjectID()
err := validateDeleteAll(nil, accountID)
require.Error(t, err)
assert.Contains(t, err.Error(), "organization cannot be nil")
})
t.Run("DeleteAll_EmptyAccountID", func(t *testing.T) {
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
Describable: model.Describable{Name: "Valid Organization"},
},
}
org.ID = bson.NewObjectID()
err := validateDeleteAll(org, bson.NilObjectID)
require.Error(t, err)
assert.Contains(t, err.Error(), "account ID cannot be empty")
})
t.Run("DeleteAll_ValidInput", func(t *testing.T) {
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
Describable: model.Describable{Name: "Valid Organization"},
},
}
org.ID = bson.NewObjectID()
accountID := bson.NewObjectID()
err := validateDeleteAll(org, accountID)
require.NoError(t, err)
})
}
// Helper functions that implement the validation logic from the service
func validateDeleteAccount(org *model.Organization) error {
if len(org.Members) <= 1 {
return merrors.InvalidArgument("Cannot delete the only member of an organization")
}
return nil
}
func validateDeleteOrganization(org *model.Organization) error {
if org == nil {
return merrors.InvalidArgument("organization cannot be nil")
}
if org.ID == bson.NilObjectID {
return merrors.InvalidArgument("organization ID cannot be empty")
}
return nil
}
func validateDeleteAll(org *model.Organization, accountRef bson.ObjectID) error {
if org == nil {
return merrors.InvalidArgument("organization cannot be nil")
}
if accountRef == bson.NilObjectID {
return merrors.InvalidArgument("account ID cannot be empty")
}
return nil
}
@@ -4,9 +4,8 @@ import (
"context"
accountserviceimp "git.sendico.io/tech/sxchnge/bff/edge/product/interface/accountservice/internal"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/auth/management"
"git.sendico.io/tech/sxchnge/bff/pkg/db"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
@@ -15,14 +14,6 @@ import (
// AccountService defines all account-related workflows.
type AccountService interface {
// ValidateAccount will:
// 1) check it's completeness
// 2) hash password
// 3) prepare verification token
ValidateAccount(
acct *model.Account,
) error
// ValidatePassword will:
// 1) check passsword conformance
ValidatePassword(
@@ -38,14 +29,11 @@ type AccountService interface {
) (verificationToken string, err error)
// CreateAccount will:
// 1) create the account
// 2) add it to the org’s member list
// 3) assign the given role description to it
// 1) validate and hash the password, then create the account
// 2) issue an activation token
CreateAccount(
ctx context.Context,
org *model.Organization,
acct *model.Account,
roleDescID bson.ObjectID,
) (verificationToken string, err error)
VerifyAccount(
@@ -53,42 +41,15 @@ type AccountService interface {
acct *model.Account,
) (verificationToken string, err error)
JoinOrganization(
ctx context.Context,
org *model.Organization,
acct *model.Account,
roleDescID bson.ObjectID,
) error
UpdateLogin(
ctx context.Context,
acct *model.Account,
newLogin string,
) (verificationToken string, err error)
// DeleteAccount deletes the account and removes it from the org.
// DeleteAccount deletes the local customer account.
DeleteAccount(
ctx context.Context,
org *model.Organization,
accountRef bson.ObjectID,
) error
// RemoveAccountFromOrganization just drops it from the member slice.
RemoveAccountFromOrganization(
ctx context.Context,
org *model.Organization,
accountRef bson.ObjectID,
) error
DeleteOrganization(
ctx context.Context,
org *model.Organization,
) error
// DeleteAll deletes both the organization and the account.
DeleteAll(
ctx context.Context,
org *model.Organization,
accountRef bson.ObjectID,
) error
}
@@ -96,9 +57,8 @@ type AccountService interface {
func NewAccountService(
logger mlogger.Logger,
dbf db.Factory,
enforcer auth.Enforcer,
roleManeger management.Role,
config *middleware.PasswordConfig,
storageConfig *api.AuthStorageConfig,
) (AccountService, error) {
return accountserviceimp.NewAccountService(logger, dbf, enforcer, roleManeger, config)
return accountserviceimp.NewAccountService(logger, dbf, config, storageConfig)
}