diff --git a/internal/api/grpc/auth/password.go b/internal/api/grpc/auth/password.go index 0cbc8d4f61..cd0f85ae69 100644 --- a/internal/api/grpc/auth/password.go +++ b/internal/api/grpc/auth/password.go @@ -11,7 +11,7 @@ import ( func (s *Server) UpdateMyPassword(ctx context.Context, req *auth_pb.UpdateMyPasswordRequest) (*auth_pb.UpdateMyPasswordResponse, error) { ctxData := authz.GetCtxData(ctx) - objectDetails, err := s.command.ChangePassword(ctx, ctxData.ResourceOwner, ctxData.UserID, req.OldPassword, req.NewPassword, "") + objectDetails, err := s.command.ChangePassword(ctx, ctxData.ResourceOwner, ctxData.UserID, req.OldPassword, req.NewPassword) if err != nil { return nil, err } diff --git a/internal/api/grpc/user/v2/password.go b/internal/api/grpc/user/v2/password.go index 119806c4d9..c5e23f920e 100644 --- a/internal/api/grpc/user/v2/password.go +++ b/internal/api/grpc/user/v2/password.go @@ -53,9 +53,9 @@ func (s *Server) SetPassword(ctx context.Context, req *user.SetPasswordRequest) switch v := req.GetVerification().(type) { case *user.SetPasswordRequest_CurrentPassword: - details, err = s.command.ChangePassword(ctx, resourceOwner, req.GetUserId(), v.CurrentPassword, req.GetNewPassword().GetPassword(), "") + details, err = s.command.ChangePassword(ctx, resourceOwner, req.GetUserId(), v.CurrentPassword, req.GetNewPassword().GetPassword()) case *user.SetPasswordRequest_VerificationCode: - details, err = s.command.SetPasswordWithVerifyCode(ctx, resourceOwner, req.GetUserId(), v.VerificationCode, req.GetNewPassword().GetPassword(), "") + details, err = s.command.SetPasswordWithVerifyCode(ctx, resourceOwner, req.GetUserId(), v.VerificationCode, req.GetNewPassword().GetPassword()) case nil: details, err = s.command.SetPassword(ctx, resourceOwner, req.GetUserId(), req.GetNewPassword().GetPassword(), req.GetNewPassword().GetChangeRequired()) default: diff --git a/internal/api/grpc/user/v2/user.go b/internal/api/grpc/user/v2/user.go index d48cbcd6ea..146020cdf3 100644 --- a/internal/api/grpc/user/v2/user.go +++ b/internal/api/grpc/user/v2/user.go @@ -28,7 +28,7 @@ func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest return nil, err } orgID := authz.GetCtxData(ctx).OrgID - if err = s.command.AddHuman(ctx, orgID, human, false); err != nil { + if err = s.command.AddUserHuman(ctx, orgID, human, false, s.userCodeAlg); err != nil { return nil, err } return &user.AddHumanUserResponse{ @@ -113,6 +113,172 @@ func genderToDomain(gender user.Gender) domain.Gender { } } +func (s *Server) UpdateHumanUser(ctx context.Context, req *user.UpdateHumanUserRequest) (_ *user.UpdateHumanUserResponse, err error) { + human, err := UpdateUserRequestToChangeHuman(req) + if err != nil { + return nil, err + } + err = s.command.ChangeUserHuman(ctx, human, s.userCodeAlg) + if err != nil { + return nil, err + } + return &user.UpdateHumanUserResponse{ + Details: object.DomainToDetailsPb(human.Details), + EmailCode: human.EmailCode, + PhoneCode: human.PhoneCode, + }, nil +} + +func (s *Server) LockUser(ctx context.Context, req *user.LockUserRequest) (_ *user.LockUserResponse, err error) { + details, err := s.command.LockUserV2(ctx, req.UserId) + if err != nil { + return nil, err + } + return &user.LockUserResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func (s *Server) UnlockUser(ctx context.Context, req *user.UnlockUserRequest) (_ *user.UnlockUserResponse, err error) { + details, err := s.command.UnlockUserV2(ctx, req.UserId) + if err != nil { + return nil, err + } + return &user.UnlockUserResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func (s *Server) DeactivateUser(ctx context.Context, req *user.DeactivateUserRequest) (_ *user.DeactivateUserResponse, err error) { + details, err := s.command.DeactivateUserV2(ctx, req.UserId) + if err != nil { + return nil, err + } + return &user.DeactivateUserResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func (s *Server) ReactivateUser(ctx context.Context, req *user.ReactivateUserRequest) (_ *user.ReactivateUserResponse, err error) { + details, err := s.command.ReactivateUserV2(ctx, req.UserId) + if err != nil { + return nil, err + } + return &user.ReactivateUserResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func ifNotNilPtr[v, p any](value *v, conv func(v) p) *p { + var pNil *p + if value == nil { + return pNil + } + pVal := conv(*value) + return &pVal +} + +func UpdateUserRequestToChangeHuman(req *user.UpdateHumanUserRequest) (*command.ChangeHuman, error) { + email, err := SetHumanEmailToEmail(req.Email, req.GetUserId()) + if err != nil { + return nil, err + } + return &command.ChangeHuman{ + ID: req.GetUserId(), + Username: req.Username, + Profile: SetHumanProfileToProfile(req.Profile), + Email: email, + Phone: SetHumanPhoneToPhone(req.Phone), + Password: SetHumanPasswordToPassword(req.Password), + }, nil +} + +func SetHumanProfileToProfile(profile *user.SetHumanProfile) *command.Profile { + if profile == nil { + return nil + } + var firstName *string + if profile.GivenName != "" { + firstName = &profile.GivenName + } + var lastName *string + if profile.FamilyName != "" { + lastName = &profile.FamilyName + } + return &command.Profile{ + FirstName: firstName, + LastName: lastName, + NickName: profile.NickName, + DisplayName: profile.DisplayName, + PreferredLanguage: ifNotNilPtr(profile.PreferredLanguage, language.Make), + Gender: ifNotNilPtr(profile.Gender, genderToDomain), + } +} + +func SetHumanEmailToEmail(email *user.SetHumanEmail, userID string) (*command.Email, error) { + if email == nil { + return nil, nil + } + var urlTemplate string + if email.GetSendCode() != nil && email.GetSendCode().UrlTemplate != nil { + urlTemplate = *email.GetSendCode().UrlTemplate + if err := domain.RenderConfirmURLTemplate(io.Discard, urlTemplate, userID, "code", "orgID"); err != nil { + return nil, err + } + } + return &command.Email{ + Address: domain.EmailAddress(email.Email), + Verified: email.GetIsVerified(), + ReturnCode: email.GetReturnCode() != nil, + URLTemplate: urlTemplate, + }, nil +} + +func SetHumanPhoneToPhone(phone *user.SetHumanPhone) *command.Phone { + if phone == nil { + return nil + } + return &command.Phone{ + Number: domain.PhoneNumber(phone.GetPhone()), + Verified: phone.GetIsVerified(), + ReturnCode: phone.GetReturnCode() != nil, + } +} + +func SetHumanPasswordToPassword(password *user.SetPassword) *command.Password { + if password == nil { + return nil + } + var changeRequired bool + var passwordStr *string + if password.GetPassword() != nil { + passwordStr = &password.GetPassword().Password + changeRequired = password.GetPassword().GetChangeRequired() + } + var hash *string + if password.GetHashedPassword() != nil { + hash = &password.GetHashedPassword().Hash + changeRequired = password.GetHashedPassword().GetChangeRequired() + } + var code *string + if password.GetVerificationCode() != "" { + codeT := password.GetVerificationCode() + code = &codeT + } + var oldPassword *string + if password.GetCurrentPassword() != "" { + oldPasswordT := password.GetCurrentPassword() + oldPassword = &oldPasswordT + } + return &command.Password{ + PasswordCode: code, + OldPassword: oldPassword, + Password: passwordStr, + EncodedPasswordHash: hash, + ChangeRequired: changeRequired, + } +} + func (s *Server) AddIDPLink(ctx context.Context, req *user.AddIDPLinkRequest) (_ *user.AddIDPLinkResponse, err error) { orgID := authz.GetCtxData(ctx).OrgID details, err := s.command.AddUserIDPLink(ctx, req.UserId, orgID, &command.AddLink{ @@ -128,6 +294,92 @@ func (s *Server) AddIDPLink(ctx context.Context, req *user.AddIDPLinkRequest) (_ }, nil } +func (s *Server) DeleteUser(ctx context.Context, req *user.DeleteUserRequest) (_ *user.DeleteUserResponse, err error) { + memberships, grants, err := s.removeUserDependencies(ctx, req.GetUserId()) + if err != nil { + return nil, err + } + details, err := s.command.RemoveUserV2(ctx, req.UserId, memberships, grants...) + if err != nil { + return nil, err + } + return &user.DeleteUserResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func (s *Server) removeUserDependencies(ctx context.Context, userID string) ([]*command.CascadingMembership, []string, error) { + userGrantUserQuery, err := query.NewUserGrantUserIDSearchQuery(userID) + if err != nil { + return nil, nil, err + } + grants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{ + Queries: []query.SearchQuery{userGrantUserQuery}, + }, true, true) + if err != nil { + return nil, nil, err + } + membershipsUserQuery, err := query.NewMembershipUserIDQuery(userID) + if err != nil { + return nil, nil, err + } + memberships, err := s.query.Memberships(ctx, &query.MembershipSearchQuery{ + Queries: []query.SearchQuery{membershipsUserQuery}, + }, false) + if err != nil { + return nil, nil, err + } + return cascadingMemberships(memberships.Memberships), userGrantsToIDs(grants.UserGrants), nil +} + +func cascadingMemberships(memberships []*query.Membership) []*command.CascadingMembership { + cascades := make([]*command.CascadingMembership, len(memberships)) + for i, membership := range memberships { + cascades[i] = &command.CascadingMembership{ + UserID: membership.UserID, + ResourceOwner: membership.ResourceOwner, + IAM: cascadingIAMMembership(membership.IAM), + Org: cascadingOrgMembership(membership.Org), + Project: cascadingProjectMembership(membership.Project), + ProjectGrant: cascadingProjectGrantMembership(membership.ProjectGrant), + } + } + return cascades +} + +func cascadingIAMMembership(membership *query.IAMMembership) *command.CascadingIAMMembership { + if membership == nil { + return nil + } + return &command.CascadingIAMMembership{IAMID: membership.IAMID} +} +func cascadingOrgMembership(membership *query.OrgMembership) *command.CascadingOrgMembership { + if membership == nil { + return nil + } + return &command.CascadingOrgMembership{OrgID: membership.OrgID} +} +func cascadingProjectMembership(membership *query.ProjectMembership) *command.CascadingProjectMembership { + if membership == nil { + return nil + } + return &command.CascadingProjectMembership{ProjectID: membership.ProjectID} +} +func cascadingProjectGrantMembership(membership *query.ProjectGrantMembership) *command.CascadingProjectGrantMembership { + if membership == nil { + return nil + } + return &command.CascadingProjectGrantMembership{ProjectID: membership.ProjectID, GrantID: membership.GrantID} +} + +func userGrantsToIDs(userGrants []*query.UserGrant) []string { + converted := make([]string, len(userGrants)) + for i, grant := range userGrants { + converted[i] = grant.ID + } + return converted +} + func (s *Server) StartIdentityProviderIntent(ctx context.Context, req *user.StartIdentityProviderIntentRequest) (_ *user.StartIdentityProviderIntentResponse, err error) { switch t := req.GetContent().(type) { case *user.StartIdentityProviderIntentRequest_Urls: diff --git a/internal/api/grpc/user/v2/user_integration_test.go b/internal/api/grpc/user/v2/user_integration_test.go index b9408eb74b..56dfc47f38 100644 --- a/internal/api/grpc/user/v2/user_integration_test.go +++ b/internal/api/grpc/user/v2/user_integration_test.go @@ -540,6 +540,896 @@ func TestServer_AddHumanUser(t *testing.T) { if tt.want.GetEmailCode() != "" { assert.NotEmpty(t, got.GetEmailCode()) } + if tt.want.GetPhoneCode() != "" { + assert.NotEmpty(t, got.GetPhoneCode()) + } + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_UpdateHumanUser(t *testing.T) { + type args struct { + ctx context.Context + req *user.UpdateHumanUserRequest + } + tests := []struct { + name string + prepare func(request *user.UpdateHumanUserRequest) error + args args + want *user.UpdateHumanUserResponse + wantErr bool + }{ + { + name: "not exisiting", + prepare: func(request *user.UpdateHumanUserRequest) error { + request.UserId = "notexisiting" + return nil + }, + args: args{ + CTX, + &user.UpdateHumanUserRequest{ + Username: gu.Ptr("changed"), + }, + }, + wantErr: true, + }, + { + name: "change username, ok", + prepare: func(request *user.UpdateHumanUserRequest) error { + userID := Tester.CreateHumanUser(CTX).GetUserId() + request.UserId = userID + return nil + }, + args: args{ + CTX, + &user.UpdateHumanUserRequest{ + Username: gu.Ptr(fmt.Sprint(time.Now().UnixNano() + 1)), + }, + }, + want: &user.UpdateHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "change profile, ok", + prepare: func(request *user.UpdateHumanUserRequest) error { + userID := Tester.CreateHumanUser(CTX).GetUserId() + request.UserId = userID + return nil + }, + args: args{ + CTX, + &user.UpdateHumanUserRequest{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + NickName: gu.Ptr("Dukkie"), + DisplayName: gu.Ptr("Donald Duck"), + PreferredLanguage: gu.Ptr("en"), + Gender: user.Gender_GENDER_DIVERSE.Enum(), + }, + }, + }, + want: &user.UpdateHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "change email, ok", + prepare: func(request *user.UpdateHumanUserRequest) error { + userID := Tester.CreateHumanUser(CTX).GetUserId() + request.UserId = userID + return nil + }, + args: args{ + CTX, + &user.UpdateHumanUserRequest{ + Email: &user.SetHumanEmail{ + Email: "changed@test.com", + Verification: &user.SetHumanEmail_IsVerified{IsVerified: true}, + }, + }, + }, + want: &user.UpdateHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "change email, code, ok", + prepare: func(request *user.UpdateHumanUserRequest) error { + userID := Tester.CreateHumanUser(CTX).GetUserId() + request.UserId = userID + return nil + }, + args: args{ + CTX, + &user.UpdateHumanUserRequest{ + Email: &user.SetHumanEmail{ + Email: "changed@test.com", + Verification: &user.SetHumanEmail_ReturnCode{}, + }, + }, + }, + want: &user.UpdateHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + EmailCode: gu.Ptr("something"), + }, + }, + { + name: "change phone, ok", + prepare: func(request *user.UpdateHumanUserRequest) error { + userID := Tester.CreateHumanUser(CTX).GetUserId() + request.UserId = userID + return nil + }, + args: args{ + CTX, + &user.UpdateHumanUserRequest{ + Phone: &user.SetHumanPhone{ + Phone: "+41791234567", + Verification: &user.SetHumanPhone_IsVerified{IsVerified: true}, + }, + }, + }, + want: &user.UpdateHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "change phone, code, ok", + prepare: func(request *user.UpdateHumanUserRequest) error { + userID := Tester.CreateHumanUser(CTX).GetUserId() + request.UserId = userID + return nil + }, + args: args{ + CTX, + &user.UpdateHumanUserRequest{ + Phone: &user.SetHumanPhone{ + Phone: "+41791234568", + Verification: &user.SetHumanPhone_ReturnCode{}, + }, + }, + }, + want: &user.UpdateHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + PhoneCode: gu.Ptr("something"), + }, + }, + { + name: "change password, code, ok", + prepare: func(request *user.UpdateHumanUserRequest) error { + userID := Tester.CreateHumanUser(CTX).GetUserId() + request.UserId = userID + resp, err := Client.PasswordReset(CTX, &user.PasswordResetRequest{ + UserId: userID, + Medium: &user.PasswordResetRequest_ReturnCode{ + ReturnCode: &user.ReturnPasswordResetCode{}, + }, + }) + if err != nil { + return err + } + request.Password.Verification = &user.SetPassword_VerificationCode{ + VerificationCode: resp.GetVerificationCode(), + } + return nil + }, + args: args{ + CTX, + &user.UpdateHumanUserRequest{ + Password: &user.SetPassword{ + PasswordType: &user.SetPassword_Password{ + Password: &user.Password{ + Password: "Password1!", + ChangeRequired: true, + }, + }, + }, + }, + }, + want: &user.UpdateHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "change hashed password, code, ok", + prepare: func(request *user.UpdateHumanUserRequest) error { + userID := Tester.CreateHumanUser(CTX).GetUserId() + request.UserId = userID + resp, err := Client.PasswordReset(CTX, &user.PasswordResetRequest{ + UserId: userID, + Medium: &user.PasswordResetRequest_ReturnCode{ + ReturnCode: &user.ReturnPasswordResetCode{}, + }, + }) + if err != nil { + return err + } + request.Password.Verification = &user.SetPassword_VerificationCode{ + VerificationCode: resp.GetVerificationCode(), + } + return nil + }, + args: args{ + CTX, + &user.UpdateHumanUserRequest{ + Password: &user.SetPassword{ + PasswordType: &user.SetPassword_HashedPassword{ + HashedPassword: &user.HashedPassword{ + Hash: "$2y$12$hXUrnqdq1RIIYZ2HPytIIe5lXdIvbhqrTvdPsSF7o.jFh817Z6lwm", + }, + }, + }, + }, + }, + want: &user.UpdateHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "change hashed password, code, not supported", + prepare: func(request *user.UpdateHumanUserRequest) error { + userID := Tester.CreateHumanUser(CTX).GetUserId() + request.UserId = userID + resp, err := Client.PasswordReset(CTX, &user.PasswordResetRequest{ + UserId: userID, + Medium: &user.PasswordResetRequest_ReturnCode{ + ReturnCode: &user.ReturnPasswordResetCode{}, + }, + }) + if err != nil { + return err + } + request.Password = &user.SetPassword{ + Verification: &user.SetPassword_VerificationCode{ + VerificationCode: resp.GetVerificationCode(), + }, + } + return nil + }, + args: args{ + CTX, + &user.UpdateHumanUserRequest{ + Password: &user.SetPassword{ + PasswordType: &user.SetPassword_HashedPassword{ + HashedPassword: &user.HashedPassword{ + Hash: "$scrypt$ln=16,r=8,p=1$cmFuZG9tc2FsdGlzaGFyZA$Rh+NnJNo1I6nRwaNqbDm6kmADswD1+7FTKZ7Ln9D8nQ", + }, + }, + }, + }, + }, + wantErr: true, + }, + { + name: "change password, old password, ok", + prepare: func(request *user.UpdateHumanUserRequest) error { + userID := Tester.CreateHumanUser(CTX).GetUserId() + request.UserId = userID + + resp, err := Client.PasswordReset(CTX, &user.PasswordResetRequest{ + UserId: userID, + Medium: &user.PasswordResetRequest_ReturnCode{ + ReturnCode: &user.ReturnPasswordResetCode{}, + }, + }) + if err != nil { + return err + } + pw := "Password1." + _, err = Client.SetPassword(CTX, &user.SetPasswordRequest{ + UserId: userID, + NewPassword: &user.Password{ + Password: pw, + ChangeRequired: true, + }, + Verification: &user.SetPasswordRequest_VerificationCode{ + VerificationCode: resp.GetVerificationCode(), + }, + }) + if err != nil { + return err + } + request.Password.Verification = &user.SetPassword_CurrentPassword{ + CurrentPassword: pw, + } + return nil + }, + args: args{ + CTX, + &user.UpdateHumanUserRequest{ + Password: &user.SetPassword{ + PasswordType: &user.SetPassword_Password{ + Password: &user.Password{ + Password: "Password1!", + ChangeRequired: true, + }, + }, + }, + }, + }, + want: &user.UpdateHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.prepare(tt.args.req) + require.NoError(t, err) + + got, err := Client.UpdateHumanUser(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + if tt.want.GetEmailCode() != "" { + assert.NotEmpty(t, got.GetEmailCode()) + } + if tt.want.GetPhoneCode() != "" { + assert.NotEmpty(t, got.GetPhoneCode()) + } + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_LockUser(t *testing.T) { + type args struct { + ctx context.Context + req *user.LockUserRequest + prepare func(request *user.LockUserRequest) error + } + tests := []struct { + name string + args args + want *user.LockUserResponse + wantErr bool + }{ + { + name: "lock, not existing", + args: args{ + CTX, + &user.LockUserRequest{ + UserId: "notexisting", + }, + func(request *user.LockUserRequest) error { return nil }, + }, + wantErr: true, + }, + { + name: "lock, ok", + args: args{ + CTX, + &user.LockUserRequest{}, + func(request *user.LockUserRequest) error { + resp := Tester.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + return nil + }, + }, + want: &user.LockUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "lock machine, ok", + args: args{ + CTX, + &user.LockUserRequest{}, + func(request *user.LockUserRequest) error { + resp := Tester.CreateMachineUser(CTX) + request.UserId = resp.GetUserId() + return nil + }, + }, + want: &user.LockUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "lock, already locked", + args: args{ + CTX, + &user.LockUserRequest{}, + func(request *user.LockUserRequest) error { + resp := Tester.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + _, err := Client.LockUser(CTX, &user.LockUserRequest{ + UserId: resp.GetUserId(), + }) + return err + }, + }, + wantErr: true, + }, + { + name: "lock machine, already locked", + args: args{ + CTX, + &user.LockUserRequest{}, + func(request *user.LockUserRequest) error { + resp := Tester.CreateMachineUser(CTX) + request.UserId = resp.GetUserId() + _, err := Client.LockUser(CTX, &user.LockUserRequest{ + UserId: resp.GetUserId(), + }) + return err + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + + got, err := Client.LockUser(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_UnLockUser(t *testing.T) { + type args struct { + ctx context.Context + req *user.UnlockUserRequest + prepare func(request *user.UnlockUserRequest) error + } + tests := []struct { + name string + args args + want *user.UnlockUserResponse + wantErr bool + }{ + { + name: "unlock, not existing", + args: args{ + CTX, + &user.UnlockUserRequest{ + UserId: "notexisting", + }, + func(request *user.UnlockUserRequest) error { return nil }, + }, + wantErr: true, + }, + { + name: "unlock, not locked", + args: args{ + ctx: CTX, + req: &user.UnlockUserRequest{}, + prepare: func(request *user.UnlockUserRequest) error { + resp := Tester.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + return nil + }, + }, + wantErr: true, + }, + { + name: "unlock machine, not locked", + args: args{ + ctx: CTX, + req: &user.UnlockUserRequest{}, + prepare: func(request *user.UnlockUserRequest) error { + resp := Tester.CreateMachineUser(CTX) + request.UserId = resp.GetUserId() + return nil + }, + }, + wantErr: true, + }, + { + name: "unlock, ok", + args: args{ + ctx: CTX, + req: &user.UnlockUserRequest{}, + prepare: func(request *user.UnlockUserRequest) error { + resp := Tester.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + _, err := Client.LockUser(CTX, &user.LockUserRequest{ + UserId: resp.GetUserId(), + }) + return err + }, + }, + want: &user.UnlockUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "unlock machine, ok", + args: args{ + ctx: CTX, + req: &user.UnlockUserRequest{}, + prepare: func(request *user.UnlockUserRequest) error { + resp := Tester.CreateMachineUser(CTX) + request.UserId = resp.GetUserId() + _, err := Client.LockUser(CTX, &user.LockUserRequest{ + UserId: resp.GetUserId(), + }) + return err + }, + }, + want: &user.UnlockUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + + got, err := Client.UnlockUser(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_DeactivateUser(t *testing.T) { + type args struct { + ctx context.Context + req *user.DeactivateUserRequest + prepare func(request *user.DeactivateUserRequest) error + } + tests := []struct { + name string + args args + want *user.DeactivateUserResponse + wantErr bool + }{ + { + name: "deactivate, not existing", + args: args{ + CTX, + &user.DeactivateUserRequest{ + UserId: "notexisting", + }, + func(request *user.DeactivateUserRequest) error { return nil }, + }, + wantErr: true, + }, + { + name: "deactivate, ok", + args: args{ + CTX, + &user.DeactivateUserRequest{}, + func(request *user.DeactivateUserRequest) error { + resp := Tester.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + return nil + }, + }, + want: &user.DeactivateUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "deactivate machine, ok", + args: args{ + CTX, + &user.DeactivateUserRequest{}, + func(request *user.DeactivateUserRequest) error { + resp := Tester.CreateMachineUser(CTX) + request.UserId = resp.GetUserId() + return nil + }, + }, + want: &user.DeactivateUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "deactivate, already deactivated", + args: args{ + CTX, + &user.DeactivateUserRequest{}, + func(request *user.DeactivateUserRequest) error { + resp := Tester.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + _, err := Client.DeactivateUser(CTX, &user.DeactivateUserRequest{ + UserId: resp.GetUserId(), + }) + return err + }, + }, + wantErr: true, + }, + { + name: "deactivate machine, already deactivated", + args: args{ + CTX, + &user.DeactivateUserRequest{}, + func(request *user.DeactivateUserRequest) error { + resp := Tester.CreateMachineUser(CTX) + request.UserId = resp.GetUserId() + _, err := Client.DeactivateUser(CTX, &user.DeactivateUserRequest{ + UserId: resp.GetUserId(), + }) + return err + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + + got, err := Client.DeactivateUser(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_ReactivateUser(t *testing.T) { + type args struct { + ctx context.Context + req *user.ReactivateUserRequest + prepare func(request *user.ReactivateUserRequest) error + } + tests := []struct { + name string + args args + want *user.ReactivateUserResponse + wantErr bool + }{ + { + name: "reactivate, not existing", + args: args{ + CTX, + &user.ReactivateUserRequest{ + UserId: "notexisting", + }, + func(request *user.ReactivateUserRequest) error { return nil }, + }, + wantErr: true, + }, + { + name: "reactivate, not deactivated", + args: args{ + ctx: CTX, + req: &user.ReactivateUserRequest{}, + prepare: func(request *user.ReactivateUserRequest) error { + resp := Tester.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + return nil + }, + }, + wantErr: true, + }, + { + name: "reactivate machine, not deactivated", + args: args{ + ctx: CTX, + req: &user.ReactivateUserRequest{}, + prepare: func(request *user.ReactivateUserRequest) error { + resp := Tester.CreateMachineUser(CTX) + request.UserId = resp.GetUserId() + return nil + }, + }, + wantErr: true, + }, + { + name: "reactivate, ok", + args: args{ + ctx: CTX, + req: &user.ReactivateUserRequest{}, + prepare: func(request *user.ReactivateUserRequest) error { + resp := Tester.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + _, err := Client.DeactivateUser(CTX, &user.DeactivateUserRequest{ + UserId: resp.GetUserId(), + }) + return err + }, + }, + want: &user.ReactivateUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "reactivate machine, ok", + args: args{ + ctx: CTX, + req: &user.ReactivateUserRequest{}, + prepare: func(request *user.ReactivateUserRequest) error { + resp := Tester.CreateMachineUser(CTX) + request.UserId = resp.GetUserId() + _, err := Client.DeactivateUser(CTX, &user.DeactivateUserRequest{ + UserId: resp.GetUserId(), + }) + return err + }, + }, + want: &user.ReactivateUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + + got, err := Client.ReactivateUser(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_DeleteUser(t *testing.T) { + projectResp, err := Tester.CreateProject(CTX) + require.NoError(t, err) + type args struct { + ctx context.Context + req *user.DeleteUserRequest + prepare func(request *user.DeleteUserRequest) error + } + tests := []struct { + name string + args args + want *user.DeleteUserResponse + wantErr bool + }{ + { + name: "remove, not existing", + args: args{ + CTX, + &user.DeleteUserRequest{ + UserId: "notexisting", + }, + func(request *user.DeleteUserRequest) error { return nil }, + }, + wantErr: true, + }, + { + name: "remove human, ok", + args: args{ + ctx: CTX, + req: &user.DeleteUserRequest{}, + prepare: func(request *user.DeleteUserRequest) error { + resp := Tester.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + return err + }, + }, + want: &user.DeleteUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "remove machine, ok", + args: args{ + ctx: CTX, + req: &user.DeleteUserRequest{}, + prepare: func(request *user.DeleteUserRequest) error { + resp := Tester.CreateMachineUser(CTX) + request.UserId = resp.GetUserId() + return err + }, + }, + want: &user.DeleteUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "remove dependencies, ok", + args: args{ + ctx: CTX, + req: &user.DeleteUserRequest{}, + prepare: func(request *user.DeleteUserRequest) error { + resp := Tester.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + Tester.CreateProjectUserGrant(t, CTX, projectResp.GetId(), request.UserId) + Tester.CreateProjectMembership(t, CTX, projectResp.GetId(), request.UserId) + Tester.CreateOrgMembership(t, CTX, request.UserId) + return err + }, + }, + want: &user.DeleteUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + + got, err := Client.DeleteUser(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } integration.AssertDetails(t, tt.want, got) }) } diff --git a/internal/api/ui/login/change_password_handler.go b/internal/api/ui/login/change_password_handler.go index e85b99dcd5..ecbbf41028 100644 --- a/internal/api/ui/login/change_password_handler.go +++ b/internal/api/ui/login/change_password_handler.go @@ -4,8 +4,6 @@ import ( "net/http" "github.com/zitadel/zitadel/internal/domain" - - http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" ) const ( @@ -26,8 +24,7 @@ func (l *Login) handleChangePassword(w http.ResponseWriter, r *http.Request) { l.renderError(w, r, authReq, err) return } - userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context()) - _, err = l.command.ChangePassword(setContext(r.Context(), authReq.UserOrgID), authReq.UserOrgID, authReq.UserID, data.OldPassword, data.NewPassword, userAgentID) + _, err = l.command.ChangePassword(setContext(r.Context(), authReq.UserOrgID), authReq.UserOrgID, authReq.UserID, data.OldPassword, data.NewPassword) if err != nil { l.renderChangePassword(w, r, authReq, err) return diff --git a/internal/api/ui/login/init_password_handler.go b/internal/api/ui/login/init_password_handler.go index f177099703..c0af8880a2 100644 --- a/internal/api/ui/login/init_password_handler.go +++ b/internal/api/ui/login/init_password_handler.go @@ -4,7 +4,6 @@ import ( "fmt" "net/http" - http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -72,8 +71,7 @@ func (l *Login) checkPWCode(w http.ResponseWriter, r *http.Request, authReq *dom if authReq != nil { userOrg = authReq.UserOrgID } - userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context()) - _, err := l.command.SetPasswordWithVerifyCode(setContext(r.Context(), userOrg), userOrg, data.UserID, data.Code, data.Password, userAgentID) + _, err := l.command.SetPasswordWithVerifyCode(setContext(r.Context(), userOrg), userOrg, data.UserID, data.Code, data.Password) if err != nil { l.renderInitPassword(w, r, authReq, data.UserID, "", err) return diff --git a/internal/command/instance_policy_domain.go b/internal/command/instance_policy_domain.go index 59c3c06914..969bc219fe 100644 --- a/internal/command/instance_policy_domain.go +++ b/internal/command/instance_policy_domain.go @@ -44,7 +44,7 @@ func (c *Commands) ChangeDefaultDomainPolicy(ctx context.Context, userLoginMustB } func (c *Commands) getDefaultDomainPolicy(ctx context.Context) (*domain.DomainPolicy, error) { - policyWriteModel, err := c.defaultDomainPolicyWriteModelByID(ctx) + policyWriteModel, err := c.instanceDomainPolicyWriteModel(ctx) if err != nil { return nil, err } @@ -56,7 +56,7 @@ func (c *Commands) getDefaultDomainPolicy(ctx context.Context) (*domain.DomainPo return policy, nil } -func (c *Commands) defaultDomainPolicyWriteModelByID(ctx context.Context) (policy *InstanceDomainPolicyWriteModel, err error) { +func (c *Commands) instanceDomainPolicyWriteModel(ctx context.Context) (policy *InstanceDomainPolicyWriteModel, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() diff --git a/internal/command/org.go b/internal/command/org.go index f6c9e11644..5f997183af 100644 --- a/internal/command/org.go +++ b/internal/command/org.go @@ -453,7 +453,7 @@ func (c *Commands) prepareRemoveOrg(a *org.Aggregate) preparation.Validation { return nil, zerrors.ThrowNotFound(nil, "COMMA-aps2n", "Errors.Org.NotFound") } - domainPolicy, err := c.getOrgDomainPolicy(ctx, a.ID) + domainPolicy, err := c.domainPolicyWriteModel(ctx, a.ID) if err != nil { return nil, err } diff --git a/internal/command/org_policy_domain.go b/internal/command/org_policy_domain.go index 32cbd7389f..f4e4b59a93 100644 --- a/internal/command/org_policy_domain.go +++ b/internal/command/org_policy_domain.go @@ -59,18 +59,19 @@ func (c *Commands) RemoveOrgDomainPolicy(ctx context.Context, orgID string) (*do return pushedEventsToObjectDetails(pushedEvents), nil } +// Deprecated: Use commands.domainPolicyWriteModel directly, to remove the domain.DomainPolicy struct func (c *Commands) getOrgDomainPolicy(ctx context.Context, orgID string) (*domain.DomainPolicy, error) { - policy, err := c.orgDomainPolicyWriteModelByID(ctx, orgID) + policy, err := c.orgDomainPolicyWriteModel(ctx, orgID) if err != nil { return nil, err } - if policy.State == domain.PolicyStateActive { + if policy.State.Exists() { return orgWriteModelToDomainPolicy(policy), nil } return c.getDefaultDomainPolicy(ctx) } -func (c *Commands) orgDomainPolicyWriteModelByID(ctx context.Context, orgID string) (policy *OrgDomainPolicyWriteModel, err error) { +func (c *Commands) orgDomainPolicyWriteModel(ctx context.Context, orgID string) (policy *OrgDomainPolicyWriteModel, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() diff --git a/internal/command/unique_constraints_model.go b/internal/command/unique_constraints_model.go index 9b71b0e7a1..a02a3e18fc 100644 --- a/internal/command/unique_constraints_model.go +++ b/internal/command/unique_constraints_model.go @@ -26,7 +26,7 @@ type UniqueConstraintReadModel struct { } type commandProvider interface { - getOrgDomainPolicy(ctx context.Context, orgID string) (*domain.DomainPolicy, error) + domainPolicyWriteModel(ctx context.Context, orgID string) (*PolicyDomainWriteModel, error) } func NewUniqueConstraintReadModel(ctx context.Context, provider commandProvider) *UniqueConstraintReadModel { @@ -114,21 +114,21 @@ func (rm *UniqueConstraintReadModel) Reduce() error { case *project.RoleRemovedEvent: rm.removeUniqueConstraint(e.Aggregate().ID, e.Key, project.UniqueRoleType) case *user.HumanAddedEvent: - policy, err := rm.commandProvider.getOrgDomainPolicy(rm.ctx, e.Aggregate().ResourceOwner) + policy, err := rm.commandProvider.domainPolicyWriteModel(rm.ctx, e.Aggregate().ResourceOwner) if err != nil { logging.Log("COMMAND-0k9Gs").WithError(err).Error("could not read policy for human added event unique constraint") continue } rm.addUniqueConstraint(e.Aggregate().ID, e.Aggregate().ID, user.NewAddUsernameUniqueConstraint(e.UserName, e.Aggregate().ResourceOwner, policy.UserLoginMustBeDomain)) case *user.HumanRegisteredEvent: - policy, err := rm.commandProvider.getOrgDomainPolicy(rm.ctx, e.Aggregate().ResourceOwner) + policy, err := rm.commandProvider.domainPolicyWriteModel(rm.ctx, e.Aggregate().ResourceOwner) if err != nil { logging.Log("COMMAND-m9fod").WithError(err).Error("could not read policy for human registered event unique constraint") continue } rm.addUniqueConstraint(e.Aggregate().ID, e.Aggregate().ID, user.NewAddUsernameUniqueConstraint(e.UserName, e.Aggregate().ResourceOwner, policy.UserLoginMustBeDomain)) case *user.MachineAddedEvent: - policy, err := rm.commandProvider.getOrgDomainPolicy(rm.ctx, e.Aggregate().ResourceOwner) + policy, err := rm.commandProvider.domainPolicyWriteModel(rm.ctx, e.Aggregate().ResourceOwner) if err != nil { logging.Log("COMMAND-2n8vs").WithError(err).Error("could not read policy for machine added event unique constraint") continue @@ -138,14 +138,14 @@ func (rm *UniqueConstraintReadModel) Reduce() error { rm.removeUniqueConstraint(e.Aggregate().ID, e.Aggregate().ID, user.UniqueUsername) rm.listRemoveUniqueConstraint(e.Aggregate().ID, user.UniqueUserIDPLinkType) case *user.UsernameChangedEvent: - policy, err := rm.commandProvider.getOrgDomainPolicy(rm.ctx, e.Aggregate().ResourceOwner) + policy, err := rm.commandProvider.domainPolicyWriteModel(rm.ctx, e.Aggregate().ResourceOwner) if err != nil { logging.Log("COMMAND-5n8gk").WithError(err).Error("could not read policy for username changed event unique constraint") continue } rm.changeUniqueConstraint(e.Aggregate().ID, e.Aggregate().ID, user.NewAddUsernameUniqueConstraint(e.UserName, e.Aggregate().ResourceOwner, policy.UserLoginMustBeDomain)) case *user.DomainClaimedEvent: - policy, err := rm.commandProvider.getOrgDomainPolicy(rm.ctx, e.Aggregate().ResourceOwner) + policy, err := rm.commandProvider.domainPolicyWriteModel(rm.ctx, e.Aggregate().ResourceOwner) if err != nil { logging.Log("COMMAND-xb8uf").WithError(err).Error("could not read policy for domain claimed event unique constraint") continue diff --git a/internal/command/user.go b/internal/command/user.go index 929c5b9d6c..19047cf2bc 100644 --- a/internal/command/user.go +++ b/internal/command/user.go @@ -38,7 +38,7 @@ func (c *Commands) ChangeUsername(ctx context.Context, orgID, userID, userName s return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-6m9gs", "Errors.User.UsernameNotChanged") } - domainPolicy, err := c.getOrgDomainPolicy(ctx, orgID) + domainPolicy, err := c.domainPolicyWriteModel(ctx, orgID) if err != nil { return nil, zerrors.ThrowPreconditionFailed(err, "COMMAND-38fnu", "Errors.Org.DomainPolicy.NotExisting") } @@ -196,7 +196,7 @@ func (c *Commands) RemoveUser(ctx context.Context, userID, resourceOwner string, return nil, zerrors.ThrowNotFound(nil, "COMMAND-m9od", "Errors.User.NotFound") } - domainPolicy, err := c.getOrgDomainPolicy(ctx, existingUser.ResourceOwner) + domainPolicy, err := c.domainPolicyWriteModel(ctx, existingUser.ResourceOwner) if err != nil { return nil, zerrors.ThrowPreconditionFailed(err, "COMMAND-3M9fs", "Errors.Org.DomainPolicy.NotExisting") } @@ -330,7 +330,7 @@ func (c *Commands) userDomainClaimed(ctx context.Context, userID string) (events changedUserGrant := NewUserWriteModel(userID, existingUser.ResourceOwner) userAgg := UserAggregateFromWriteModel(&changedUserGrant.WriteModel) - domainPolicy, err := c.getOrgDomainPolicy(ctx, existingUser.ResourceOwner) + domainPolicy, err := c.domainPolicyWriteModel(ctx, existingUser.ResourceOwner) if err != nil { return nil, nil, err } diff --git a/internal/command/user_domain_policy.go b/internal/command/user_domain_policy.go index deda7cacb5..2226608ff1 100644 --- a/internal/command/user_domain_policy.go +++ b/internal/command/user_domain_policy.go @@ -7,6 +7,7 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) +// Deprecated: User commands.domainPolicyWriteModel directly, to remove use of eventstore.Filter function func domainPolicyWriteModel(ctx context.Context, filter preparation.FilterToQueryReducer, orgID string) (*PolicyDomainWriteModel, error) { wm, err := orgDomainPolicy(ctx, filter, orgID) if err != nil { @@ -25,6 +26,25 @@ func domainPolicyWriteModel(ctx context.Context, filter preparation.FilterToQuer return nil, zerrors.ThrowInternal(nil, "USER-Ggk9n", "Errors.Internal") } +func (c *Commands) domainPolicyWriteModel(ctx context.Context, orgID string) (*PolicyDomainWriteModel, error) { + wm, err := c.orgDomainPolicyWriteModel(ctx, orgID) + if err != nil { + return nil, err + } + if wm != nil && wm.State.Exists() { + return &wm.PolicyDomainWriteModel, err + } + instanceWriteModel, err := c.instanceDomainPolicyWriteModel(ctx) + if err != nil { + return nil, err + } + if instanceWriteModel != nil && instanceWriteModel.State.Exists() { + return &instanceWriteModel.PolicyDomainWriteModel, err + } + return nil, zerrors.ThrowInternal(nil, "USER-Ggk9n", "Errors.Internal") +} + +// Deprecated: Use commands.orgDomainPolicyWriteModel directly, to remove use of eventstore.Filter function func orgDomainPolicy(ctx context.Context, filter preparation.FilterToQueryReducer, orgID string) (*OrgDomainPolicyWriteModel, error) { policy := NewOrgDomainPolicyWriteModel(orgID) events, err := filter(ctx, policy.Query()) @@ -39,6 +59,7 @@ func orgDomainPolicy(ctx context.Context, filter preparation.FilterToQueryReduce return policy, err } +// Deprecated: Use commands.instanceDomainPolicyWriteModel directly, to remove use of eventstore.Filter function func instanceDomainPolicy(ctx context.Context, filter preparation.FilterToQueryReducer) (*InstanceDomainPolicyWriteModel, error) { policy := NewInstanceDomainPolicyWriteModel(ctx) events, err := filter(ctx, policy.Query()) diff --git a/internal/command/user_human.go b/internal/command/user_human.go index d4512fcc04..f8993ae6b4 100644 --- a/internal/command/user_human.go +++ b/internal/command/user_human.go @@ -127,6 +127,7 @@ func (m *AddMetadataEntry) Valid() error { return nil } +// Deprecated: use commands.AddUserHuman func (c *Commands) AddHuman(ctx context.Context, resourceOwner string, human *AddHuman, allowInitMail bool) (err error) { if resourceOwner == "" { return zerrors.ThrowInvalidArgument(nil, "COMMA-5Ky74", "Errors.Internal") @@ -180,7 +181,7 @@ func (c *Commands) AddHumanCommand(human *AddHuman, orgID string, hasher *crypto return nil, err } - if err = userValidateDomain(ctx, a, human.Username, domainPolicy.UserLoginMustBeDomain, filter); err != nil { + if err = c.userValidateDomain(ctx, a.ResourceOwner, human.Username, domainPolicy.UserLoginMustBeDomain); err != nil { return nil, err } @@ -310,6 +311,7 @@ func (c *Commands) addHumanCommandPhone(ctx context.Context, filter preparation. return append(cmds, user.NewHumanPhoneCodeAddedEventV2(ctx, &a.Aggregate, phoneCode.Crypted, phoneCode.Expiry, human.Phone.ReturnCode)), nil } +// Deprecated: use commands.NewUserHumanWriteModel, to remove deprecated eventstore.Filter func (c *Commands) addHumanCommandCheckID(ctx context.Context, filter preparation.FilterToQueryReducer, human *AddHuman, orgID string) (err error) { if human.ID == "" { human.ID, err = c.idGenerator.Next() @@ -347,7 +349,7 @@ func addHumanCommandPassword(ctx context.Context, filter preparation.FilterToQue return nil } -func userValidateDomain(ctx context.Context, a *user.Aggregate, username string, mustBeDomain bool, filter preparation.FilterToQueryReducer) error { +func (c *Commands) userValidateDomain(ctx context.Context, resourceOwner string, username string, mustBeDomain bool) error { if mustBeDomain { return nil } @@ -357,17 +359,12 @@ func userValidateDomain(ctx context.Context, a *user.Aggregate, username string, return nil } - domainCheck := NewOrgDomainVerifiedWriteModel(username[index+1:]) - events, err := filter(ctx, domainCheck.Query()) + domainCheck, err := c.orgDomainVerifiedWriteModel(ctx, username[index+1:]) if err != nil { return err } - domainCheck.AppendEvents(events...) - if err = domainCheck.Reduce(); err != nil { - return err - } - if domainCheck.Verified && domainCheck.ResourceOwner != a.ResourceOwner { + if domainCheck.Verified && domainCheck.ResourceOwner != resourceOwner { return zerrors.ThrowInvalidArgument(nil, "COMMAND-SFd21", "Errors.User.DomainNotAllowedAsUsername") } @@ -411,6 +408,7 @@ func (h *AddHuman) shouldAddInitCode() bool { h.Password == "" } +// Deprecated: use commands.AddUserHuman func (c *Commands) ImportHuman(ctx context.Context, orgID string, human *domain.Human, passwordless bool, links []*domain.UserIDPLink, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator, passwordlessCodeGenerator crypto.Generator) (_ *domain.Human, passwordlessCode *domain.PasswordlessInitCode, err error) { if orgID == "" { return nil, nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-5N8fs", "Errors.ResourceOwnerMissing") @@ -459,6 +457,7 @@ func (c *Commands) ImportHuman(ctx context.Context, orgID string, human *domain. return writeModelToHuman(addedHuman), passwordlessCode, nil } +// Deprecated: use commands.AddUserHuman func (c *Commands) RegisterHuman(ctx context.Context, orgID string, human *domain.Human, link *domain.UserIDPLink, orgMemberRoles []string, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator crypto.Generator) (*domain.Human, error) { if orgID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-GEdf2", "Errors.ResourceOwnerMissing") diff --git a/internal/command/user_human_init.go b/internal/command/user_human_init.go index 837bd3e905..4770332704 100644 --- a/internal/command/user_human_init.go +++ b/internal/command/user_human_init.go @@ -80,9 +80,7 @@ func (c *Commands) HumanVerifyInitCode(ctx context.Context, userID, resourceOwne commands = append(commands, user.NewHumanEmailVerifiedEvent(ctx, userAgg)) } if password != "" { - passwordWriteModel := NewHumanPasswordWriteModel(userID, existingCode.ResourceOwner) - passwordWriteModel.UserState = domain.UserStateActive - passwordCommand, err := c.setPasswordCommand(ctx, passwordWriteModel, password, false) + passwordCommand, err := c.setPasswordCommand(ctx, userAgg, domain.UserStateActive, password, false, false) if err != nil { return err } diff --git a/internal/command/user_human_otp.go b/internal/command/user_human_otp.go index c6692526af..38f360649d 100644 --- a/internal/command/user_human_otp.go +++ b/internal/command/user_human_otp.go @@ -78,7 +78,7 @@ func (c *Commands) createHumanTOTP(ctx context.Context, userID, resourceOwner st logging.WithError(err).WithField("traceID", tracing.TraceIDFromCtx(ctx)).Debug("unable to get org for loginname") return nil, zerrors.ThrowPreconditionFailed(err, "COMMAND-55M9f", "Errors.Org.NotFound") } - orgPolicy, err := c.getOrgDomainPolicy(ctx, org.AggregateID) + orgPolicy, err := c.domainPolicyWriteModel(ctx, org.AggregateID) if err != nil { logging.WithError(err).WithField("traceID", tracing.TraceIDFromCtx(ctx)).Debug("unable to get org policy for loginname") return nil, zerrors.ThrowPreconditionFailed(err, "COMMAND-8ugTs", "Errors.Org.DomainPolicy.NotFound") diff --git a/internal/command/user_human_password.go b/internal/command/user_human_password.go index 1ccacabdfa..251ffe83fb 100644 --- a/internal/command/user_human_password.go +++ b/internal/command/user_human_password.go @@ -34,7 +34,7 @@ func (c *Commands) SetPassword(ctx context.Context, orgID, userID, password stri return c.setPassword(ctx, wm, password, oneTime) } -func (c *Commands) SetPasswordWithVerifyCode(ctx context.Context, orgID, userID, code, password, userAgentID string) (objectDetails *domain.ObjectDetails, err error) { +func (c *Commands) SetPasswordWithVerifyCode(ctx context.Context, orgID, userID, code, password string) (objectDetails *domain.ObjectDetails, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -61,8 +61,10 @@ func (c *Commands) SetPasswordWithVerifyCode(ctx context.Context, orgID, userID, return c.setPassword(ctx, wm, password, false) } -func (c *Commands) setPassword(ctx context.Context, wm *HumanPasswordWriteModel, password string, changeRequired bool) (objectDetails *domain.ObjectDetails, err error) { - command, err := c.setPasswordCommand(ctx, wm, password, changeRequired) +// setEncodedPassword add change event from already encoded password to HumanPasswordWriteModel and return the necessary object details for response +func (c *Commands) setEncodedPassword(ctx context.Context, wm *HumanPasswordWriteModel, password string, changeRequired bool) (objectDetails *domain.ObjectDetails, err error) { + agg := user.NewAggregate(wm.AggregateID, wm.ResourceOwner) + command, err := c.setPasswordCommand(ctx, &agg.Aggregate, wm.UserState, password, changeRequired, true) if err != nil { return nil, err } @@ -73,20 +75,39 @@ func (c *Commands) setPassword(ctx context.Context, wm *HumanPasswordWriteModel, return writeModelToObjectDetails(&wm.WriteModel), nil } -func (c *Commands) setPasswordCommand(ctx context.Context, wm *HumanPasswordWriteModel, password string, changeRequired bool) (_ eventstore.Command, err error) { - if err = c.canUpdatePassword(ctx, password, wm); err != nil { +// setPassword add change event to HumanPasswordWriteModel and return the necessary object details for response +func (c *Commands) setPassword(ctx context.Context, wm *HumanPasswordWriteModel, password string, changeRequired bool) (objectDetails *domain.ObjectDetails, err error) { + agg := user.NewAggregate(wm.AggregateID, wm.ResourceOwner) + command, err := c.setPasswordCommand(ctx, &agg.Aggregate, wm.UserState, password, changeRequired, false) + if err != nil { return nil, err } - ctx, span := tracing.NewNamedSpan(ctx, "passwap.Hash") - encoded, err := c.userPasswordHasher.Hash(password) - span.EndWithError(err) - if err = convertPasswapErr(err); err != nil { + err = c.pushAppendAndReduce(ctx, wm, command) + if err != nil { return nil, err } - return user.NewHumanPasswordChangedEvent(ctx, UserAggregateFromWriteModel(&wm.WriteModel), encoded, changeRequired, ""), nil + return writeModelToObjectDetails(&wm.WriteModel), nil } -func (c *Commands) ChangePassword(ctx context.Context, orgID, userID, oldPassword, newPassword, userAgentID string) (objectDetails *domain.ObjectDetails, err error) { +func (c *Commands) setPasswordCommand(ctx context.Context, agg *eventstore.Aggregate, userState domain.UserState, password string, changeRequired, encoded bool) (_ eventstore.Command, err error) { + if err = c.canUpdatePassword(ctx, password, agg.ResourceOwner, userState); err != nil { + return nil, err + } + + if !encoded { + ctx, span := tracing.NewNamedSpan(ctx, "passwap.Hash") + encodedPassword, err := c.userPasswordHasher.Hash(password) + span.EndWithError(err) + if err = convertPasswapErr(err); err != nil { + return nil, err + } + return user.NewHumanPasswordChangedEvent(ctx, agg, encodedPassword, changeRequired, ""), nil + } + return user.NewHumanPasswordChangedEvent(ctx, agg, password, changeRequired, ""), nil +} + +// ChangePassword change password of existing user +func (c *Commands) ChangePassword(ctx context.Context, orgID, userID, oldPassword, newPassword string) (objectDetails *domain.ObjectDetails, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -100,38 +121,39 @@ func (c *Commands) ChangePassword(ctx context.Context, orgID, userID, oldPasswor if err != nil { return nil, err } - if wm.EncodedHash == "" { - return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Fds3s", "Errors.User.Password.Empty") - } - if err = c.canUpdatePassword(ctx, newPassword, wm); err != nil { - return nil, err - } - ctx, spanPasswap := tracing.NewNamedSpan(ctx, "passwap.VerifyAndUpdate") - updated, err := c.userPasswordHasher.VerifyAndUpdate(wm.EncodedHash, oldPassword, newPassword) - spanPasswap.EndWithError(err) - if err = convertPasswapErr(err); err != nil { - return nil, err - } - err = c.pushAppendAndReduce(ctx, wm, - user.NewHumanPasswordChangedEvent(ctx, UserAggregateFromWriteModel(&wm.WriteModel), updated, false, userAgentID)) + newPasswordHash, err := c.verifyAndUpdatePassword(ctx, wm.EncodedHash, oldPassword, newPassword) if err != nil { return nil, err } - return writeModelToObjectDetails(&wm.WriteModel), nil + return c.setEncodedPassword(ctx, wm, newPasswordHash, false) } -func (c *Commands) canUpdatePassword(ctx context.Context, newPassword string, wm *HumanPasswordWriteModel) (err error) { +// verifyAndUpdatePassword verify if the old password is correct with the encoded hash and +// returns the hash of the new password if so +func (c *Commands) verifyAndUpdatePassword(ctx context.Context, encodedHash, oldPassword, newPassword string) (string, error) { + if encodedHash == "" { + return "", zerrors.ThrowPreconditionFailed(nil, "COMMAND-Fds3s", "Errors.User.Password.NotSet") + } + + _, spanPasswap := tracing.NewNamedSpan(ctx, "passwap.Verify") + updated, err := c.userPasswordHasher.VerifyAndUpdate(encodedHash, oldPassword, newPassword) + spanPasswap.EndWithError(err) + return updated, convertPasswapErr(err) +} + +// canUpdatePassword checks uf the given password can be used to be the password of a user +func (c *Commands) canUpdatePassword(ctx context.Context, newPassword string, resourceOwner string, state domain.UserState) (err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if wm.UserState == domain.UserStateUnspecified || wm.UserState == domain.UserStateDeleted { + if !isUserStateExists(state) { return zerrors.ThrowNotFound(nil, "COMMAND-G8dh3", "Errors.User.Password.NotFound") } - if wm.UserState == domain.UserStateInitial { + if state == domain.UserStateInitial { return zerrors.ThrowPreconditionFailed(nil, "COMMAND-M9dse", "Errors.User.NotInitialised") } - policy, err := c.getOrgPasswordComplexityPolicy(ctx, wm.ResourceOwner) + policy, err := c.getOrgPasswordComplexityPolicy(ctx, resourceOwner) if err != nil { return err } @@ -142,6 +164,7 @@ func (c *Commands) canUpdatePassword(ctx context.Context, newPassword string, wm return nil } +// RequestSetPassword generate and send out new code to change password for a specific user func (c *Commands) RequestSetPassword(ctx context.Context, userID, resourceOwner string, notifyType domain.NotificationType, passwordVerificationCode crypto.Generator) (objectDetails *domain.ObjectDetails, err error) { if userID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-M00oL", "Errors.User.UserIDMissing") @@ -151,7 +174,7 @@ func (c *Commands) RequestSetPassword(ctx context.Context, userID, resourceOwner if err != nil { return nil, err } - if existingHuman.UserState == domain.UserStateUnspecified || existingHuman.UserState == domain.UserStateDeleted { + if !isUserStateExists(existingHuman.UserState) { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Hj9ds", "Errors.User.NotFound") } if existingHuman.UserState == domain.UserStateInitial { @@ -173,6 +196,7 @@ func (c *Commands) RequestSetPassword(ctx context.Context, userID, resourceOwner return writeModelToObjectDetails(&existingHuman.WriteModel), nil } +// PasswordCodeSent notification send with code to change password func (c *Commands) PasswordCodeSent(ctx context.Context, orgID, userID string) (err error) { if userID == "" { return zerrors.ThrowInvalidArgument(nil, "COMMAND-meEfe", "Errors.User.UserIDMissing") @@ -190,6 +214,7 @@ func (c *Commands) PasswordCodeSent(ctx context.Context, orgID, userID string) ( return err } +// PasswordChangeSent notification sent that user changed his password func (c *Commands) PasswordChangeSent(ctx context.Context, orgID, userID string) (err error) { if userID == "" { return zerrors.ThrowInvalidArgument(nil, "COMMAND-pqlm2n", "Errors.User.UserIDMissing") @@ -207,6 +232,7 @@ func (c *Commands) PasswordChangeSent(ctx context.Context, orgID, userID string) return err } +// HumanCheckPassword check password for user with additional informations from authRequest func (c *Commands) HumanCheckPassword(ctx context.Context, orgID, userID, password string, authRequest *domain.AuthRequest, lockoutPolicy *domain.LockoutPolicy) (err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -230,13 +256,13 @@ func (c *Commands) HumanCheckPassword(ctx context.Context, orgID, userID, passwo if err != nil { return err } - if wm.UserState == domain.UserStateUnspecified || wm.UserState == domain.UserStateDeleted { + + if !isUserStateExists(wm.UserState) { return zerrors.ThrowPreconditionFailed(nil, "COMMAND-3n77z", "Errors.User.NotFound") } if wm.UserState == domain.UserStateLocked { return zerrors.ThrowPreconditionFailed(nil, "COMMAND-JLK35", "Errors.User.Locked") } - if wm.EncodedHash == "" { return zerrors.ThrowPreconditionFailed(nil, "COMMAND-3nJ4t", "Errors.User.Password.NotSet") } diff --git a/internal/command/user_human_password_test.go b/internal/command/user_human_password_test.go index d112cf7d97..0bb9e613ae 100644 --- a/internal/command/user_human_password_test.go +++ b/internal/command/user_human_password_test.go @@ -278,7 +278,6 @@ func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) { code string resourceOwner string password string - agentID string } type res struct { want *domain.ObjectDetails @@ -505,7 +504,7 @@ func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) { userPasswordHasher: tt.fields.userPasswordHasher, userEncryption: tt.fields.userEncryption, } - got, err := r.SetPasswordWithVerifyCode(tt.args.ctx, tt.args.resourceOwner, tt.args.userID, tt.args.code, tt.args.password, tt.args.agentID) + got, err := r.SetPasswordWithVerifyCode(tt.args.ctx, tt.args.resourceOwner, tt.args.userID, tt.args.code, tt.args.password) if tt.res.err == nil { assert.NoError(t, err) } @@ -529,7 +528,6 @@ func TestCommandSide_ChangePassword(t *testing.T) { resourceOwner string oldPassword string newPassword string - agentID string } type res struct { want *domain.ObjectDetails @@ -675,18 +673,6 @@ func TestCommandSide_ChangePassword(t *testing.T) { false, "")), ), - expectFilter( - eventFromEventPusher( - org.NewPasswordComplexityPolicyAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, - 1, - false, - false, - false, - false, - ), - ), - ), }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -766,7 +752,7 @@ func TestCommandSide_ChangePassword(t *testing.T) { eventstore: eventstoreExpect(t, tt.expect...), userPasswordHasher: tt.fields.userPasswordHasher, } - got, err := r.ChangePassword(tt.args.ctx, tt.args.resourceOwner, tt.args.userID, tt.args.oldPassword, tt.args.newPassword, tt.args.agentID) + got, err := r.ChangePassword(tt.args.ctx, tt.args.resourceOwner, tt.args.userID, tt.args.oldPassword, tt.args.newPassword) if tt.res.err == nil { assert.NoError(t, err) } diff --git a/internal/command/user_human_test.go b/internal/command/user_human_test.go index fa0149404f..009c0ec994 100644 --- a/internal/command/user_human_test.go +++ b/internal/command/user_human_test.go @@ -4266,6 +4266,17 @@ func TestCommandSide_HumanSignOut(t *testing.T) { } } +func newAddMachineEvent(userLoginMustBeDomain bool, accessTokenType domain.OIDCTokenType) *user.MachineAddedEvent { + return user.NewMachineAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "name", + "description", + userLoginMustBeDomain, + accessTokenType, + ) +} + func newAddHumanEvent(password string, changeRequired, userLoginMustBeDomain bool, phone string, preferredLanguage language.Tag) *user.HumanAddedEvent { event := user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, diff --git a/internal/command/user_human_webauthn.go b/internal/command/user_human_webauthn.go index 6bb61bfc5c..3328e95455 100644 --- a/internal/command/user_human_webauthn.go +++ b/internal/command/user_human_webauthn.go @@ -149,7 +149,7 @@ func (c *Commands) addHumanWebAuthN(ctx context.Context, userID, resourceowner, if err != nil { return nil, nil, nil, err } - orgPolicy, err := c.getOrgDomainPolicy(ctx, org.AggregateID) + orgPolicy, err := c.domainPolicyWriteModel(ctx, org.AggregateID) if err != nil { return nil, nil, nil, err } diff --git a/internal/command/user_v2.go b/internal/command/user_v2.go new file mode 100644 index 0000000000..032ac0b8f7 --- /dev/null +++ b/internal/command/user_v2.go @@ -0,0 +1,213 @@ +package command + +import ( + "context" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func (c *Commands) LockUserV2(ctx context.Context, userID string) (*domain.ObjectDetails, error) { + if userID == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-agz3eczifm", "Errors.User.UserIDMissing") + } + + existingHuman, err := c.userStateWriteModel(ctx, userID) + if err != nil { + return nil, err + } + if !isUserStateExists(existingHuman.UserState) { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-450yxuqrh1", "Errors.User.NotFound") + } + if !hasUserState(existingHuman.UserState, domain.UserStateActive, domain.UserStateInitial) { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-lgws8wtsqf", "Errors.User.ShouldBeActiveOrInitial") + } + + if err := c.checkPermissionUpdateUser(ctx, existingHuman.ResourceOwner, existingHuman.AggregateID); err != nil { + return nil, err + } + + if err := c.pushAppendAndReduce(ctx, existingHuman, user.NewUserLockedEvent(ctx, &existingHuman.Aggregate().Aggregate)); err != nil { + return nil, err + } + return writeModelToObjectDetails(&existingHuman.WriteModel), nil +} + +func (c *Commands) UnlockUserV2(ctx context.Context, userID string) (*domain.ObjectDetails, error) { + if userID == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-a9ld4xckax", "Errors.User.UserIDMissing") + } + + existingHuman, err := c.userStateWriteModel(ctx, userID) + if err != nil { + return nil, err + } + if !isUserStateExists(existingHuman.UserState) { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-x377t913pw", "Errors.User.NotFound") + } + if !hasUserState(existingHuman.UserState, domain.UserStateLocked) { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-olb9vb0oca", "Errors.User.NotLocked") + } + if err := c.checkPermissionUpdateUser(ctx, existingHuman.ResourceOwner, existingHuman.AggregateID); err != nil { + return nil, err + } + + if err := c.pushAppendAndReduce(ctx, existingHuman, user.NewUserUnlockedEvent(ctx, &existingHuman.Aggregate().Aggregate)); err != nil { + return nil, err + } + return writeModelToObjectDetails(&existingHuman.WriteModel), nil +} + +func (c *Commands) DeactivateUserV2(ctx context.Context, userID string) (*domain.ObjectDetails, error) { + if userID == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-78iiirat8y", "Errors.User.UserIDMissing") + } + + existingHuman, err := c.userStateWriteModel(ctx, userID) + if err != nil { + return nil, err + } + if !isUserStateExists(existingHuman.UserState) { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-5gp2p62iin", "Errors.User.NotFound") + } + if isUserStateInitial(existingHuman.UserState) { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-gvx4kct9r2", "Errors.User.CantDeactivateInitial") + } + if isUserStateInactive(existingHuman.UserState) { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-5gunjw0cd7", "Errors.User.AlreadyInactive") + } + if err := c.checkPermissionUpdateUser(ctx, existingHuman.ResourceOwner, existingHuman.AggregateID); err != nil { + return nil, err + } + + if err := c.pushAppendAndReduce(ctx, existingHuman, user.NewUserDeactivatedEvent(ctx, &existingHuman.Aggregate().Aggregate)); err != nil { + return nil, err + } + return writeModelToObjectDetails(&existingHuman.WriteModel), nil +} + +func (c *Commands) ReactivateUserV2(ctx context.Context, userID string) (*domain.ObjectDetails, error) { + if userID == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-0nx1ie38fw", "Errors.User.UserIDMissing") + } + + existingHuman, err := c.userStateWriteModel(ctx, userID) + if err != nil { + return nil, err + } + if !isUserStateExists(existingHuman.UserState) { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-9hy5kzbuk6", "Errors.User.NotFound") + } + if !isUserStateInactive(existingHuman.UserState) { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-s5qqcz97hf", "Errors.User.NotInactive") + } + if err := c.checkPermissionUpdateUser(ctx, existingHuman.ResourceOwner, existingHuman.AggregateID); err != nil { + return nil, err + } + + if err := c.pushAppendAndReduce(ctx, existingHuman, user.NewUserReactivatedEvent(ctx, &existingHuman.Aggregate().Aggregate)); err != nil { + return nil, err + } + return writeModelToObjectDetails(&existingHuman.WriteModel), nil +} + +func (c *Commands) checkPermissionUpdateUser(ctx context.Context, resourceOwner, userID string) error { + if userID != "" && userID == authz.GetCtxData(ctx).UserID { + return nil + } + if err := c.checkPermission(ctx, domain.PermissionUserWrite, resourceOwner, userID); err != nil { + return err + } + return nil +} + +func (c *Commands) checkPermissionDeleteUser(ctx context.Context, resourceOwner, userID string) error { + if userID != "" && userID == authz.GetCtxData(ctx).UserID { + return nil + } + if err := c.checkPermission(ctx, domain.PermissionUserDelete, resourceOwner, userID); err != nil { + return err + } + return nil +} + +func (c *Commands) userStateWriteModel(ctx context.Context, userID string) (writeModel *UserV2WriteModel, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + writeModel = NewUserStateWriteModel(userID, "") + err = c.eventstore.FilterToQueryReducer(ctx, writeModel) + if err != nil { + return nil, err + } + return writeModel, nil +} + +func (c *Commands) RemoveUserV2(ctx context.Context, userID string, cascadingUserMemberships []*CascadingMembership, cascadingGrantIDs ...string) (*domain.ObjectDetails, error) { + if userID == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-vaipl7s13l", "Errors.User.UserIDMissing") + } + + existingUser, err := c.userRemoveWriteModel(ctx, userID) + if err != nil { + return nil, err + } + if !isUserStateExists(existingUser.UserState) { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-bd4ir1mblj", "Errors.User.NotFound") + } + if err := c.checkPermissionDeleteUser(ctx, existingUser.ResourceOwner, existingUser.AggregateID); err != nil { + return nil, err + } + + domainPolicy, err := c.domainPolicyWriteModel(ctx, existingUser.ResourceOwner) + if err != nil { + return nil, zerrors.ThrowPreconditionFailed(err, "COMMAND-l40ykb3xh2", "Errors.Org.DomainPolicy.NotExisting") + } + var events []eventstore.Command + events = append(events, user.NewUserRemovedEvent(ctx, &existingUser.Aggregate().Aggregate, existingUser.UserName, existingUser.IDPLinks, domainPolicy.UserLoginMustBeDomain)) + + for _, grantID := range cascadingGrantIDs { + removeEvent, _, err := c.removeUserGrant(ctx, grantID, "", true) + if err != nil { + logging.WithFields("usergrantid", grantID).WithError(err).Warn("could not cascade remove role on user grant") + continue + } + events = append(events, removeEvent) + } + + if len(cascadingUserMemberships) > 0 { + membershipEvents, err := c.removeUserMemberships(ctx, cascadingUserMemberships) + if err != nil { + return nil, err + } + events = append(events, membershipEvents...) + } + + pushedEvents, err := c.eventstore.Push(ctx, events...) + if err != nil { + return nil, err + } + err = AppendAndReduce(existingUser, pushedEvents...) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&existingUser.WriteModel), nil +} + +func (c *Commands) userRemoveWriteModel(ctx context.Context, userID string) (writeModel *UserV2WriteModel, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + writeModel = NewUserRemoveWriteModel(userID, "") + err = c.eventstore.FilterToQueryReducer(ctx, writeModel) + if err != nil { + return nil, err + } + return writeModel, nil +} diff --git a/internal/command/user_v2_email.go b/internal/command/user_v2_email.go index e1c30063a1..3f1b4439e9 100644 --- a/internal/command/user_v2_email.go +++ b/internal/command/user_v2_email.go @@ -67,6 +67,14 @@ func (c *Commands) changeUserEmailWithCode(ctx context.Context, userID, resource // When the plain text code is returned, no notification e-mail will be send to the user. // urlTmpl allows changing the target URL that is used by the e-mail and should be a validated Go template, if used. func (c *Commands) changeUserEmailWithGenerator(ctx context.Context, userID, resourceOwner, email string, gen crypto.Generator, returnCode bool, urlTmpl string) (*domain.Email, error) { + cmd, err := c.changeUserEmailWithGeneratorEvents(ctx, userID, resourceOwner, email, gen, returnCode, urlTmpl) + if err != nil { + return nil, err + } + return cmd.Push(ctx) +} + +func (c *Commands) changeUserEmailWithGeneratorEvents(ctx context.Context, userID, resourceOwner, email string, gen crypto.Generator, returnCode bool, urlTmpl string) (*UserEmailEvents, error) { cmd, err := c.NewUserEmailEvents(ctx, userID, resourceOwner) if err != nil { return nil, err @@ -82,7 +90,7 @@ func (c *Commands) changeUserEmailWithGenerator(ctx context.Context, userID, res if err = cmd.AddGeneratedCode(ctx, gen, urlTmpl, returnCode); err != nil { return nil, err } - return cmd.Push(ctx) + return cmd, nil } func (c *Commands) VerifyUserEmail(ctx context.Context, userID, resourceOwner, code string, alg crypto.EncryptionAlgorithm) (*domain.ObjectDetails, error) { @@ -167,18 +175,30 @@ func (c *UserEmailEvents) SetVerified(ctx context.Context) { // AddGeneratedCode generates a new encrypted code and sets it to the email address. // When returnCode a plain text of the code will be returned from Push. func (c *UserEmailEvents) AddGeneratedCode(ctx context.Context, gen crypto.Generator, urlTmpl string, returnCode bool) error { - value, plain, err := crypto.NewCode(gen) + cmd, code, err := generateCodeCommand(ctx, c.aggregate, gen, urlTmpl, returnCode) if err != nil { return err } - - c.events = append(c.events, user.NewHumanEmailCodeAddedEventV2(ctx, c.aggregate, value, gen.Expiry(), urlTmpl, returnCode)) + c.events = append(c.events, cmd) if returnCode { - c.plainCode = &plain + c.plainCode = &code } return nil } +func generateCodeCommand(ctx context.Context, agg *eventstore.Aggregate, gen crypto.Generator, urlTmpl string, returnCode bool) (eventstore.Command, string, error) { + value, plain, err := crypto.NewCode(gen) + if err != nil { + return nil, "", err + } + + cmd := user.NewHumanEmailCodeAddedEventV2(ctx, agg, value, gen.Expiry(), urlTmpl, returnCode) + if returnCode { + return cmd, plain, nil + } + return cmd, "", nil +} + func (c *UserEmailEvents) VerifyCode(ctx context.Context, code string, gen crypto.Generator) error { if code == "" { return zerrors.ThrowInvalidArgument(nil, "COMMAND-Fia4a", "Errors.User.Code.Empty") diff --git a/internal/command/user_v2_human.go b/internal/command/user_v2_human.go new file mode 100644 index 0000000000..c0a61b5a30 --- /dev/null +++ b/internal/command/user_v2_human.go @@ -0,0 +1,457 @@ +package command + +import ( + "context" + + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type ChangeHuman struct { + ID string + Username *string + Profile *Profile + Email *Email + Phone *Phone + + Password *Password + + // Details are set after a successful execution of the command + Details *domain.ObjectDetails + + // EmailCode is set by the command + EmailCode *string + + // PhoneCode is set by the command + PhoneCode *string +} + +type Profile struct { + FirstName *string + LastName *string + NickName *string + DisplayName *string + PreferredLanguage *language.Tag + Gender *domain.Gender +} + +type Password struct { + // Either you have to have permission, a password code or the old password to change + PasswordCode *string + OldPassword *string + Password *string + EncodedPasswordHash *string + + ChangeRequired bool +} + +func (h *ChangeHuman) Validate(hasher *crypto.PasswordHasher) (err error) { + if h.Email != nil && h.Email.Address != "" { + if err := h.Email.Validate(); err != nil { + return err + } + } + + if h.Phone != nil && h.Phone.Number != "" { + if h.Phone.Number, err = h.Phone.Number.Normalize(); err != nil { + return err + } + } + + if h.Password != nil { + if err := h.Password.Validate(hasher); err != nil { + return err + } + } + return nil +} + +func (p *Password) Validate(hasher *crypto.PasswordHasher) error { + if p.EncodedPasswordHash != nil { + if !hasher.EncodingSupported(*p.EncodedPasswordHash) { + return zerrors.ThrowInvalidArgument(nil, "USER-oz74onzvqr", "Errors.User.Password.NotSupported") + } + } + if p.Password == nil && p.EncodedPasswordHash == nil { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-3klek4sbns", "Errors.User.Password.Empty") + } + return nil +} + +func (h *ChangeHuman) Changed() bool { + if h.Username != nil { + return true + } + if h.Profile != nil { + return true + } + if h.Email != nil { + return true + } + if h.Phone != nil { + return true + } + if h.Password != nil { + return true + } + return false +} + +func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human *AddHuman, allowInitMail bool, alg crypto.EncryptionAlgorithm) (err error) { + if resourceOwner == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMA-095xh8fll1", "Errors.Internal") + } + + if err := human.Validate(c.userPasswordHasher); err != nil { + return err + } + + if human.ID == "" { + human.ID, err = c.idGenerator.Next() + if err != nil { + return err + } + } + + // only check if user is already existing + existingHuman, err := c.userExistsWriteModel( + ctx, + human.ID, + ) + if err != nil { + return err + } + if isUserStateExists(existingHuman.UserState) { + return zerrors.ThrowPreconditionFailed(nil, "COMMAND-7yiox1isql", "Errors.User.AlreadyExisting") + } + // check for permission to create user on resourceOwner + if err := c.checkPermission(ctx, domain.PermissionUserWrite, resourceOwner, human.ID); err != nil { + return err + } + // add resourceowner for the events with the aggregate + existingHuman.ResourceOwner = resourceOwner + + domainPolicy, err := c.domainPolicyWriteModel(ctx, resourceOwner) + if err != nil { + return err + } + + if err = c.userValidateDomain(ctx, resourceOwner, human.Username, domainPolicy.UserLoginMustBeDomain); err != nil { + return err + } + var createCmd humanCreationCommand + if human.Register { + createCmd = user.NewHumanRegisteredEvent( + ctx, + &existingHuman.Aggregate().Aggregate, + human.Username, + human.FirstName, + human.LastName, + human.NickName, + human.DisplayName, + human.PreferredLanguage, + human.Gender, + human.Email.Address, + domainPolicy.UserLoginMustBeDomain, + ) + } else { + createCmd = user.NewHumanAddedEvent( + ctx, + &existingHuman.Aggregate().Aggregate, + human.Username, + human.FirstName, + human.LastName, + human.NickName, + human.DisplayName, + human.PreferredLanguage, + human.Gender, + human.Email.Address, + domainPolicy.UserLoginMustBeDomain, + ) + } + + if human.Phone.Number != "" { + createCmd.AddPhoneData(human.Phone.Number) + } + + // separated to change when old user logic is not used anymore + filter := c.eventstore.Filter //nolint:staticcheck + if err := addHumanCommandPassword(ctx, filter, createCmd, human, c.userPasswordHasher); err != nil { + return err + } + + cmds := make([]eventstore.Command, 0, 3) + cmds = append(cmds, createCmd) + + cmds, err = c.addHumanCommandEmail(ctx, filter, cmds, existingHuman.Aggregate(), human, alg, allowInitMail) + if err != nil { + return err + } + + cmds, err = c.addHumanCommandPhone(ctx, filter, cmds, existingHuman.Aggregate(), human, alg) + if err != nil { + return err + } + + for _, metadataEntry := range human.Metadata { + cmds = append(cmds, user.NewMetadataSetEvent( + ctx, + &existingHuman.Aggregate().Aggregate, + metadataEntry.Key, + metadataEntry.Value, + )) + } + for _, link := range human.Links { + cmd, err := addLink(ctx, filter, existingHuman.Aggregate(), link) + if err != nil { + return err + } + cmds = append(cmds, cmd) + } + + if len(cmds) == 0 { + human.Details = writeModelToObjectDetails(&existingHuman.WriteModel) + return nil + } + + err = c.pushAppendAndReduce(ctx, existingHuman, cmds...) + if err != nil { + return err + } + human.Details = writeModelToObjectDetails(&existingHuman.WriteModel) + return nil +} + +func (c *Commands) ChangeUserHuman(ctx context.Context, human *ChangeHuman, alg crypto.EncryptionAlgorithm) (err error) { + if err := human.Validate(c.userPasswordHasher); err != nil { + return err + } + + existingHuman, err := c.userHumanWriteModel( + ctx, + human.ID, + human.Profile != nil, + human.Email != nil, + human.Phone != nil, + human.Password != nil, + false, // avatar not updateable + false, // IDPLinks not updateable + ) + if err != nil { + return err + } + if !isUserStateExists(existingHuman.UserState) { + return zerrors.ThrowNotFound(nil, "COMMAND-ugjs0upun6", "Errors.User.NotFound") + } + + if human.Changed() { + if err := c.checkPermissionUpdateUser(ctx, existingHuman.ResourceOwner, existingHuman.AggregateID); err != nil { + return err + } + } + + cmds := make([]eventstore.Command, 0) + if human.Username != nil { + cmds, err = c.changeUsername(ctx, cmds, existingHuman, *human.Username) + if err != nil { + return err + } + } + if human.Profile != nil { + cmds, err = changeUserProfile(ctx, cmds, existingHuman, human.Profile) + if err != nil { + return err + } + } + if human.Email != nil { + cmds, human.EmailCode, err = c.changeUserEmail(ctx, cmds, existingHuman, human.Email, alg) + if err != nil { + return err + } + } + if human.Phone != nil { + cmds, human.PhoneCode, err = c.changeUserPhone(ctx, cmds, existingHuman, human.Phone, alg) + if err != nil { + return err + } + } + if human.Password != nil { + cmds, err = c.changeUserPassword(ctx, cmds, existingHuman, human.Password, alg) + if err != nil { + return err + } + } + + if len(cmds) == 0 { + human.Details = writeModelToObjectDetails(&existingHuman.WriteModel) + return nil + } + err = c.pushAppendAndReduce(ctx, existingHuman, cmds...) + if err != nil { + return err + } + human.Details = writeModelToObjectDetails(&existingHuman.WriteModel) + return nil +} + +func (c *Commands) changeUserEmail(ctx context.Context, cmds []eventstore.Command, wm *UserV2WriteModel, email *Email, alg crypto.EncryptionAlgorithm) (_ []eventstore.Command, code *string, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.End() }() + + if email.Address != "" && email.Address != wm.Email { + cmds = append(cmds, user.NewHumanEmailChangedEvent(ctx, &wm.Aggregate().Aggregate, email.Address)) + + if email.Verified { + return append(cmds, user.NewHumanEmailVerifiedEvent(ctx, &wm.Aggregate().Aggregate)), code, nil + } else { + cryptoCode, err := c.newEmailCode(ctx, c.eventstore.Filter, alg) //nolint:staticcheck + if err != nil { + return cmds, code, err + } + cmds = append(cmds, user.NewHumanEmailCodeAddedEventV2(ctx, &wm.Aggregate().Aggregate, cryptoCode.Crypted, cryptoCode.Expiry, email.URLTemplate, email.ReturnCode)) + if email.ReturnCode { + code = &cryptoCode.Plain + } + return cmds, code, nil + } + } + // only create separate event of verified if email was not changed + if email.Verified && wm.IsEmailVerified != email.Verified { + return append(cmds, user.NewHumanEmailVerifiedEvent(ctx, &wm.Aggregate().Aggregate)), nil, nil + } + return cmds, code, nil +} + +func (c *Commands) changeUserPhone(ctx context.Context, cmds []eventstore.Command, wm *UserV2WriteModel, phone *Phone, alg crypto.EncryptionAlgorithm) (_ []eventstore.Command, code *string, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.End() }() + + if phone.Number != "" && phone.Number != wm.Phone { + cmds = append(cmds, user.NewHumanPhoneChangedEvent(ctx, &wm.Aggregate().Aggregate, phone.Number)) + + if phone.Verified { + return append(cmds, user.NewHumanPhoneVerifiedEvent(ctx, &wm.Aggregate().Aggregate)), code, nil + } else { + cryptoCode, err := c.newPhoneCode(ctx, c.eventstore.Filter, alg) //nolint:staticcheck + if err != nil { + return cmds, code, err + } + cmds = append(cmds, user.NewHumanPhoneCodeAddedEventV2(ctx, &wm.Aggregate().Aggregate, cryptoCode.Crypted, cryptoCode.Expiry, phone.ReturnCode)) + if phone.ReturnCode { + code = &cryptoCode.Plain + } + return cmds, code, nil + } + } + // only create separate event of verified if email was not changed + if phone.Verified && wm.IsPhoneVerified != phone.Verified { + return append(cmds, user.NewHumanPhoneVerifiedEvent(ctx, &wm.Aggregate().Aggregate)), code, nil + } + return cmds, code, nil +} + +func changeUserProfile(ctx context.Context, cmds []eventstore.Command, wm *UserV2WriteModel, profile *Profile) ([]eventstore.Command, error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.End() }() + + cmd, err := wm.NewProfileChangedEvent(ctx, profile.FirstName, profile.LastName, profile.NickName, profile.DisplayName, profile.PreferredLanguage, profile.Gender) + if cmd != nil { + return append(cmds, cmd), err + } + return cmds, err +} + +func (c *Commands) changeUserPassword(ctx context.Context, cmds []eventstore.Command, wm *UserV2WriteModel, password *Password, alg crypto.EncryptionAlgorithm) ([]eventstore.Command, error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.End() }() + + // Either have a code to set the password + if password.PasswordCode != nil { + if err := crypto.VerifyCodeWithAlgorithm(wm.PasswordCodeCreationDate, wm.PasswordCodeExpiry, wm.PasswordCode, *password.PasswordCode, alg); err != nil { + return cmds, err + } + } + var encodedPassword string + // or have the old password to change it + if password.OldPassword != nil { + // newly encode old password if no new and already encoded password is set + pw := *password.OldPassword + if password.Password != nil { + pw = *password.Password + } + alreadyEncodedPassword, err := c.verifyAndUpdatePassword(ctx, wm.PasswordEncodedHash, *password.OldPassword, pw) + if err != nil { + return cmds, err + } + encodedPassword = alreadyEncodedPassword + } + + // password already hashed in request + if password.EncodedPasswordHash != nil { + cmd, err := c.setPasswordCommand(ctx, &wm.Aggregate().Aggregate, wm.UserState, *password.EncodedPasswordHash, password.ChangeRequired, true) + if cmd != nil { + return append(cmds, cmd), err + } + return cmds, err + } + // password already hashed in verify + if encodedPassword != "" { + cmd, err := c.setPasswordCommand(ctx, &wm.Aggregate().Aggregate, wm.UserState, encodedPassword, password.ChangeRequired, true) + if cmd != nil { + return append(cmds, cmd), err + } + return cmds, err + } + // password still to be hashed + if password.Password != nil { + cmd, err := c.setPasswordCommand(ctx, &wm.Aggregate().Aggregate, wm.UserState, *password.Password, password.ChangeRequired, false) + if cmd != nil { + return append(cmds, cmd), err + } + return cmds, err + } + // no password changes necessary + return cmds, nil +} + +func (c *Commands) userExistsWriteModel(ctx context.Context, userID string) (writeModel *UserV2WriteModel, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + writeModel = NewUserExistsWriteModel(userID, "") + err = c.eventstore.FilterToQueryReducer(ctx, writeModel) + if err != nil { + return nil, err + } + return writeModel, nil +} + +func (c *Commands) userHumanWriteModel(ctx context.Context, userID string, profileWM, emailWM, phoneWM, passwordWM, avatarWM, idpLinksWM bool) (writeModel *UserV2WriteModel, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + writeModel = NewUserHumanWriteModel(userID, "", profileWM, emailWM, phoneWM, passwordWM, avatarWM, idpLinksWM) + err = c.eventstore.FilterToQueryReducer(ctx, writeModel) + if err != nil { + return nil, err + } + return writeModel, nil +} + +func (c *Commands) orgDomainVerifiedWriteModel(ctx context.Context, domain string) (writeModel *OrgDomainVerifiedWriteModel, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + writeModel = NewOrgDomainVerifiedWriteModel(domain) + err = c.eventstore.FilterToQueryReducer(ctx, writeModel) + if err != nil { + return nil, err + } + return writeModel, nil +} diff --git a/internal/command/user_v2_human_test.go b/internal/command/user_v2_human_test.go new file mode 100644 index 0000000000..e0f99034bb --- /dev/null +++ b/internal/command/user_v2_human_test.go @@ -0,0 +1,2568 @@ +package command + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/id" + id_mock "github.com/zitadel/zitadel/internal/id/mock" + "github.com/zitadel/zitadel/internal/repository/org" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestCommandSide_AddUserHuman(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + idGenerator id.Generator + userPasswordHasher *crypto.PasswordHasher + newCode cryptoCodeFunc + checkPermission domain.PermissionCheck + } + type args struct { + ctx context.Context + orgID string + human *AddHuman + secretGenerator crypto.Generator + allowInitMail bool + codeAlg crypto.EncryptionAlgorithm + } + type res struct { + want *domain.ObjectDetails + wantID string + wantEmailCode string + err func(error) bool + } + + userAgg := user.NewAggregate("user1", "org1") + + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "orgid missing, invalid argument error", + fields: fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "", + human: &AddHuman{ + Username: "username", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + }, + }, + allowInitMail: true, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMA-095xh8fll1", "Errors.Internal")) + }, + }, + }, + { + name: "user invalid, invalid argument error", + fields: fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username", + FirstName: "firstname", + }, + allowInitMail: true, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "EMAIL-spblu", "Errors.User.Email.Empty")) + }, + }, + }, + { + name: "with id, already exists, precondition error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + ID: "user1", + Username: "username", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + }, + PreferredLanguage: language.English, + }, + allowInitMail: true, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-7yiox1isql", "Errors.User.AlreadyExisting")) + }, + }, + }, + { + name: "domain policy not found, precondition error", + fields: fields{ + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + eventstore: expectEventstore( + expectFilter(), + expectFilter(), + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + }, + PreferredLanguage: language.English, + }, + allowInitMail: true, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInternal(nil, "USER-Ggk9n", "Errors.Internal")) + }, + }, + }, + { + name: "password policy not found, precondition error", + fields: fields{ + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &userAgg.Aggregate, + true, + true, + true, + ), + ), + ), + expectFilter(), + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username", + FirstName: "firstname", + LastName: "lastname", + Password: "pass", + Email: Email{ + Address: "email@test.ch", + Verified: true, + }, + PreferredLanguage: language.English, + }, + allowInitMail: true, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInternal(nil, "USER-uQ96e", "Errors.Internal")) + }, + }, + }, + { + name: "register human (with initial code), ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &userAgg.Aggregate, + true, + true, + true, + ), + ), + ), + expectPush( + user.NewHumanRegisteredEvent(context.Background(), + &userAgg.Aggregate, + "username", + "firstname", + "lastname", + "", + "firstname lastname", + language.English, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + user.NewHumanInitialCodeAddedEvent(context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("userinit"), + }, + time.Hour*1, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + newCode: mockCode("userinit", time.Hour), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + }, + PreferredLanguage: language.English, + Register: true, + }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + wantID: "user1", + }, + }, + { + name: "add human (with initial code), no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + newCode: mockCode("userinit", time.Hour), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + }, + PreferredLanguage: language.English, + }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + name: "add human (with initial code), ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &userAgg.Aggregate, + true, + true, + true, + ), + ), + ), + expectPush( + user.NewHumanAddedEvent(context.Background(), + &userAgg.Aggregate, + "username", + "firstname", + "lastname", + "", + "firstname lastname", + language.English, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + user.NewHumanInitialCodeAddedEvent(context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("userinit"), + }, + time.Hour*1, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + newCode: mockCode("userinit", time.Hour), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + }, + PreferredLanguage: language.English, + }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + wantID: "user1", + }, + }, + { + name: "add human (with password and initial code), ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + expectPush( + newAddHumanEvent("$plain$x$password", false, true, "", language.English), + user.NewHumanInitialCodeAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("userinit"), + }, + 1*time.Hour, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + newCode: mockCode("userinit", time.Hour), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username", + Password: "password", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + }, + PreferredLanguage: language.English, + }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + wantID: "user1", + }, + }, + { + name: "add human (with password and email code custom template), ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + expectPush( + newAddHumanEvent("$plain$x$password", false, true, "", language.English), + user.NewHumanEmailCodeAddedEventV2(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailCode"), + }, + 1*time.Hour, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}", + false, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + newCode: mockCode("emailCode", time.Hour), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username", + Password: "password", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + URLTemplate: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}", + }, + PreferredLanguage: language.English, + }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: false, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + wantID: "user1", + }, + }, + { + name: "add human (with password and return email code), ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + expectPush( + newAddHumanEvent("$plain$x$password", false, true, "", language.English), + user.NewHumanEmailCodeAddedEventV2(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailCode"), + }, + 1*time.Hour, + "", + true, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + newCode: mockCode("emailCode", time.Hour), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username", + Password: "password", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + ReturnCode: true, + }, + PreferredLanguage: language.English, + }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: false, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + wantID: "user1", + wantEmailCode: "emailCode", + }, + }, + { + name: "add human email verified, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &userAgg.Aggregate, + true, + true, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &userAgg.Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + expectPush( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + user.NewHumanEmailVerifiedEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username", + Password: "password", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + Verified: true, + }, + PreferredLanguage: language.English, + PasswordChangeRequired: true, + }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + wantID: "user1", + }, + }, + { + name: "add human email verified, trim spaces, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &userAgg.Aggregate, + true, + true, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &userAgg.Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + expectPush( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + user.NewHumanEmailVerifiedEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: " username ", + Password: "password", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + Verified: true, + }, + PreferredLanguage: language.English, + PasswordChangeRequired: true, + }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + wantID: "user1", + }, + }, + { + name: "add human, email verified, userLoginMustBeDomain false, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &userAgg.Aggregate, + false, + true, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &userAgg.Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + expectPush( + newAddHumanEvent("$plain$x$password", true, false, "", language.English), + user.NewHumanEmailVerifiedEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username", + Password: "password", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + Verified: true, + }, + PreferredLanguage: language.English, + PasswordChangeRequired: true, + }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + wantID: "user1", + }, + }, + { + name: "add human claimed domain, userLoginMustBeDomain false, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &userAgg.Aggregate, + false, + true, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewDomainVerifiedEvent(context.Background(), + &org.NewAggregate("org2").Aggregate, + "test.ch", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username@test.ch", + Password: "password", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + Verified: true, + }, + PreferredLanguage: language.English, + PasswordChangeRequired: true, + }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-SFd21", "Errors.User.DomainNotAllowedAsUsername")) + }, + }, + }, + { + name: "add human domain, userLoginMustBeDomain false, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &userAgg.Aggregate, + false, + true, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewDomainVerifiedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "test.ch", + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &userAgg.Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + expectPush( + func() eventstore.Command { + event := user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username@test.ch", + "firstname", + "lastname", + "", + "firstname lastname", + language.English, + domain.GenderUnspecified, + "email@test.ch", + false, + ) + event.AddPasswordData("$plain$x$password", true) + return event + }(), + user.NewHumanEmailVerifiedEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username@test.ch", + Password: "password", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + Verified: true, + }, + PreferredLanguage: language.English, + PasswordChangeRequired: true, + }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + wantID: "user1", + }, + }, + { + name: "add human (with phone), ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &userAgg.Aggregate, + true, + true, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &userAgg.Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + expectPush( + newAddHumanEvent("$plain$x$password", false, true, "+41711234567", language.English), + user.NewHumanEmailVerifiedEvent( + context.Background(), + &userAgg.Aggregate, + ), + user.NewHumanPhoneCodeAddedEvent(context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phonecode"), + }, + time.Hour*1, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + newCode: mockCode("phonecode", time.Hour), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username", + FirstName: "firstname", + LastName: "lastname", + Password: "password", + Email: Email{ + Address: "email@test.ch", + Verified: true, + }, + Phone: Phone{ + Number: "+41711234567", + }, + PreferredLanguage: language.English, + }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + wantID: "user1", + }, + }, + { + name: "add human (with verified phone), ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &userAgg.Aggregate, + true, + true, + true, + ), + ), + ), + expectPush( + newAddHumanEvent("", false, true, "+41711234567", language.English), + user.NewHumanInitialCodeAddedEvent( + context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("userinit"), + }, + 1*time.Hour, + ), + user.NewHumanPhoneVerifiedEvent( + context.Background(), + &userAgg.Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + newCode: mockCode("userinit", time.Hour), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + }, + Phone: Phone{ + Number: "+41711234567", + Verified: true, + }, + PreferredLanguage: language.English, + }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + wantID: "user1", + }, + }, { + name: "add human (with return code), ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + expectPush( + newAddHumanEvent("$plain$x$password", false, true, "+41711234567", language.English), + user.NewHumanEmailVerifiedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate), + user.NewHumanPhoneCodeAddedEventV2( + context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneCode"), + }, + 1*time.Hour, + true, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + newCode: mockCode("phoneCode", time.Hour), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username", + Password: "password", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + Verified: true, + }, + Phone: Phone{ + Number: "+41711234567", + ReturnCode: true, + }, + PreferredLanguage: language.English, + }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + wantID: "user1", + }, + }, + { + name: "add human with metadata, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &userAgg.Aggregate, + true, + true, + true, + ), + ), + ), + expectPush( + newAddHumanEvent("", false, true, "", language.English), + user.NewHumanInitialCodeAddedEvent( + context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("userinit"), + }, + 1*time.Hour, + ), + user.NewMetadataSetEvent( + context.Background(), + &userAgg.Aggregate, + "testKey", + []byte("testValue"), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + newCode: mockCode("userinit", time.Hour), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + }, + PreferredLanguage: language.English, + Metadata: []*AddMetadataEntry{ + { + Key: "testKey", + Value: []byte("testValue"), + }, + }, + }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + wantID: "user1", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + userPasswordHasher: tt.fields.userPasswordHasher, + idGenerator: tt.fields.idGenerator, + newCode: tt.fields.newCode, + checkPermission: tt.fields.checkPermission, + } + err := r.AddUserHuman(tt.args.ctx, tt.args.orgID, tt.args.human, tt.args.allowInitMail, tt.args.codeAlg) + if tt.res.err == nil { + if !assert.NoError(t, err) { + t.FailNow() + } + } else if !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + return + } + if tt.res.err == nil { + assert.Equal(t, tt.res.want, tt.args.human.Details) + assert.Equal(t, tt.res.wantID, tt.args.human.ID) + assert.Equal(t, tt.res.wantEmailCode, gu.Value(tt.args.human.EmailCode)) + } + }) + } +} + +func TestCommandSide_ChangeUserHuman(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + userPasswordHasher *crypto.PasswordHasher + newCode cryptoCodeFunc + checkPermission domain.PermissionCheck + } + type args struct { + ctx context.Context + orgID string + human *ChangeHuman + codeAlg crypto.EncryptionAlgorithm + } + type res struct { + want *domain.ObjectDetails + wantEmailCode *string + wantPhoneCode *string + err func(error) bool + } + + userAgg := user.NewAggregate("user1", "org1") + + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "domain policy not found, precondition error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + ), + expectFilter(), + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Username: gu.Ptr("changed"), + }, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-79pv6e1q62", "Errors.Org.DomainPolicy.NotExisting")) + }, + }, + }, + { + name: "change human username, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Username: gu.Ptr("changed"), + }, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + name: "change human username, not found", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Username: gu.Ptr("changed"), + }, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-ugjs0upun6", "Errors.User.NotFound")) + }, + }, + }, + { + name: "change human username, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &userAgg.Aggregate, + true, + true, + true, + ), + ), + ), + expectPush( + user.NewUsernameChangedEvent(context.Background(), + &userAgg.Aggregate, + "username", + "changed", + true, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Username: gu.Ptr("changed"), + }, + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + }, + }, + { + name: "change human username, no change", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Username: gu.Ptr("username"), + }, + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + }, + }, + { + name: "change human profile, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Profile: &Profile{ + FirstName: gu.Ptr("changedfn"), + LastName: gu.Ptr("changedln"), + NickName: gu.Ptr("changednn"), + DisplayName: gu.Ptr("changeddn"), + PreferredLanguage: gu.Ptr(language.Afrikaans), + Gender: gu.Ptr(domain.GenderDiverse), + }, + }, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + name: "change human profile, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + ), + expectPush( + func() eventstore.Command { + cmd, _ := user.NewHumanProfileChangedEvent(context.Background(), + &userAgg.Aggregate, + []user.ProfileChanges{ + user.ChangeFirstName("changedfn"), + user.ChangeLastName("changedln"), + user.ChangeNickName("changednn"), + user.ChangeDisplayName("changeddn"), + user.ChangePreferredLanguage(language.Afrikaans), + user.ChangeGender(domain.GenderDiverse), + }, + ) + return cmd + }(), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Profile: &Profile{ + FirstName: gu.Ptr("changedfn"), + LastName: gu.Ptr("changedln"), + NickName: gu.Ptr("changednn"), + DisplayName: gu.Ptr("changeddn"), + PreferredLanguage: gu.Ptr(language.Afrikaans), + Gender: gu.Ptr(domain.GenderDiverse), + }, + }, + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + }, + }, + { + name: "change human profile, no change", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Profile: &Profile{ + FirstName: gu.Ptr("firstname"), + LastName: gu.Ptr("lastname"), + NickName: gu.Ptr(""), + DisplayName: gu.Ptr("firstname lastname"), + PreferredLanguage: gu.Ptr(language.English), + Gender: gu.Ptr(domain.GenderUnspecified), + }, + }, + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + }, + }, + { + name: "change human email, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + ), + expectPush( + user.NewHumanEmailChangedEvent(context.Background(), + &userAgg.Aggregate, + "changed@example.com", + ), + user.NewHumanEmailCodeAddedEventV2(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailCode"), + }, + time.Hour, + "", + false, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockCode("emailCode", time.Hour), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Email: &Email{ + Address: "changed@example.com", + }, + }, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + }, + }, + { + name: "change human email, no change", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Email: &Email{ + Address: "email@test.ch", + }, + }, + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + }, + }, + { + name: "change human email verified, not allowed", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Email: &Email{ + Address: "changed@example.com", + Verified: true, + }, + }, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + name: "change human email verified, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + ), + expectPush( + user.NewHumanEmailChangedEvent(context.Background(), + &userAgg.Aggregate, + "changed@example.com", + ), + user.NewHumanEmailVerifiedEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Email: &Email{ + Address: "changed@example.com", + Verified: true, + }, + }, + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + }, + }, + { + name: "change human email isVerified, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + ), + expectPush( + user.NewHumanEmailVerifiedEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Email: &Email{ + Verified: true, + }, + }, + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + }, + }, + { + name: "change human email returnCode, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + ), + expectPush( + user.NewHumanEmailChangedEvent(context.Background(), + &userAgg.Aggregate, + "changed@test.com", + ), + user.NewHumanEmailCodeAddedEventV2(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailCode"), + }, + time.Hour, + "", + true, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockCode("emailCode", time.Hour), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Email: &Email{ + Address: "changed@test.com", + ReturnCode: true, + }, + }, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + wantEmailCode: gu.Ptr("emailCode"), + }, + }, + { + name: "change human phone, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + ), + expectPush( + user.NewHumanPhoneChangedEvent(context.Background(), + &userAgg.Aggregate, + "+41791234567", + ), + user.NewHumanPhoneCodeAddedEventV2(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneCode"), + }, + time.Hour, + false, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockCode("phoneCode", time.Hour), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Phone: &Phone{ + Number: "+41791234567", + }, + }, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + }, + }, { + name: "change human phone verified, not allowed", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Phone: &Phone{ + Number: "+41791234567", + Verified: true, + }, + }, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + name: "change human phone verified, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + ), + expectPush( + user.NewHumanPhoneChangedEvent(context.Background(), + &userAgg.Aggregate, + "+41791234567", + ), + user.NewHumanPhoneVerifiedEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Phone: &Phone{ + Number: "+41791234567", + Verified: true, + }, + }, + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + }, + }, + { + name: "change human phone isVerified, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + ), + expectPush( + user.NewHumanPhoneVerifiedEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Phone: &Phone{ + Verified: true, + }, + }, + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + }, + }, + { + name: "change human phone returnCode, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + ), + expectPush( + user.NewHumanPhoneChangedEvent(context.Background(), + &userAgg.Aggregate, + "+41791234567", + ), + user.NewHumanPhoneCodeAddedEventV2(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneCode"), + }, + time.Hour, + true, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockCode("phoneCode", time.Hour), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Phone: &Phone{ + Number: "+41791234567", + ReturnCode: true, + }, + }, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + wantPhoneCode: gu.Ptr("phoneCode"), + }, + }, + { + name: "password change, no password, invalid argument error", + fields: fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + userPasswordHasher: mockPasswordHasher("x"), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Password: &Password{}, + }, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-3klek4sbns", "Errors.User.Password.Empty")) + }, + }, + }, + { + name: "change human password, not initialized", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanInitialCodeAddedEvent(context.Background(), + &userAgg.Aggregate, + nil, time.Hour*1, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + userPasswordHasher: mockPasswordHasher("x"), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Password: &Password{ + Password: gu.Ptr("password2"), + OldPassword: gu.Ptr("password"), + ChangeRequired: true, + }, + }, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-M9dse", "Errors.User.NotInitialised")) + }, + }, + }, + { + name: "change human password, not in complexity", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanInitializedCheckSucceededEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + 1, + false, + true, + false, + false, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + userPasswordHasher: mockPasswordHasher("x"), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Password: &Password{ + Password: gu.Ptr("password2"), + OldPassword: gu.Ptr("password"), + ChangeRequired: true, + }, + }, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "DOMAIN-VoaRj", "Errors.User.PasswordComplexityPolicy.HasUpper")) + }, + }, + }, + { + name: "change human password, empty", + fields: fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + userPasswordHasher: mockPasswordHasher("x"), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Password: &Password{ + OldPassword: gu.Ptr("password"), + ChangeRequired: true, + }, + }, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-3klek4sbns", "Errors.User.Password.Empty")) + }, + }, + }, + { + name: "change human password, not allowed", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanInitializedCheckSucceededEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + ), + ), + userPasswordHasher: mockPasswordHasher("x"), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Password: &Password{ + Password: gu.Ptr("password2"), + ChangeRequired: true, + }, + }, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + name: "change human password, permission, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanInitializedCheckSucceededEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + expectPush( + user.NewHumanPasswordChangedEvent(context.Background(), + &userAgg.Aggregate, + "$plain$x$password2", + true, + "", + ), + ), + ), + userPasswordHasher: mockPasswordHasher("x"), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Password: &Password{ + Password: gu.Ptr("password2"), + ChangeRequired: true, + }, + }, + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + }, + }, + { + name: "change human password, old password, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanInitializedCheckSucceededEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + expectPush( + user.NewHumanPasswordChangedEvent(context.Background(), + &userAgg.Aggregate, + "$plain$x$password2", + true, + "", + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + userPasswordHasher: mockPasswordHasher("x"), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Password: &Password{ + Password: gu.Ptr("password2"), + OldPassword: gu.Ptr("password"), + ChangeRequired: true, + }, + }, + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + }, + }, + { + name: "change human password, old password, failed", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanInitializedCheckSucceededEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + userPasswordHasher: mockPasswordHasher("x"), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Password: &Password{ + Password: gu.Ptr("password2"), + OldPassword: gu.Ptr("wrong"), + ChangeRequired: true, + }, + }, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-3M0fs", "Errors.User.Password.Invalid")) + }, + }, + }, + { + name: "change human password, password code, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanInitializedCheckSucceededEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + eventFromEventPusherWithCreationDateNow( + user.NewHumanPasswordCodeAddedEventV2(context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour*1, + domain.NotificationTypeEmail, + "", + false, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + expectPush( + user.NewHumanPasswordChangedEvent(context.Background(), + &userAgg.Aggregate, + "$plain$x$password2", + true, + "", + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + userPasswordHasher: mockPasswordHasher("x"), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Password: &Password{ + Password: gu.Ptr("password2"), + PasswordCode: gu.Ptr("code"), + ChangeRequired: true, + }, + }, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + }, + }, + { + name: "change human password, password code, wrong code", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanInitializedCheckSucceededEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + eventFromEventPusherWithCreationDateNow( + user.NewHumanPasswordCodeAddedEventV2(context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour*1, + domain.NotificationTypeEmail, + "", + false, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + userPasswordHasher: mockPasswordHasher("x"), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Password: &Password{ + Password: gu.Ptr("password2"), + PasswordCode: gu.Ptr("wrong"), + ChangeRequired: true, + }, + }, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "CODE-woT0xc", "Errors.User.Code.Invalid")) + }, + }, + }, + { + name: "change human password encoded, password code, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanInitializedCheckSucceededEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + eventFromEventPusherWithCreationDateNow( + user.NewHumanPasswordCodeAddedEventV2(context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour*1, + domain.NotificationTypeEmail, + "", + false, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + expectPush( + user.NewHumanPasswordChangedEvent(context.Background(), + &userAgg.Aggregate, + "$plain$x$password2", + true, + "", + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + userPasswordHasher: mockPasswordHasher("x"), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Password: &Password{ + EncodedPasswordHash: gu.Ptr("$plain$x$password2"), + PasswordCode: gu.Ptr("code"), + ChangeRequired: true, + }, + }, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + }, + }, + { + name: "change human password and password encoded, password code, encoded used", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanInitializedCheckSucceededEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + eventFromEventPusherWithCreationDateNow( + user.NewHumanPasswordCodeAddedEventV2(context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour*1, + domain.NotificationTypeEmail, + "", + false, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + expectPush( + user.NewHumanPasswordChangedEvent(context.Background(), + &userAgg.Aggregate, + "$plain$x$password2", + true, + "", + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + userPasswordHasher: mockPasswordHasher("x"), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Password: &Password{ + Password: gu.Ptr("passwordnotused"), + EncodedPasswordHash: gu.Ptr("$plain$x$password2"), + PasswordCode: gu.Ptr("code"), + ChangeRequired: true, + }, + }, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + userPasswordHasher: tt.fields.userPasswordHasher, + newCode: tt.fields.newCode, + checkPermission: tt.fields.checkPermission, + } + err := r.ChangeUserHuman(tt.args.ctx, tt.args.human, tt.args.codeAlg) + if tt.res.err == nil { + if !assert.NoError(t, err) { + t.FailNow() + } + } else if !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + return + } + if tt.res.err == nil { + assert.Equal(t, tt.res.want, tt.args.human.Details) + assert.Equal(t, tt.res.wantEmailCode, tt.args.human.EmailCode) + assert.Equal(t, tt.res.wantPhoneCode, tt.args.human.PhoneCode) + } + }) + } +} diff --git a/internal/command/user_v2_model.go b/internal/command/user_v2_model.go new file mode 100644 index 0000000000..381a463884 --- /dev/null +++ b/internal/command/user_v2_model.go @@ -0,0 +1,558 @@ +package command + +import ( + "context" + "time" + + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/user" +) + +type UserV2WriteModel struct { + eventstore.WriteModel + + UserName string + + MachineWriteModel bool + Name string + Description string + AccessTokenType domain.OIDCTokenType + + MachineSecretWriteModel bool + ClientSecret *crypto.CryptoValue + + ProfileWriteModel bool + FirstName string + LastName string + NickName string + DisplayName string + PreferredLanguage language.Tag + Gender domain.Gender + + AvatarWriteModel bool + Avatar string + + HumanWriteModel bool + InitCode *crypto.CryptoValue + InitCodeCreationDate time.Time + InitCodeExpiry time.Duration + InitCheckFailedCount uint64 + + PasswordWriteModel bool + PasswordEncodedHash string + PasswordChangeRequired bool + PasswordCode *crypto.CryptoValue + PasswordCodeCreationDate time.Time + PasswordCodeExpiry time.Duration + PasswordCheckFailedCount uint64 + + EmailWriteModel bool + Email domain.EmailAddress + IsEmailVerified bool + EmailCode *crypto.CryptoValue + EmailCodeCreationDate time.Time + EmailCodeExpiry time.Duration + EmailCheckFailedCount uint64 + + PhoneWriteModel bool + Phone domain.PhoneNumber + IsPhoneVerified bool + PhoneCode *crypto.CryptoValue + PhoneCodeCreationDate time.Time + PhoneCodeExpiry time.Duration + PhoneCheckFailedCount uint64 + + StateWriteModel bool + UserState domain.UserState + + IDPLinkWriteModel bool + IDPLinks []*domain.UserIDPLink +} + +func NewUserExistsWriteModel(userID, resourceOwner string) *UserV2WriteModel { + return newUserV2WriteModel(userID, resourceOwner, WithHuman(), WithMachine()) +} + +func NewUserStateWriteModel(userID, resourceOwner string) *UserV2WriteModel { + return newUserV2WriteModel(userID, resourceOwner, WithHuman(), WithMachine(), WithState()) +} + +func NewUserRemoveWriteModel(userID, resourceOwner string) *UserV2WriteModel { + return newUserV2WriteModel(userID, resourceOwner, WithHuman(), WithMachine(), WithState(), WithIDPLinks()) +} + +func NewUserHumanWriteModel(userID, resourceOwner string, profileWM, emailWM, phoneWM, passwordWM, avatarWM, idpLinks bool) *UserV2WriteModel { + opts := []UserV2WMOption{WithHuman(), WithState()} + if profileWM { + opts = append(opts, WithProfile()) + } + if emailWM { + opts = append(opts, WithEmail()) + } + if phoneWM { + opts = append(opts, WithPhone()) + } + if passwordWM { + opts = append(opts, WithPassword()) + } + if avatarWM { + opts = append(opts, WithAvatar()) + } + if idpLinks { + opts = append(opts, WithIDPLinks()) + } + return newUserV2WriteModel(userID, resourceOwner, opts...) +} + +func newUserV2WriteModel(userID, resourceOwner string, opts ...UserV2WMOption) *UserV2WriteModel { + wm := &UserV2WriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: userID, + ResourceOwner: resourceOwner, + }, + } + + for _, optFunc := range opts { + optFunc(wm) + } + return wm +} + +type UserV2WMOption func(o *UserV2WriteModel) + +func WithHuman() UserV2WMOption { + return func(o *UserV2WriteModel) { + o.HumanWriteModel = true + } +} +func WithMachine() UserV2WMOption { + return func(o *UserV2WriteModel) { + o.MachineWriteModel = true + } +} +func WithProfile() UserV2WMOption { + return func(o *UserV2WriteModel) { + o.ProfileWriteModel = true + } +} +func WithEmail() UserV2WMOption { + return func(o *UserV2WriteModel) { + o.EmailWriteModel = true + } +} +func WithPhone() UserV2WMOption { + return func(o *UserV2WriteModel) { + o.PhoneWriteModel = true + } +} +func WithPassword() UserV2WMOption { + return func(o *UserV2WriteModel) { + o.PasswordWriteModel = true + } +} +func WithState() UserV2WMOption { + return func(o *UserV2WriteModel) { + o.StateWriteModel = true + } +} +func WithAvatar() UserV2WMOption { + return func(o *UserV2WriteModel) { + o.AvatarWriteModel = true + } +} +func WithIDPLinks() UserV2WMOption { + return func(o *UserV2WriteModel) { + o.IDPLinkWriteModel = true + } +} + +func (wm *UserV2WriteModel) Reduce() error { + for _, event := range wm.Events { + switch e := event.(type) { + case *user.HumanAddedEvent: + wm.reduceHumanAddedEvent(e) + case *user.HumanRegisteredEvent: + wm.reduceHumanRegisteredEvent(e) + + case *user.HumanInitialCodeAddedEvent: + wm.UserState = domain.UserStateInitial + wm.SetInitCode(e.Code, e.Expiry, e.CreationDate()) + case *user.HumanInitializedCheckSucceededEvent: + wm.UserState = domain.UserStateActive + wm.EmptyInitCode() + case *user.HumanInitializedCheckFailedEvent: + wm.InitCheckFailedCount += 1 + + case *user.UsernameChangedEvent: + wm.UserName = e.UserName + case *user.HumanProfileChangedEvent: + wm.reduceHumanProfileChangedEvent(e) + + case *user.MachineChangedEvent: + if e.Name != nil { + wm.Name = *e.Name + } + if e.Description != nil { + wm.Description = *e.Description + } + if e.AccessTokenType != nil { + wm.AccessTokenType = *e.AccessTokenType + } + + case *user.MachineAddedEvent: + wm.UserName = e.UserName + wm.Name = e.Name + wm.Description = e.Description + wm.AccessTokenType = e.AccessTokenType + wm.UserState = domain.UserStateActive + + case *user.HumanEmailChangedEvent: + wm.Email = e.EmailAddress + wm.IsEmailVerified = false + wm.EmptyEmailCode() + case *user.HumanEmailCodeAddedEvent: + wm.IsEmailVerified = false + wm.SetEMailCode(e.Code, e.Expiry, e.CreationDate()) + case *user.HumanEmailVerifiedEvent: + wm.IsEmailVerified = true + wm.EmptyEmailCode() + case *user.HumanEmailVerificationFailedEvent: + wm.EmailCheckFailedCount += 1 + + case *user.HumanPhoneChangedEvent: + wm.IsPhoneVerified = false + wm.Phone = e.PhoneNumber + wm.EmptyPhoneCode() + case *user.HumanPhoneCodeAddedEvent: + wm.IsPhoneVerified = false + wm.SetPhoneCode(e.Code, e.Expiry, e.CreationDate()) + case *user.HumanPhoneVerifiedEvent: + wm.IsPhoneVerified = true + wm.EmptyPhoneCode() + case *user.HumanPhoneVerificationFailedEvent: + wm.PhoneCheckFailedCount += 1 + case *user.HumanPhoneRemovedEvent: + wm.EmptyPhoneCode() + wm.Phone = "" + wm.IsPhoneVerified = false + + case *user.HumanAvatarAddedEvent: + wm.Avatar = e.StoreKey + case *user.HumanAvatarRemovedEvent: + wm.Avatar = "" + + case *user.UserLockedEvent: + wm.UserState = domain.UserStateLocked + case *user.UserUnlockedEvent: + wm.PasswordCheckFailedCount = 0 + wm.UserState = domain.UserStateActive + + case *user.UserDeactivatedEvent: + wm.UserState = domain.UserStateInactive + case *user.UserReactivatedEvent: + wm.UserState = domain.UserStateActive + + case *user.UserRemovedEvent: + wm.UserState = domain.UserStateDeleted + + case *user.HumanPasswordHashUpdatedEvent: + wm.PasswordEncodedHash = e.EncodedHash + case *user.HumanPasswordCheckFailedEvent: + wm.PasswordCheckFailedCount += 1 + case *user.HumanPasswordCheckSucceededEvent: + wm.PasswordCheckFailedCount = 0 + case *user.HumanPasswordChangedEvent: + wm.PasswordEncodedHash = user.SecretOrEncodedHash(e.Secret, e.EncodedHash) + wm.PasswordChangeRequired = e.ChangeRequired + wm.EmptyPasswordCode() + case *user.HumanPasswordCodeAddedEvent: + wm.SetPasswordCode(e.Code, e.Expiry, e.CreationDate()) + case *user.UserIDPLinkAddedEvent: + wm.AddIDPLink(e.IDPConfigID, e.DisplayName, e.ExternalUserID) + case *user.UserIDPLinkRemovedEvent: + wm.RemoveIDPLink(e.IDPConfigID, e.ExternalUserID) + case *user.UserIDPLinkCascadeRemovedEvent: + wm.RemoveIDPLink(e.IDPConfigID, e.ExternalUserID) + } + } + return wm.WriteModel.Reduce() +} + +func (wm *UserV2WriteModel) AddIDPLink(configID, displayName, externalUserID string) { + wm.IDPLinks = append(wm.IDPLinks, &domain.UserIDPLink{IDPConfigID: configID, DisplayName: displayName, ExternalUserID: externalUserID}) +} + +func (wm *UserV2WriteModel) RemoveIDPLink(configID, externalUserID string) { + idx, _ := wm.IDPLinkByID(configID, externalUserID) + if idx < 0 { + return + } + copy(wm.IDPLinks[idx:], wm.IDPLinks[idx+1:]) + wm.IDPLinks[len(wm.IDPLinks)-1] = nil + wm.IDPLinks = wm.IDPLinks[:len(wm.IDPLinks)-1] +} + +func (wm *UserV2WriteModel) EmptyInitCode() { + wm.InitCode = nil + wm.InitCodeExpiry = 0 + wm.InitCodeCreationDate = time.Time{} + wm.InitCheckFailedCount = 0 +} +func (wm *UserV2WriteModel) SetInitCode(code *crypto.CryptoValue, expiry time.Duration, creationDate time.Time) { + wm.InitCode = code + wm.InitCodeExpiry = expiry + wm.InitCodeCreationDate = creationDate + wm.InitCheckFailedCount = 0 +} +func (wm *UserV2WriteModel) EmptyEmailCode() { + wm.EmailCode = nil + wm.EmailCodeExpiry = 0 + wm.EmailCodeCreationDate = time.Time{} + wm.EmailCheckFailedCount = 0 +} +func (wm *UserV2WriteModel) SetEMailCode(code *crypto.CryptoValue, expiry time.Duration, creationDate time.Time) { + wm.EmailCode = code + wm.EmailCodeExpiry = expiry + wm.EmailCodeCreationDate = creationDate + wm.EmailCheckFailedCount = 0 +} +func (wm *UserV2WriteModel) EmptyPhoneCode() { + wm.PhoneCode = nil + wm.PhoneCodeExpiry = 0 + wm.PhoneCodeCreationDate = time.Time{} + wm.PhoneCheckFailedCount = 0 +} +func (wm *UserV2WriteModel) SetPhoneCode(code *crypto.CryptoValue, expiry time.Duration, creationDate time.Time) { + wm.PhoneCode = code + wm.PhoneCodeExpiry = expiry + wm.PhoneCodeCreationDate = creationDate + wm.PhoneCheckFailedCount = 0 +} +func (wm *UserV2WriteModel) EmptyPasswordCode() { + wm.PasswordCode = nil + wm.PasswordCodeExpiry = 0 + wm.PasswordCodeCreationDate = time.Time{} +} +func (wm *UserV2WriteModel) SetPasswordCode(code *crypto.CryptoValue, expiry time.Duration, creationDate time.Time) { + wm.PasswordCode = code + wm.PasswordCodeExpiry = expiry + wm.PasswordCodeCreationDate = creationDate +} + +func (wm *UserV2WriteModel) Query() *eventstore.SearchQueryBuilder { + // remove events are always processed + // and username is based for machine and human + eventTypes := []eventstore.EventType{ + user.UserRemovedType, + user.UserUserNameChangedType, + } + + if wm.HumanWriteModel { + eventTypes = append(eventTypes, + user.UserV1AddedType, + user.HumanAddedType, + user.UserV1RegisteredType, + user.HumanRegisteredType, + ) + } + + if wm.MachineWriteModel { + eventTypes = append(eventTypes, + user.MachineChangedEventType, + user.MachineAddedEventType, + ) + } + + if wm.EmailWriteModel { + eventTypes = append(eventTypes, + user.UserV1EmailChangedType, + user.HumanEmailChangedType, + user.UserV1EmailCodeAddedType, + user.HumanEmailCodeAddedType, + + user.UserV1EmailVerifiedType, + user.HumanEmailVerifiedType, + user.HumanEmailVerificationFailedType, + user.UserV1EmailVerificationFailedType, + ) + } + if wm.PhoneWriteModel { + eventTypes = append(eventTypes, + user.UserV1PhoneChangedType, + user.HumanPhoneChangedType, + user.UserV1PhoneCodeAddedType, + user.HumanPhoneCodeAddedType, + + user.UserV1PhoneVerifiedType, + user.HumanPhoneVerifiedType, + user.HumanPhoneVerificationFailedType, + user.UserV1PhoneVerificationFailedType, + + user.UserV1PhoneRemovedType, + user.HumanPhoneRemovedType, + ) + } + if wm.ProfileWriteModel { + eventTypes = append(eventTypes, + user.UserV1ProfileChangedType, + user.HumanProfileChangedType, + ) + } + if wm.StateWriteModel { + eventTypes = append(eventTypes, + user.UserV1InitialCodeAddedType, + user.HumanInitialCodeAddedType, + + user.UserV1InitializedCheckSucceededType, + user.HumanInitializedCheckSucceededType, + user.HumanInitializedCheckFailedType, + user.UserV1InitializedCheckFailedType, + + user.UserLockedType, + user.UserUnlockedType, + user.UserDeactivatedType, + user.UserReactivatedType, + ) + } + if wm.AvatarWriteModel { + eventTypes = append(eventTypes, + user.HumanAvatarAddedType, + user.HumanAvatarRemovedType, + ) + } + if wm.PasswordWriteModel { + eventTypes = append(eventTypes, + user.HumanPasswordHashUpdatedType, + + user.HumanPasswordChangedType, + user.UserV1PasswordChangedType, + user.HumanPasswordCodeAddedType, + user.UserV1PasswordCodeAddedType, + + user.HumanPasswordCheckFailedType, + user.UserV1PasswordCheckFailedType, + user.HumanPasswordCheckSucceededType, + user.UserV1PasswordCheckSucceededType, + ) + } + if wm.IDPLinkWriteModel { + eventTypes = append(eventTypes, + user.UserIDPLinkAddedType, + user.UserIDPLinkRemovedType, + user.UserIDPLinkCascadeRemovedType, + ) + } + + query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AddQuery(). + AggregateTypes(user.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes(eventTypes...). + Builder() + if wm.ResourceOwner != "" { + query.ResourceOwner(wm.ResourceOwner) + } + return query +} + +func (wm *UserV2WriteModel) reduceHumanAddedEvent(e *user.HumanAddedEvent) { + wm.UserName = e.UserName + wm.FirstName = e.FirstName + wm.LastName = e.LastName + wm.NickName = e.NickName + wm.DisplayName = e.DisplayName + wm.PreferredLanguage = e.PreferredLanguage + wm.Gender = e.Gender + wm.Email = e.EmailAddress + wm.Phone = e.PhoneNumber + wm.UserState = domain.UserStateActive + wm.PasswordEncodedHash = user.SecretOrEncodedHash(e.Secret, e.EncodedHash) + wm.PasswordChangeRequired = e.ChangeRequired +} + +func (wm *UserV2WriteModel) reduceHumanRegisteredEvent(e *user.HumanRegisteredEvent) { + wm.UserName = e.UserName + wm.FirstName = e.FirstName + wm.LastName = e.LastName + wm.NickName = e.NickName + wm.DisplayName = e.DisplayName + wm.PreferredLanguage = e.PreferredLanguage + wm.Gender = e.Gender + wm.Email = e.EmailAddress + wm.Phone = e.PhoneNumber + wm.UserState = domain.UserStateActive + wm.PasswordEncodedHash = user.SecretOrEncodedHash(e.Secret, e.EncodedHash) + wm.PasswordChangeRequired = e.ChangeRequired +} + +func (wm *UserV2WriteModel) reduceHumanProfileChangedEvent(e *user.HumanProfileChangedEvent) { + if e.FirstName != "" { + wm.FirstName = e.FirstName + } + if e.LastName != "" { + wm.LastName = e.LastName + } + if e.NickName != nil { + wm.NickName = *e.NickName + } + if e.DisplayName != nil { + wm.DisplayName = *e.DisplayName + } + if e.PreferredLanguage != nil { + wm.PreferredLanguage = *e.PreferredLanguage + } + if e.Gender != nil { + wm.Gender = *e.Gender + } +} + +func (wm *UserV2WriteModel) Aggregate() *user.Aggregate { + return user.NewAggregate(wm.AggregateID, wm.ResourceOwner) +} + +func (wm *UserV2WriteModel) NewProfileChangedEvent( + ctx context.Context, + firstName, + lastName, + nickName, + displayName *string, + preferredLanguage *language.Tag, + gender *domain.Gender, +) (*user.HumanProfileChangedEvent, error) { + changes := make([]user.ProfileChanges, 0) + if firstName != nil && wm.FirstName != *firstName { + changes = append(changes, user.ChangeFirstName(*firstName)) + } + if lastName != nil && wm.LastName != *lastName { + changes = append(changes, user.ChangeLastName(*lastName)) + } + if nickName != nil && wm.NickName != *nickName { + changes = append(changes, user.ChangeNickName(*nickName)) + } + if displayName != nil && wm.DisplayName != *displayName { + changes = append(changes, user.ChangeDisplayName(*displayName)) + } + if preferredLanguage != nil && wm.PreferredLanguage != *preferredLanguage { + changes = append(changes, user.ChangePreferredLanguage(*preferredLanguage)) + } + if gender != nil && wm.Gender != *gender { + changes = append(changes, user.ChangeGender(*gender)) + } + if len(changes) == 0 { + return nil, nil + } + return user.NewHumanProfileChangedEvent(ctx, &wm.Aggregate().Aggregate, changes) +} + +func (wm *UserV2WriteModel) IDPLinkByID(idpID, externalUserID string) (idx int, idp *domain.UserIDPLink) { + for idx, idp = range wm.IDPLinks { + if idp.IDPConfigID == idpID && idp.ExternalUserID == externalUserID { + return idx, idp + } + } + return -1, nil +} diff --git a/internal/command/user_v2_model_test.go b/internal/command/user_v2_model_test.go new file mode 100644 index 0000000000..7c77e491fd --- /dev/null +++ b/internal/command/user_v2_model_test.go @@ -0,0 +1,2386 @@ +package command + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/user" +) + +func TestCommandSide_userExistsWriteModel(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + } + type args struct { + ctx context.Context + userID string + } + type res struct { + want *UserV2WriteModel + err func(error) bool + } + + userAgg := user.NewAggregate("user1", "org1") + + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "user added", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + MachineWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + Email: "email@test.ch", + IsEmailVerified: false, + InitCode: nil, + InitCodeCreationDate: time.Time{}, + InitCodeExpiry: 0, + UserState: domain.UserStateActive, + }, + }, + }, + { + name: "user registered", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newRegisterHumanEvent("username", "$plain$x$password", true, true, "", language.English), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + MachineWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + Email: "email@test.ch", + IsEmailVerified: false, + InitCode: nil, + InitCodeCreationDate: time.Time{}, + InitCodeExpiry: 0, + PreferredLanguage: language.English, + UserState: domain.UserStateActive, + }, + }, + }, + { + name: "user machine added", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddMachineEvent(true, domain.OIDCTokenTypeBearer), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + MachineWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + Name: "name", + Description: "description", + AccessTokenType: domain.OIDCTokenTypeBearer, + UserState: domain.UserStateActive, + }, + }, + }, + { + name: "user added with init code", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newRegisterHumanEvent("username", "$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanInitialCodeAddedEvent(context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + MachineWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + Email: "email@test.ch", + IsEmailVerified: false, + InitCode: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + InitCodeCreationDate: time.Time{}, + InitCodeExpiry: time.Hour * 1, + UserState: domain.UserStateInitial, + }, + }, + }, + { + name: "user added with initialized", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newRegisterHumanEvent("username", "$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanInitialCodeAddedEvent(context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + ), + ), + eventFromEventPusher( + user.NewHumanInitializedCheckSucceededEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + MachineWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + Email: "email@test.ch", + IsEmailVerified: false, + UserState: domain.UserStateActive, + }, + }, + }, + { + name: "user added with initialized failed", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newRegisterHumanEvent("username", "$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanInitialCodeAddedEvent(context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + ), + ), + eventFromEventPusher( + user.NewHumanInitializedCheckFailedEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + MachineWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + Email: "email@test.ch", + IsEmailVerified: false, + InitCode: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + InitCodeCreationDate: time.Time{}, + InitCodeExpiry: time.Hour * 1, + InitCheckFailedCount: 1, + UserState: domain.UserStateInitial, + }, + }, + }, + { + name: "user added with username changed", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newRegisterHumanEvent("username", "$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewUsernameChangedEvent(context.Background(), + &userAgg.Aggregate, + "username", + "changed", + true, + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + MachineWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "changed", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + Email: "email@test.ch", + IsEmailVerified: false, + UserState: domain.UserStateActive, + }, + }, + }, + { + name: "user removed", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewUserRemovedEvent(context.Background(), + &userAgg.Aggregate, + "username", + []*domain.UserIDPLink{}, + true, + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + MachineWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + Email: "email@test.ch", + IsEmailVerified: false, + UserState: domain.UserStateDeleted, + }, + }, + }, + { + name: "user machine removed", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddMachineEvent(true, domain.OIDCTokenTypeBearer), + ), + eventFromEventPusher( + user.NewUserRemovedEvent(context.Background(), + &userAgg.Aggregate, + "username", + []*domain.UserIDPLink{}, + true, + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + MachineWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + Name: "name", + Description: "description", + AccessTokenType: domain.OIDCTokenTypeBearer, + UserState: domain.UserStateDeleted, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + } + wm, err := r.userExistsWriteModel(tt.args.ctx, tt.args.userID) + if tt.res.err == nil { + if !assert.NoError(t, err) { + t.FailNow() + } + } else if !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + return + } + if tt.res.err == nil { + assert.Equal(t, tt.res.want, wm) + } + }) + } +} + +func TestCommandSide_userHumanWriteModel_profile(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + } + type args struct { + ctx context.Context + userID string + } + type res struct { + want *UserV2WriteModel + err func(error) bool + } + + userAgg := user.NewAggregate("user1", "org1") + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "user added with profile changed", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newRegisterHumanEvent("username", "$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + func() eventstore.Command { + cmd, _ := user.NewHumanProfileChangedEvent(context.Background(), + &userAgg.Aggregate, + []user.ProfileChanges{ + user.ChangeFirstName("changedfn"), + user.ChangeLastName("changedln"), + user.ChangeNickName("changednn"), + user.ChangeDisplayName("changeddn"), + user.ChangePreferredLanguage(language.Afrikaans), + user.ChangeGender(domain.GenderDiverse), + }, + ) + return cmd + }(), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + ProfileWriteModel: true, + StateWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "changedfn", + LastName: "changedln", + DisplayName: "changeddn", + NickName: "changednn", + PreferredLanguage: language.Afrikaans, + Gender: domain.GenderDiverse, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + Email: "email@test.ch", + IsEmailVerified: false, + UserState: domain.UserStateActive, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + } + wm, err := r.userHumanWriteModel(tt.args.ctx, tt.args.userID, true, false, false, false, false, false) + if tt.res.err == nil { + if !assert.NoError(t, err) { + t.FailNow() + } + } else if !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + return + } + if tt.res.err == nil { + assert.Equal(t, tt.res.want, wm) + } + }) + } +} + +func TestCommandSide_userHumanWriteModel_email(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + } + type args struct { + ctx context.Context + userID string + } + type res struct { + want *UserV2WriteModel + err func(error) bool + } + + userAgg := user.NewAggregate("user1", "org1") + + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "user added email changed", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanEmailChangedEvent(context.Background(), + &userAgg.Aggregate, + "changed@test.com", + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + EmailWriteModel: true, + StateWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + Email: "changed@test.com", + IsEmailVerified: false, + InitCode: nil, + InitCodeCreationDate: time.Time{}, + InitCodeExpiry: 0, + UserState: domain.UserStateActive, + }, + }, + }, + { + name: "user added with email code", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newRegisterHumanEvent("username", "$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanEmailCodeAddedEventV2(context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + "", + false, + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + EmailWriteModel: true, + StateWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + Email: "email@test.ch", + IsEmailVerified: false, + EmailCode: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + EmailCodeCreationDate: time.Time{}, + EmailCodeExpiry: time.Hour * 1, + UserState: domain.UserStateActive, + }, + }, + }, + { + name: "user added with email code verified", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newRegisterHumanEvent("username", "$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanEmailCodeAddedEventV2(context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + "", + false, + ), + ), + eventFromEventPusher( + user.NewHumanEmailVerifiedEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + EmailWriteModel: true, + StateWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + Email: "email@test.ch", + IsEmailVerified: true, + UserState: domain.UserStateActive, + }, + }, + }, + { + name: "user added with email code verified failed", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newRegisterHumanEvent("username", "$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanEmailCodeAddedEventV2(context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + "", + false, + ), + ), + eventFromEventPusher( + user.NewHumanEmailVerificationFailedEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + EmailWriteModel: true, + StateWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + Email: "email@test.ch", + IsEmailVerified: false, + EmailCode: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + EmailCodeCreationDate: time.Time{}, + EmailCodeExpiry: time.Hour * 1, + EmailCheckFailedCount: 1, + UserState: domain.UserStateActive, + }, + }, + }, + { + name: "user added with email code verified, then changed", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newRegisterHumanEvent("username", "$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanEmailCodeAddedEventV2(context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + "", + false, + ), + ), + eventFromEventPusher( + user.NewHumanEmailVerifiedEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + eventFromEventPusher( + user.NewHumanEmailChangedEvent(context.Background(), + &userAgg.Aggregate, + "changed@test.com", + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + EmailWriteModel: true, + StateWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + Email: "changed@test.com", + IsEmailVerified: false, + UserState: domain.UserStateActive, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + } + wm, err := r.userHumanWriteModel(tt.args.ctx, tt.args.userID, false, true, false, false, false, false) + if tt.res.err == nil { + if !assert.NoError(t, err) { + t.FailNow() + } + } else if !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + return + } + if tt.res.err == nil { + assert.Equal(t, tt.res.want, wm) + } + }) + } +} + +func TestCommandSide_userHumanWriteModel_phone(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + } + type args struct { + ctx context.Context + userID string + } + type res struct { + want *UserV2WriteModel + err func(error) bool + } + + userAgg := user.NewAggregate("user1", "org1") + + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "user added phone changed", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanPhoneChangedEvent(context.Background(), + &userAgg.Aggregate, + "+41791234567", + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + PhoneWriteModel: true, + StateWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + Email: "email@test.ch", + IsEmailVerified: false, + Phone: "+41791234567", + IsPhoneVerified: false, + UserState: domain.UserStateActive, + }, + }, + }, + { + name: "user added with phone code", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newRegisterHumanEvent("username", "$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanPhoneChangedEvent(context.Background(), + &userAgg.Aggregate, + "+41791234567", + ), + ), + eventFromEventPusher( + user.NewHumanPhoneCodeAddedEventV2(context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + false, + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + PhoneWriteModel: true, + StateWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + Email: "email@test.ch", + IsEmailVerified: false, + Phone: "+41791234567", + IsPhoneVerified: false, + PhoneCode: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + PhoneCodeCreationDate: time.Time{}, + PhoneCodeExpiry: time.Hour * 1, + UserState: domain.UserStateActive, + }, + }, + }, + { + name: "user added with phone code verified", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newRegisterHumanEvent("username", "$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanPhoneChangedEvent(context.Background(), + &userAgg.Aggregate, + "+41791234567", + ), + ), + eventFromEventPusher( + user.NewHumanPhoneCodeAddedEventV2(context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + false, + ), + ), + eventFromEventPusher( + user.NewHumanPhoneVerifiedEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + PhoneWriteModel: true, + StateWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + Email: "email@test.ch", + IsEmailVerified: false, + Phone: "+41791234567", + IsPhoneVerified: true, + UserState: domain.UserStateActive, + }, + }, + }, + { + name: "user added with phone code verified failed", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newRegisterHumanEvent("username", "$plain$x$password", true, true, "", language.English), + ), + + eventFromEventPusher( + user.NewHumanPhoneChangedEvent(context.Background(), + &userAgg.Aggregate, + "+41791234567", + ), + ), + eventFromEventPusher( + user.NewHumanPhoneCodeAddedEventV2(context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + false, + ), + ), + eventFromEventPusher( + user.NewHumanPhoneVerificationFailedEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + PhoneWriteModel: true, + StateWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + Email: "email@test.ch", + IsEmailVerified: false, + Phone: "+41791234567", + IsPhoneVerified: false, + PhoneCode: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + PhoneCodeCreationDate: time.Time{}, + PhoneCodeExpiry: time.Hour * 1, + PhoneCheckFailedCount: 1, + UserState: domain.UserStateActive, + }, + }, + }, + { + name: "user added with email code verified, then changed", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newRegisterHumanEvent("username", "$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanPhoneChangedEvent(context.Background(), + &userAgg.Aggregate, + "+41791234567", + ), + ), + eventFromEventPusher( + user.NewHumanPhoneCodeAddedEventV2(context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + false, + ), + ), + eventFromEventPusher( + user.NewHumanPhoneVerifiedEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + eventFromEventPusher( + user.NewHumanPhoneChangedEvent(context.Background(), + &userAgg.Aggregate, + "+41797654321", + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + PhoneWriteModel: true, + StateWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + Email: "email@test.ch", + IsEmailVerified: false, + Phone: "+41797654321", + IsPhoneVerified: false, + UserState: domain.UserStateActive, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + } + wm, err := r.userHumanWriteModel(tt.args.ctx, tt.args.userID, false, false, true, false, false, false) + if tt.res.err == nil { + if !assert.NoError(t, err) { + t.FailNow() + } + } else if !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + return + } + if tt.res.err == nil { + assert.Equal(t, tt.res.want, wm) + } + }) + } +} + +func TestCommandSide_userHumanWriteModel_password(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + } + type args struct { + ctx context.Context + userID string + } + type res struct { + want *UserV2WriteModel + err func(error) bool + } + + userAgg := user.NewAggregate("user1", "org1") + + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "user added password hashchanged", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanPasswordHashUpdatedEvent(context.Background(), + &userAgg.Aggregate, + "hash", + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + PasswordWriteModel: true, + StateWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "hash", + PasswordChangeRequired: true, + Email: "email@test.ch", + IsEmailVerified: false, + UserState: domain.UserStateActive, + }, + }, + }, + { + name: "user added password changed", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanPasswordChangedEvent(context.Background(), + &userAgg.Aggregate, + "hash", + false, + "", + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + PasswordWriteModel: true, + StateWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "hash", + PasswordChangeRequired: false, + Email: "email@test.ch", + IsEmailVerified: false, + UserState: domain.UserStateActive, + }, + }, + }, + { + name: "user added with password code", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newRegisterHumanEvent("username", "$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanPasswordCodeAddedEventV2(context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + domain.NotificationTypeEmail, + "", + false, + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + PasswordWriteModel: true, + StateWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + Email: "email@test.ch", + IsEmailVerified: false, + PasswordCode: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + PasswordCodeCreationDate: time.Time{}, + PasswordCodeExpiry: time.Hour * 1, + UserState: domain.UserStateActive, + }, + }, + }, + { + name: "user added with password code and then change", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newRegisterHumanEvent("username", "$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanPasswordCodeAddedEventV2(context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + domain.NotificationTypeEmail, + "", + false, + ), + ), + eventFromEventPusher( + user.NewHumanPasswordChangedEvent(context.Background(), + &userAgg.Aggregate, + "hash", + true, + "", + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + PasswordWriteModel: true, + StateWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "hash", + PasswordChangeRequired: true, + Email: "email@test.ch", + IsEmailVerified: false, + UserState: domain.UserStateActive, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + } + wm, err := r.userHumanWriteModel(tt.args.ctx, tt.args.userID, false, false, false, true, false, false) + if tt.res.err == nil { + if !assert.NoError(t, err) { + t.FailNow() + } + } else if !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + return + } + if tt.res.err == nil { + assert.Equal(t, tt.res.want, wm) + } + }) + } +} + +func TestCommandSide_userStateWriteModel(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + } + type args struct { + ctx context.Context + userID string + } + type res struct { + want *UserV2WriteModel + err func(error) bool + } + + userAgg := user.NewAggregate("user1", "org1") + + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "user added", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + MachineWriteModel: true, + StateWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + PasswordCheckFailedCount: 0, + Email: "email@test.ch", + IsEmailVerified: false, + UserState: domain.UserStateActive, + }, + }, + }, + { + name: "user added initialized", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanInitializedCheckSucceededEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + MachineWriteModel: true, + StateWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + PasswordCheckFailedCount: 0, + Email: "email@test.ch", + IsEmailVerified: false, + UserState: domain.UserStateActive, + }, + }, + }, + { + name: "user machine added", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddMachineEvent(true, domain.OIDCTokenTypeBearer), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + MachineWriteModel: true, + StateWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + Name: "name", + Description: "description", + AccessTokenType: domain.OIDCTokenTypeBearer, + UserState: domain.UserStateActive, + }, + }, + }, + { + name: "user added locked", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanPasswordCheckFailedEvent(context.Background(), + &userAgg.Aggregate, + nil, + ), + ), + eventFromEventPusher( + user.NewHumanPasswordCheckFailedEvent(context.Background(), + &userAgg.Aggregate, + nil, + ), + ), + eventFromEventPusher( + user.NewHumanPasswordCheckFailedEvent(context.Background(), + &userAgg.Aggregate, + nil, + ), + ), + eventFromEventPusher( + user.NewUserLockedEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + MachineWriteModel: true, + StateWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + PasswordCheckFailedCount: 3, + Email: "email@test.ch", + IsEmailVerified: false, + UserState: domain.UserStateLocked, + }, + }, + }, + { + name: "user added locked and unlocked", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newRegisterHumanEvent("username", "$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanPasswordCheckFailedEvent(context.Background(), + &userAgg.Aggregate, + nil, + ), + ), + eventFromEventPusher( + user.NewHumanPasswordCheckFailedEvent(context.Background(), + &userAgg.Aggregate, + nil, + ), + ), + eventFromEventPusher( + user.NewHumanPasswordCheckFailedEvent(context.Background(), + &userAgg.Aggregate, + nil, + ), + ), + eventFromEventPusher( + user.NewUserLockedEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + eventFromEventPusher( + user.NewUserUnlockedEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + MachineWriteModel: true, + StateWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + PasswordCheckFailedCount: 0, + Email: "email@test.ch", + IsEmailVerified: false, + UserState: domain.UserStateActive, + }, + }, + }, + { + name: "user added deactivated", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newRegisterHumanEvent("username", "$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewUserDeactivatedEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + MachineWriteModel: true, + StateWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + Email: "email@test.ch", + IsEmailVerified: false, + UserState: domain.UserStateInactive, + }, + }, + }, + { + name: "user added deactivated and reactived", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newRegisterHumanEvent("username", "$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewUserDeactivatedEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + eventFromEventPusher( + user.NewUserReactivatedEvent(context.Background(), + &userAgg.Aggregate, + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + MachineWriteModel: true, + StateWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + Email: "email@test.ch", + IsEmailVerified: false, + UserState: domain.UserStateActive, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + } + wm, err := r.userStateWriteModel(tt.args.ctx, tt.args.userID) + if tt.res.err == nil { + if !assert.NoError(t, err) { + t.FailNow() + } + } else if !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + return + } + if tt.res.err == nil { + assert.Equal(t, tt.res.want, wm) + } + }) + } +} + +func TestCommandSide_userHumanWriteModel_avatar(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + } + type args struct { + ctx context.Context + userID string + } + type res struct { + want *UserV2WriteModel + err func(error) bool + } + + userAgg := user.NewAggregate("user1", "org1") + + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "user added with avatar", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanAvatarAddedEvent(context.Background(), + &userAgg.Aggregate, + "key", + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + AvatarWriteModel: true, + StateWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + Email: "email@test.ch", + IsEmailVerified: false, + UserState: domain.UserStateActive, + Avatar: "key", + }, + }, + }, + + { + name: "user added with avatar and then removed", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanAvatarAddedEvent(context.Background(), + &userAgg.Aggregate, + "key", + ), + ), + eventFromEventPusher( + user.NewHumanAvatarRemovedEvent(context.Background(), + &userAgg.Aggregate, + "key", + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + AvatarWriteModel: true, + StateWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + Email: "email@test.ch", + IsEmailVerified: false, + UserState: domain.UserStateActive, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + } + wm, err := r.userHumanWriteModel(tt.args.ctx, tt.args.userID, false, false, false, false, true, false) + if tt.res.err == nil { + if !assert.NoError(t, err) { + t.FailNow() + } + } else if !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + return + } + if tt.res.err == nil { + assert.Equal(t, tt.res.want, wm) + } + }) + } +} + +func TestCommandSide_userHumanWriteModel_idpLinks(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + } + type args struct { + ctx context.Context + userID string + } + type res struct { + want *UserV2WriteModel + err func(error) bool + } + + userAgg := user.NewAggregate("user1", "org1") + + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "user added with idp link", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewUserIDPLinkAddedEvent(context.Background(), + &userAgg.Aggregate, + "idp", + "name", + "externalID", + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + MachineWriteModel: true, + StateWriteModel: true, + IDPLinkWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + Email: "email@test.ch", + IsEmailVerified: false, + UserState: domain.UserStateActive, + IDPLinks: []*domain.UserIDPLink{ + {IDPConfigID: "idp", DisplayName: "name", ExternalUserID: "externalID"}, + }, + }, + }, + }, + { + name: "user added with idp links", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewUserIDPLinkAddedEvent(context.Background(), + &userAgg.Aggregate, + "idp1", + "name1", + "externalID1", + ), + ), + eventFromEventPusher( + user.NewUserIDPLinkAddedEvent(context.Background(), + &userAgg.Aggregate, + "idp2", + "name2", + "externalID2", + ), + ), + eventFromEventPusher( + user.NewUserIDPLinkAddedEvent(context.Background(), + &userAgg.Aggregate, + "idp3", + "name3", + "externalID3", + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + MachineWriteModel: true, + StateWriteModel: true, + IDPLinkWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + Email: "email@test.ch", + IsEmailVerified: false, + UserState: domain.UserStateActive, + IDPLinks: []*domain.UserIDPLink{ + {IDPConfigID: "idp1", DisplayName: "name1", ExternalUserID: "externalID1"}, + {IDPConfigID: "idp2", DisplayName: "name2", ExternalUserID: "externalID2"}, + {IDPConfigID: "idp3", DisplayName: "name3", ExternalUserID: "externalID3"}, + }, + }, + }, + }, + { + name: "user added with idp links and removed", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewUserIDPLinkAddedEvent(context.Background(), + &userAgg.Aggregate, + "idp1", + "name1", + "externalID1", + ), + ), + eventFromEventPusher( + user.NewUserIDPLinkAddedEvent(context.Background(), + &userAgg.Aggregate, + "idp2", + "name2", + "externalID2", + ), + ), + eventFromEventPusher( + user.NewUserIDPLinkAddedEvent(context.Background(), + &userAgg.Aggregate, + "idp3", + "name3", + "externalID3", + ), + ), + eventFromEventPusher( + user.NewUserIDPLinkCascadeRemovedEvent(context.Background(), + &userAgg.Aggregate, + "idp2", + "externalID2", + ), + ), + eventFromEventPusher( + user.NewUserIDPLinkRemovedEvent(context.Background(), + &userAgg.Aggregate, + "idp3", + "externalID3", + ), + ), + eventFromEventPusher( + user.NewUserIDPLinkAddedEvent(context.Background(), + &userAgg.Aggregate, + "idp4", + "name4", + "externalID4", + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + MachineWriteModel: true, + StateWriteModel: true, + IDPLinkWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + Email: "email@test.ch", + IsEmailVerified: false, + UserState: domain.UserStateActive, + IDPLinks: []*domain.UserIDPLink{ + {IDPConfigID: "idp1", DisplayName: "name1", ExternalUserID: "externalID1"}, + {IDPConfigID: "idp4", DisplayName: "name4", ExternalUserID: "externalID4"}, + }, + }, + }, + }, + { + name: "user added with idp link and removed", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewUserIDPLinkAddedEvent(context.Background(), + &userAgg.Aggregate, + "idp", + "name", + "externalID", + ), + ), + eventFromEventPusher( + user.NewUserIDPLinkRemovedEvent(context.Background(), + &userAgg.Aggregate, + "idp", + "externalID", + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + MachineWriteModel: true, + StateWriteModel: true, + IDPLinkWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + Email: "email@test.ch", + IsEmailVerified: false, + UserState: domain.UserStateActive, + IDPLinks: []*domain.UserIDPLink{}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + } + wm, err := r.userRemoveWriteModel(tt.args.ctx, tt.args.userID) + if tt.res.err == nil { + if !assert.NoError(t, err) { + t.FailNow() + } + } else if !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + return + } + if tt.res.err == nil { + assert.Equal(t, tt.res.want, wm) + } + }) + } +} diff --git a/internal/command/user_v2_test.go b/internal/command/user_v2_test.go new file mode 100644 index 0000000000..15597b7dd7 --- /dev/null +++ b/internal/command/user_v2_test.go @@ -0,0 +1,1413 @@ +package command + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/org" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestCommandSide_LockUserV2(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type ( + args struct { + ctx context.Context + userID string + } + ) + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "userid missing, invalid argument error", + fields: fields{ + eventstore: eventstoreExpect( + t, + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-agz3eczifm", "Errors.User.UserIDMissing")) + }, + }, + }, + { + name: "user not existing, not found error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-450yxuqrh1", "Errors.User.NotFound")) + }, + }, + }, + { + name: "user already locked, precondition error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewUserLockedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-lgws8wtsqf", "Errors.User.ShouldBeActiveOrInitial")) + }, + }, + }, + { + name: "user already locked, precondition error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewMachineAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "name", + "description", + true, + domain.OIDCTokenTypeBearer, + ), + ), + eventFromEventPusher( + user.NewUserLockedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-lgws8wtsqf", "Errors.User.ShouldBeActiveOrInitial")) + }, + }, + }, + { + name: "lock user, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + expectPush( + user.NewUserLockedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "lock user, no permission", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + name: "lock user machine, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewMachineAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "name", + "description", + true, + domain.OIDCTokenTypeBearer, + ), + ), + ), + expectPush( + user.NewUserLockedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore, + checkPermission: tt.fields.checkPermission, + } + got, err := r.LockUserV2(tt.args.ctx, tt.args.userID) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assert.Equal(t, tt.res.want, got) + } + }) + } +} + +func TestCommandSide_UnlockUserV2(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type ( + args struct { + ctx context.Context + userID string + } + ) + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "userid missing, invalid argument error", + fields: fields{ + eventstore: eventstoreExpect( + t, + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-a9ld4xckax", "Errors.User.UserIDMissing")) + }, + }, + }, + { + name: "user not existing, not found error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-x377t913pw", "Errors.User.NotFound")) + }, + }, + }, + { + name: "user already active, precondition error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-olb9vb0oca", "Errors.User.NotLocked")) + }, + }, + }, + { + name: "user already active, precondition error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + user.NewMachineAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "name", + "description", + true, + domain.OIDCTokenTypeBearer, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-olb9vb0oca", "Errors.User.NotLocked")) + }, + }, + }, + { + name: "unlock user, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewUserLockedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate), + ), + ), + expectPush( + user.NewUserUnlockedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "unlock user, no permission", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewUserLockedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + name: "unlock user machine, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewMachineAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "name", + "description", + true, + domain.OIDCTokenTypeBearer, + ), + ), + eventFromEventPusher( + user.NewUserLockedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate), + ), + ), + expectPush( + user.NewUserUnlockedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore, + checkPermission: tt.fields.checkPermission, + } + got, err := r.UnlockUserV2(tt.args.ctx, tt.args.userID) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assert.Equal(t, tt.res.want, got) + } + }) + } +} + +func TestCommandSide_DeactivateUserV2(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type ( + args struct { + ctx context.Context + userID string + } + ) + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "userid missing, invalid argument error", + fields: fields{ + eventstore: eventstoreExpect( + t, + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-78iiirat8y", "Errors.User.UserIDMissing")) + }, + }, + }, + { + name: "user not existing, not found error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-5gp2p62iin", "Errors.User.NotFound")) + }, + }, + }, + { + name: "user initial, precondition error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewHumanInitialCodeAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + nil, time.Hour*1, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-gvx4kct9r2", "Errors.User.CantDeactivateInitial")) + }, + }, + }, + { + name: "user already inactive, precondition error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewUserDeactivatedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-5gunjw0cd7", "Errors.User.AlreadyInactive")) + }, + }, + }, + { + name: "deactivate user, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewHumanInitializedCheckSucceededEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + expectPush( + user.NewUserDeactivatedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "deactivate user, no permission", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewHumanInitializedCheckSucceededEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + name: "user machine already inactive, precondition error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewMachineAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "name", + "description", + true, + domain.OIDCTokenTypeBearer, + ), + ), + eventFromEventPusher( + user.NewUserDeactivatedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-5gunjw0cd7", "Errors.User.AlreadyInactive")) + }, + }, + }, + { + name: "deactivate user machine, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewMachineAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "name", + "description", + true, + domain.OIDCTokenTypeBearer, + ), + ), + ), + expectPush( + user.NewUserDeactivatedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore, + checkPermission: tt.fields.checkPermission, + } + got, err := r.DeactivateUserV2(tt.args.ctx, tt.args.userID) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assert.Equal(t, tt.res.want, got) + } + }) + } +} + +func TestCommandSide_ReactivateUserV2(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type ( + args struct { + ctx context.Context + userID string + } + ) + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "userid missing, invalid argument error", + fields: fields{ + eventstore: eventstoreExpect( + t, + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-0nx1ie38fw", "Errors.User.UserIDMissing")) + }, + }, + }, + { + name: "user not existing, not found error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-9hy5kzbuk6", "Errors.User.NotFound")) + }, + }, + }, + { + name: "user already active, precondition error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-s5qqcz97hf", "Errors.User.NotInactive")) + }, + }, + }, + { + name: "user machine already active, precondition error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewMachineAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "name", + "description", + true, + domain.OIDCTokenTypeBearer, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-s5qqcz97hf", "Errors.User.NotInactive")) + }, + }, + }, + { + name: "reactivate user, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewUserDeactivatedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate), + ), + ), + expectPush( + user.NewUserReactivatedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "reactivate user, no permission", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewUserDeactivatedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + name: "reactivate user machine, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewMachineAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "name", + "description", + true, + domain.OIDCTokenTypeBearer, + ), + ), + eventFromEventPusher( + user.NewUserDeactivatedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate), + ), + ), + expectPush( + user.NewUserReactivatedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore, + checkPermission: tt.fields.checkPermission, + } + got, err := r.ReactivateUserV2(tt.args.ctx, tt.args.userID) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assert.Equal(t, tt.res.want, got) + } + }) + } +} + +func TestCommandSide_RemoveUserV2(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type ( + args struct { + ctx context.Context + userID string + cascadingMemberships []*CascadingMembership + grantIDs []string + } + ) + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "userid missing, invalid argument error", + fields: fields{ + eventstore: eventstoreExpect( + t, + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-vaipl7s13l", "Errors.User.UserIDMissing")) + }, + }, + }, + { + name: "user not existing, not found error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-bd4ir1mblj", "Errors.User.NotFound")) + }, + }, + }, + { + name: "user removed, notfound error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewUserRemovedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + nil, + true, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-bd4ir1mblj", "Errors.User.NotFound")) + }, + }, + }, + { + name: "remove user, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectPush( + user.NewUserRemovedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + nil, + true, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "remove user, no permission", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewHumanInitializedCheckSucceededEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + name: "user machine already removed, notfound error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewMachineAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "name", + "description", + true, + domain.OIDCTokenTypeBearer, + ), + ), + eventFromEventPusher( + user.NewUserRemovedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + nil, + true, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-bd4ir1mblj", "Errors.User.NotFound")) + }, + }, + }, + { + name: "remove user machine, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewMachineAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "name", + "description", + true, + domain.OIDCTokenTypeBearer, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectPush( + user.NewUserRemovedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + nil, + true, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore, + checkPermission: tt.fields.checkPermission, + } + got, err := r.RemoveUserV2(tt.args.ctx, tt.args.userID, tt.args.cascadingMemberships, tt.args.grantIDs...) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assert.Equal(t, tt.res.want, got) + } + }) + } +} diff --git a/internal/command/user_v2_username.go b/internal/command/user_v2_username.go new file mode 100644 index 0000000000..8dd6af7b36 --- /dev/null +++ b/internal/command/user_v2_username.go @@ -0,0 +1,37 @@ +package command + +import ( + "context" + "strings" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func (c *Commands) changeUsername(ctx context.Context, cmds []eventstore.Command, wm *UserV2WriteModel, userName string) ([]eventstore.Command, error) { + if wm.UserName == userName { + return cmds, nil + } + orgID := wm.ResourceOwner + + domainPolicy, err := c.domainPolicyWriteModel(ctx, orgID) + if err != nil { + return cmds, zerrors.ThrowPreconditionFailed(err, "COMMAND-79pv6e1q62", "Errors.Org.DomainPolicy.NotExisting") + } + if !domainPolicy.UserLoginMustBeDomain { + index := strings.LastIndex(userName, "@") + if index > 1 { + domainCheck := NewOrgDomainVerifiedWriteModel(userName[index+1:]) + if err := c.eventstore.FilterToQueryReducer(ctx, domainCheck); err != nil { + return cmds, err + } + if domainCheck.Verified && domainCheck.ResourceOwner != orgID { + return cmds, zerrors.ThrowInvalidArgument(nil, "COMMAND-Di2ei", "Errors.User.DomainNotAllowedAsUsername") + } + } + } + return append(cmds, + user.NewUsernameChangedEvent(ctx, &wm.Aggregate().Aggregate, wm.UserName, userName, domainPolicy.UserLoginMustBeDomain), + ), nil +} diff --git a/internal/domain/permission.go b/internal/domain/permission.go index ebd7981ab0..fd20f77cf0 100644 --- a/internal/domain/permission.go +++ b/internal/domain/permission.go @@ -29,6 +29,7 @@ type PermissionCheck func(ctx context.Context, permission, orgID, resourceID str const ( PermissionUserWrite = "user.write" PermissionUserRead = "user.read" + PermissionUserDelete = "user.delete" PermissionSessionWrite = "session.write" PermissionSessionDelete = "session.delete" ) diff --git a/internal/integration/client.go b/internal/integration/client.go index f8a6580f75..6c4498344e 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -19,6 +19,7 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/idp/providers/ldap" openid "github.com/zitadel/zitadel/internal/idp/providers/oidc" "github.com/zitadel/zitadel/internal/idp/providers/saml" @@ -31,6 +32,7 @@ import ( organisation "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" "github.com/zitadel/zitadel/pkg/grpc/system" + user_pb "github.com/zitadel/zitadel/pkg/grpc/user" user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) @@ -134,6 +136,17 @@ func (s *Tester) CreateHumanUser(ctx context.Context) *user.AddHumanUserResponse return resp } +func (s *Tester) CreateMachineUser(ctx context.Context) *mgmt.AddMachineUserResponse { + resp, err := s.Client.Mgmt.AddMachineUser(ctx, &mgmt.AddMachineUserRequest{ + UserName: fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()), + Name: "Mickey", + Description: "Mickey Mouse", + AccessTokenType: user_pb.AccessTokenType_ACCESS_TOKEN_TYPE_BEARER, + }) + logging.OnError(err).Fatal("create human user") + return resp +} + func (s *Tester) CreateUserIDPlink(ctx context.Context, userID, externalID, idpID, username string) *user.AddIDPLinkResponse { resp, err := s.Client.UserV2.AddIDPLink( ctx, @@ -406,3 +419,29 @@ func (s *Tester) CreatePasswordSession(t *testing.T, ctx context.Context, userID return createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetChangeDate().AsTime(), createResp.GetDetails().GetChangeDate().AsTime() } + +func (s *Tester) CreateProjectUserGrant(t *testing.T, ctx context.Context, projectID, userID string) string { + resp, err := s.Client.Mgmt.AddUserGrant(ctx, &mgmt.AddUserGrantRequest{ + UserId: userID, + ProjectId: projectID, + }) + require.NoError(t, err) + return resp.GetUserGrantId() +} + +func (s *Tester) CreateOrgMembership(t *testing.T, ctx context.Context, userID string) { + _, err := s.Client.Mgmt.AddOrgMember(ctx, &mgmt.AddOrgMemberRequest{ + UserId: userID, + Roles: []string{domain.RoleOrgOwner}, + }) + require.NoError(t, err) +} + +func (s *Tester) CreateProjectMembership(t *testing.T, ctx context.Context, projectID, userID string) { + _, err := s.Client.Mgmt.AddProjectMember(ctx, &mgmt.AddProjectMemberRequest{ + ProjectId: projectID, + UserId: userID, + Roles: []string{domain.RoleProjectOwner}, + }) + require.NoError(t, err) +} diff --git a/proto/zitadel/user/v2beta/email.proto b/proto/zitadel/user/v2beta/email.proto index 6e0c3ada0b..8a76a2eb0d 100644 --- a/proto/zitadel/user/v2beta/email.proto +++ b/proto/zitadel/user/v2beta/email.proto @@ -27,6 +27,18 @@ message SetHumanEmail { } } +message HumanEmail { + string email = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"mini@mouse.com\""; + } + ]; + bool is_verified = 2; +} + + message SendEmailVerificationCode { optional string url_template = 1 [ (validate.rules).string = {min_len: 1, max_len: 200}, diff --git a/proto/zitadel/user/v2beta/password.proto b/proto/zitadel/user/v2beta/password.proto index 8a615657ed..69fe5fc303 100644 --- a/proto/zitadel/user/v2beta/password.proto +++ b/proto/zitadel/user/v2beta/password.proto @@ -55,3 +55,31 @@ enum NotificationType { NOTIFICATION_TYPE_Email = 1; NOTIFICATION_TYPE_SMS = 2; } + +message SetPassword { + oneof password_type { + Password password = 1; + HashedPassword hashed_password = 2; + } + oneof verification { + string current_password = 3 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"Secr3tP4ssw0rd!\""; + } + ]; + string verification_code = 4 [ + (validate.rules).string = {min_len: 1, max_len: 20}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 20; + example: "\"SKJd342k\""; + description: "\"the verification code generated during password reset request\""; + } + ]; + } +} \ No newline at end of file diff --git a/proto/zitadel/user/v2beta/phone.proto b/proto/zitadel/user/v2beta/phone.proto index 75bb80c4b2..c71a725b29 100644 --- a/proto/zitadel/user/v2beta/phone.proto +++ b/proto/zitadel/user/v2beta/phone.proto @@ -24,6 +24,16 @@ message SetHumanPhone { } } +message HumanPhone { + string phone = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"+41791234567\""; + } + ]; + bool is_verified = 2; +} + message SendPhoneVerificationCode {} message ReturnPhoneVerificationCode {} diff --git a/proto/zitadel/user/v2beta/user.proto b/proto/zitadel/user/v2beta/user.proto index ab1b5c5241..57482a23dd 100644 --- a/proto/zitadel/user/v2beta/user.proto +++ b/proto/zitadel/user/v2beta/user.proto @@ -7,10 +7,9 @@ option go_package = "github.com/zitadel/zitadel/pkg/grpc/user/v2beta;user"; import "google/api/field_behavior.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; import "validate/validate.proto"; - -message User { - string id = 1; -} +import "zitadel/object/v2beta/object.proto"; +import "zitadel/user/v2beta/email.proto"; +import "zitadel/user/v2beta/phone.proto"; enum Gender { GENDER_UNSPECIFIED = 0; @@ -66,6 +65,45 @@ message SetHumanProfile { ]; } +message HumanProfile { + string given_name = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"Minnie\""; + } + ]; + string family_name = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"Mouse\""; + } + ]; + optional string nick_name = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"Mini\""; + } + ]; + optional string display_name = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"Minnie Mouse\""; + } + ]; + optional string preferred_language = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 10; + example: "\"en\""; + } + ]; + optional zitadel.user.v2beta.Gender gender = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"GENDER_FEMALE\""; + } + ]; +} message SetMetadataEntry { string key = 1 [ @@ -88,3 +126,44 @@ message SetMetadataEntry { } ]; } + +message HumanUser { + string user_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\""; + } + ]; + UserState state = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "current state of the user"; + } + ]; + string username = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"minnie-mouse\""; + } + ]; + repeated string login_names = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"gigi@zitadel.com\", \"gigi@zitadel.zitadel.ch\"]"; + } + ]; + string preferred_login_name = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"gigi@zitadel.com\""; + } + ]; + HumanProfile profile = 6; + HumanEmail email = 7; + HumanPhone phone = 8; +} + +enum UserState { + USER_STATE_UNSPECIFIED = 0; + USER_STATE_ACTIVE = 1; + USER_STATE_INACTIVE = 2; + USER_STATE_DELETED = 3; + USER_STATE_LOCKED = 4; + USER_STATE_SUSPEND = 5; + USER_STATE_INITIAL = 6; +} \ No newline at end of file diff --git a/proto/zitadel/user/v2beta/user_service.proto b/proto/zitadel/user/v2beta/user_service.proto index 36517dc4c5..0a34f7c558 100644 --- a/proto/zitadel/user/v2beta/user_service.proto +++ b/proto/zitadel/user/v2beta/user_service.proto @@ -238,6 +238,149 @@ service UserService { }; } + rpc UpdateHumanUser(UpdateHumanUserRequest) returns (UpdateHumanUserResponse) { + option (google.api.http) = { + put: "/v2beta/users/{user_id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Update User"; + description: "Update all information from a user." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc DeactivateUser(DeactivateUserRequest) returns (DeactivateUserResponse) { + option (google.api.http) = { + post: "/v2beta/users/{user_id}/deactivate" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Deactivate user"; + description: "The state of the user will be changed to 'deactivated'. The user will not be able to log in anymore. The endpoint returns an error if the user is already in the state 'deactivated'. Use deactivate user when the user should not be able to use the account anymore, but you still need access to the user data." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc ReactivateUser(ReactivateUserRequest) returns (ReactivateUserResponse) { + option (google.api.http) = { + post: "/v2beta/users/{user_id}/reactivate" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Reactivate user"; + description: "Reactivate a user with the state 'deactivated'. The user will be able to log in again afterward. The endpoint returns an error if the user is not in the state 'deactivated'." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc LockUser(LockUserRequest) returns (LockUserResponse) { + option (google.api.http) = { + post: "/v2beta/users/{user_id}/lock" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Lock user"; + description: "The state of the user will be changed to 'locked'. The user will not be able to log in anymore. The endpoint returns an error if the user is already in the state 'locked'. Use this endpoint if the user should not be able to log in temporarily because of an event that happened (wrong password, etc.)" + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc UnlockUser(UnlockUserRequest) returns (UnlockUserResponse) { + option (google.api.http) = { + post: "/v2beta/users/{user_id}/unlock" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Unlock user"; + description: "Unlock a user with the state 'locked'. The user will be able to log in again afterward. The endpoint returns an error if the user is not in the state 'locked'." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc DeleteUser(DeleteUserRequest) returns (DeleteUserResponse) { + option (google.api.http) = { + delete: "/v2beta/users/{user_id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "user.delete" + } + }; + + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Delete user"; + description: "The state of the user will be changed to 'deleted'. The user will not be able to log in anymore. Endpoints requesting this user will return an error 'User not found" + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + rpc RegisterPasskey (RegisterPasskeyRequest) returns (RegisterPasskeyResponse) { option (google.api.http) = { post: "/v2beta/users/{user_id}/passkeys" @@ -804,6 +947,133 @@ message VerifyPhoneResponse{ zitadel.object.v2beta.Details details = 1; } +message DeleteUserRequest { + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629012906488334\""; + }]; +} + +message DeleteUserResponse { + zitadel.object.v2beta.Details details = 1; +} + +message GetUserByIDRequest { + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629012906488334\""; + description: "User ID of the user you like to get." + } + ]; +} + +message GetUserByIDResponse { + zitadel.object.v2beta.Details details = 1; + HumanUser user = 2; +} + +message UpdateHumanUserRequest{ + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\""; + } + ]; + optional string username = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"minnie-mouse\""; + } + ]; + optional SetHumanProfile profile = 3; + optional SetHumanEmail email = 4; + optional SetHumanPhone phone = 5; + optional SetPassword password = 6; +} + +message UpdateHumanUserResponse { + zitadel.object.v2beta.Details details = 1; + optional string email_code = 2; + optional string phone_code = 3; +} + +message DeactivateUserRequest { + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629012906488334\""; + } + ]; +} + +message DeactivateUserResponse { + zitadel.object.v2beta.Details details = 1; +} + + +message ReactivateUserRequest { + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629012906488334\""; + } + ]; +} + +message ReactivateUserResponse { + zitadel.object.v2beta.Details details = 1; +} + +message LockUserRequest { + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629012906488334\""; + } + ]; +} + +message LockUserResponse { + zitadel.object.v2beta.Details details = 1; +} + +message UnlockUserRequest { + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629012906488334\""; + } + ]; +} + +message UnlockUserResponse { + zitadel.object.v2beta.Details details = 1; +} + message RegisterPasskeyRequest{ string user_id = 1 [ (validate.rules).string = {min_len: 1, max_len: 200},