diff --git a/backend/v3/api/session/v2/convert/session_check_user.go b/backend/v3/api/session/v2/convert/session_check_user.go new file mode 100644 index 000000000..e40ec40cf --- /dev/null +++ b/backend/v3/api/session/v2/convert/session_check_user.go @@ -0,0 +1,25 @@ +package convert + +import ( + "github.com/zitadel/zitadel/backend/v3/domain" + "github.com/zitadel/zitadel/internal/zerrors" + session_grpc "github.com/zitadel/zitadel/pkg/grpc/session/v2" +) + +func CheckUserGRPCToQueryOpt(checkUser *session_grpc.CheckUser) (*domain.CheckUserType, error) { + var toReturn *domain.CheckUserType + if checkUser == nil { + return toReturn, nil + } + + switch searchType := checkUser.GetSearch().(type) { + case *session_grpc.CheckUser_UserId: + toReturn = &domain.CheckUserType{UserID: searchType.UserId} + case *session_grpc.CheckUser_LoginName: + toReturn = &domain.CheckUserType{LoginName: searchType.LoginName} + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "CONV-7B2m0b", "user search %T not implemented", searchType) + } + + return toReturn, nil +} diff --git a/backend/v3/api/session/v2/convert/session_check_user_test.go b/backend/v3/api/session/v2/convert/session_check_user_test.go new file mode 100644 index 000000000..0e6bb178f --- /dev/null +++ b/backend/v3/api/session/v2/convert/session_check_user_test.go @@ -0,0 +1,57 @@ +package convert + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/backend/v3/domain" + "github.com/zitadel/zitadel/internal/zerrors" + session_grpc "github.com/zitadel/zitadel/pkg/grpc/session/v2" +) + +func TestCheckUserGRPCToQueryOpt(t *testing.T) { + t.Parallel() + tt := []struct { + testName string + inputCheckUser *session_grpc.CheckUser + expectedError error + expectedDomainCheckUser *domain.CheckUserType + }{ + { + testName: "when input checkUser is nil should return nil, nil", + }, + { + testName: "when input checkUser query is not matching type should return invalid argument error", + inputCheckUser: &session_grpc.CheckUser{}, + expectedError: zerrors.ThrowInvalidArgumentf(nil, "CONV-7B2m0b", "user search %T not implemented", nil), + }, + { + testName: "when input checkUser query is UserID should set user id on domain CheckUserType", + inputCheckUser: &session_grpc.CheckUser{ + Search: &session_grpc.CheckUser_UserId{UserId: "user-123"}, + }, + expectedDomainCheckUser: &domain.CheckUserType{UserID: "user-123"}, + }, + { + testName: "when input checkUser query is LoginName should set user id on domain CheckUserType", + inputCheckUser: &session_grpc.CheckUser{ + Search: &session_grpc.CheckUser_LoginName{LoginName: "user@example.com"}, + }, + expectedDomainCheckUser: &domain.CheckUserType{LoginName: "user@example.com"}, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // Test + res, err := CheckUserGRPCToQueryOpt(tc.inputCheckUser) + + // Verify + assert.ErrorIs(t, err, tc.expectedError) + assert.Equal(t, tc.expectedDomainCheckUser, res) + }) + } +} diff --git a/backend/v3/domain/invoke_option.go b/backend/v3/domain/invoke_option.go index feff12eaa..1fb8a1fbf 100644 --- a/backend/v3/domain/invoke_option.go +++ b/backend/v3/domain/invoke_option.go @@ -39,6 +39,24 @@ func WithInstanceDomainRepo(repo InstanceDomainRepository) InvokeOpt { } } +func WithSessionRepo(repo SessionRepository) InvokeOpt { + return func(opts *InvokeOpts) { + opts.sessionRepo = repo + } +} + +func WithUserRepo(repo UserRepository) InvokeOpt { + return func(opts *InvokeOpts) { + opts.userRepo = repo + } +} + +func WithPermissionChecker(checker PermissionChecker) InvokeOpt { + return func(opts *InvokeOpts) { + opts.Permissions = checker + } +} + // WithQueryExecutor sets the database client to be used by the command. // If not set, the default pool will be used. // This is mainly used for testing. @@ -73,6 +91,8 @@ type InvokeOpts struct { projectRepo ProjectRepository instanceRepo InstanceRepository instanceDomainRepo InstanceDomainRepository + sessionRepo SessionRepository + userRepo UserRepository } func (o *InvokeOpts) DB() database.QueryExecutor { diff --git a/backend/v3/domain/mock/permission.mock.go b/backend/v3/domain/mock/permission.mock.go index 24edd20eb..c7cf38a4b 100644 --- a/backend/v3/domain/mock/permission.mock.go +++ b/backend/v3/domain/mock/permission.mock.go @@ -191,3 +191,41 @@ func (c *MockPermissionCheckerCheckProjectPermissionCall) DoAndReturn(f func(con c.Call = c.Call.DoAndReturn(f) return c } + +// CheckSessionPermission mocks base method. +func (m *MockPermissionChecker) CheckSessionPermission(ctx context.Context, permission, sessionID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CheckSessionPermission", ctx, permission, sessionID) + ret0, _ := ret[0].(error) + return ret0 +} + +// CheckSessionPermission indicates an expected call of CheckSessionPermission. +func (mr *MockPermissionCheckerMockRecorder) CheckSessionPermission(ctx, permission, sessionID any) *MockPermissionCheckerCheckSessionPermissionCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CheckSessionPermission", reflect.TypeOf((*MockPermissionChecker)(nil).CheckSessionPermission), ctx, permission, sessionID) + return &MockPermissionCheckerCheckSessionPermissionCall{Call: call} +} + +// MockPermissionCheckerCheckSessionPermissionCall wrap *gomock.Call +type MockPermissionCheckerCheckSessionPermissionCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockPermissionCheckerCheckSessionPermissionCall) Return(arg0 error) *MockPermissionCheckerCheckSessionPermissionCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockPermissionCheckerCheckSessionPermissionCall) Do(f func(context.Context, string, string) error) *MockPermissionCheckerCheckSessionPermissionCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockPermissionCheckerCheckSessionPermissionCall) DoAndReturn(f func(context.Context, string, string) error) *MockPermissionCheckerCheckSessionPermissionCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/backend/v3/domain/mock/session_repo.go b/backend/v3/domain/mock/session_repo.go new file mode 100644 index 000000000..6f4c195c2 --- /dev/null +++ b/backend/v3/domain/mock/session_repo.go @@ -0,0 +1,47 @@ +package domainmock + +import ( + context "context" + + gomock "go.uber.org/mock/gomock" + + domain "github.com/zitadel/zitadel/backend/v3/domain" + database "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/backend/v3/storage/database/repository" +) + +func NewSessionRepo(ctrl *gomock.Controller) *SessionRepo { + return &SessionRepo{ + mock: NewMockSessionRepository(ctrl), + SessionRepository: repository.SessionRepository(), + } +} + +type SessionRepo struct { + domain.SessionRepository + mock *MockSessionRepository +} + +func (s *SessionRepo) EXPECT() *MockSessionRepositoryMockRecorder { + return s.mock.EXPECT() +} + +func (s *SessionRepo) Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*domain.Session, error) { + return s.mock.Get(ctx, client, opts...) +} + +func (s *SessionRepo) List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*domain.Session, error) { + return s.mock.List(ctx, client, opts...) +} + +func (s *SessionRepo) Create(ctx context.Context, client database.QueryExecutor, user *domain.Session) error { + return s.mock.Create(ctx, client, user) +} + +func (s *SessionRepo) Update(ctx context.Context, client database.QueryExecutor, condition database.Condition, changes ...database.Change) (int64, error) { + return s.mock.Update(ctx, client, condition, changes...) +} + +func (s *SessionRepo) Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition) (int64, error) { + return s.mock.Delete(ctx, client, condition) +} diff --git a/backend/v3/domain/mock/user_repo.go b/backend/v3/domain/mock/user_repo.go new file mode 100644 index 000000000..e96696fb8 --- /dev/null +++ b/backend/v3/domain/mock/user_repo.go @@ -0,0 +1,51 @@ +package domainmock + +import ( + context "context" + + gomock "go.uber.org/mock/gomock" + + domain "github.com/zitadel/zitadel/backend/v3/domain" + database "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/backend/v3/storage/database/repository" +) + +func NewUserRepo(ctrl *gomock.Controller) *UserRepo { + return &UserRepo{ + mock: NewMockUserRepository(ctrl), + UserRepository: repository.UserRepository(), + } +} + +type UserRepo struct { + domain.UserRepository + mock *MockUserRepository +} + +func (u *UserRepo) EXPECT() *MockUserRepositoryMockRecorder { + return u.mock.EXPECT() +} + +func (u *UserRepo) Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*domain.User, error) { + return u.mock.Get(ctx, client, opts...) +} + +func (u *UserRepo) List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*domain.User, error) { + return u.mock.List(ctx, client, opts...) +} + +func (u *UserRepo) Create(ctx context.Context, client database.QueryExecutor, user *domain.User) error { + return u.mock.Create(ctx, client, user) +} + +func (u *UserRepo) Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition) (int64, error) { + return u.mock.Delete(ctx, client, condition) +} + +func (u *UserRepo) Human() domain.HumanUserRepository { + return u.mock.Human() +} + +func (u *UserRepo) Machine() domain.MachineUserRepository { + return u.mock.Machine() +} diff --git a/backend/v3/domain/permission.go b/backend/v3/domain/permission.go index 64eb92c3b..efedfbe48 100644 --- a/backend/v3/domain/permission.go +++ b/backend/v3/domain/permission.go @@ -9,6 +9,8 @@ const ( DomainWritePermission = "domain.write" OrganizationReadPermission = "organization.read" OrganizationWritePermission = "organization.write" + SessionReadPermission = "session.read" + SessionWritePermission = "session.write" ) // PermissionRepository is the interface that manages and checks Zitadel permissions. @@ -36,10 +38,19 @@ type PermissionChecker interface { // Check if the authenticated user has the given permission on the given project grant. // A permission may be inherited from the instance or granted organization level. CheckProjectGrantPermission(ctx context.Context, permission, projectGrantID string) error + + // Check if the user in context has the given permission on the given session. + // A permission may be inherited from the instance or granted organization level. + CheckSessionPermission(ctx context.Context, permission, sessionID string) error } type noopPermissionChecker struct{} +// CheckSessionPermission implements [PermissionChecker]. +func (n *noopPermissionChecker) CheckSessionPermission(ctx context.Context, permission string, sessionID string) error { + return nil +} + var _ PermissionChecker = (*noopPermissionChecker)(nil) func (*noopPermissionChecker) CheckInstancePermission(context.Context, string) error { diff --git a/backend/v3/domain/session_check_user.go b/backend/v3/domain/session_check_user.go new file mode 100644 index 000000000..a1fac2326 --- /dev/null +++ b/backend/v3/domain/session_check_user.go @@ -0,0 +1,168 @@ +package domain + +import ( + "context" + "errors" + "time" + + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/session" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type CheckUserType struct { + UserID string + LoginName string +} + +type UserCheckCommand struct { + InputCheckUser *CheckUserType + SessionID string + InstanceID string + + FetchedUser User + + // Out + PreferredUserLanguage *language.Tag + UserCheckedAt time.Time +} + +// NewUserCheckCommand returns a check Commander validating the input user. +// +// It assumes that a [Session] already exists: this check should be part of the +// batch call to create/set a session. +// +// The check will update the existing session or return an error if the session +// is not found or validation fails. +func NewUserCheckCommand(sessionID, instanceID string, checkUser *CheckUserType) *UserCheckCommand { + return &UserCheckCommand{ + SessionID: sessionID, + InstanceID: instanceID, + InputCheckUser: checkUser, + } +} + +// RequiresTransaction implements [Transactional]. +func (u *UserCheckCommand) RequiresTransaction() {} + +// Events implements [Commander]. +func (u *UserCheckCommand) Events(ctx context.Context, opts *InvokeOpts) ([]eventstore.Command, error) { + if u.InputCheckUser == nil { + return nil, nil + } + + return []eventstore.Command{ + session.NewUserCheckedEvent( + ctx, + &session.NewAggregate(u.SessionID, u.InstanceID).Aggregate, + u.FetchedUser.ID, + u.FetchedUser.OrganizationID, + u.UserCheckedAt, + u.PreferredUserLanguage, + ), + }, nil +} + +// Execute implements [Commander]. +func (u *UserCheckCommand) Execute(ctx context.Context, opts *InvokeOpts) (err error) { + if u.InputCheckUser == nil { + return err + } + sessionRepo := opts.sessionRepo + + if human := u.FetchedUser.Human; human != nil { + if !human.PreferredLanguage.IsRoot() { + u.PreferredUserLanguage = &human.PreferredLanguage + } + } + + session, err := sessionRepo.Get(ctx, opts.DB(), database.WithCondition(sessionRepo.IDCondition(u.SessionID))) + if err != nil { + if errors.Is(err, &database.NoRowFoundError{}) { + return zerrors.ThrowNotFound(err, "DOM-rbdCv3", "session not found") + } + return zerrors.ThrowInternal(err, "DOM-To1rLz", "failed fetching session") + } + + if session.UserID != "" && u.FetchedUser.ID != "" && session.UserID != u.FetchedUser.ID { + return zerrors.ThrowInvalidArgument(nil, "DOM-78g1TV", "user change not possible") + } + userFactor := &SessionFactorUser{ + UserID: u.FetchedUser.ID, + LastVerifiedAt: time.Now(), + } + + updateCount, err := sessionRepo.Update(ctx, opts.DB(), sessionRepo.IDCondition(session.ID), sessionRepo.SetFactor(userFactor)) + if err != nil { + return zerrors.ThrowInternal(err, "DOM-netNam", "failed updating session") + } + + if updateCount == 0 { + err = zerrors.ThrowNotFound(nil, "DOM-FszyWS", "session not found") + return err + } + if updateCount > 1 { + err = zerrors.ThrowInternal(NewMultipleObjectsUpdatedError(1, updateCount), "DOM-SsIwDt", "unexpected number of rows updated") + return err + } + + u.UserCheckedAt = userFactor.LastVerifiedAt + + return err +} + +// String implements [Commander]. +func (u *UserCheckCommand) String() string { + return "UserCheckCommand" +} + +// Validate implements [Commander]. +func (u *UserCheckCommand) Validate(ctx context.Context, opts *InvokeOpts) (err error) { + if u.InputCheckUser == nil { + return nil + } + + if u.SessionID == "" { + return zerrors.ThrowPreconditionFailed(nil, "DOM-00o0ys", "Errors.Missing.SessionID") + } + if u.InstanceID == "" { + return zerrors.ThrowPreconditionFailed(nil, "DOM-Oe1dtz", "Errors.Missing.InstanceID") + } + + if authZErr := opts.Permissions.CheckSessionPermission(ctx, SessionWritePermission, u.SessionID); authZErr != nil { + return zerrors.ThrowPermissionDenied(authZErr, "DOM-4qz3mt", "Errors.PermissionDenied") + } + + var usrQueryOpt database.QueryOption + userRepo := opts.userRepo + + if loginName := u.InputCheckUser.LoginName; loginName != "" { + usrQueryOpt = database.WithCondition(userRepo.LoginNameCondition(database.TextOperationEqual, loginName)) + } else if userID := u.InputCheckUser.UserID; userID != "" { + usrQueryOpt = database.WithCondition(userRepo.IDCondition(userID)) + } else { + return zerrors.ThrowInvalidArgument(nil, "DOM-7B2m0b", "no valid query option") + } + + usr, err := userRepo.Get(ctx, opts.DB(), usrQueryOpt) + if err != nil { + if errors.Is(err, &database.NoRowFoundError{}) { + return zerrors.ThrowNotFound(err, "DOM-lcZeXI", "user not found") + } + return zerrors.ThrowInternal(err, "DOM-Y846I0", "failed fetching user") + } + + if usr.State != UserStateActive { + return zerrors.ThrowPreconditionFailed(nil, "DOM-vgDIu9", "Errors.User.NotActive") + } + + u.FetchedUser = *usr + + return nil +} + +var _ Commander = (*UserCheckCommand)(nil) +var _ Transactional = (*UserCheckCommand)(nil) diff --git a/backend/v3/domain/session_check_user_test.go b/backend/v3/domain/session_check_user_test.go new file mode 100644 index 000000000..5c2dfc237 --- /dev/null +++ b/backend/v3/domain/session_check_user_test.go @@ -0,0 +1,735 @@ +package domain_test + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/backend/v3/domain" + domainmock "github.com/zitadel/zitadel/backend/v3/domain/mock" + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/backend/v3/storage/database/dbmock" + noopdb "github.com/zitadel/zitadel/backend/v3/storage/database/dialect/noop" + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/repository/session" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestUserCheckCommand_Validate(t *testing.T) { + t.Parallel() + getErr := errors.New("get error") + permissionErr := errors.New("permission denied") + notFoundErr := database.NewNoRowFoundError(nil) + + tt := []struct { + testName string + userRepo func(ctrl *gomock.Controller) domain.UserRepository + permissionChecker func(ctrl *gomock.Controller) domain.PermissionRepository + cmd *domain.UserCheckCommand + expectedError error + }{ + { + testName: "when CheckUser is nil should return no error", + cmd: &domain.UserCheckCommand{}, + expectedError: nil, + }, + { + testName: "when session ID is not set should return error", + cmd: &domain.UserCheckCommand{ + InputCheckUser: &domain.CheckUserType{UserID: "user-123"}, + }, + expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-00o0ys", "Errors.Missing.SessionID"), + }, + { + testName: "when instance ID is not set should return error", + cmd: &domain.UserCheckCommand{ + InputCheckUser: &domain.CheckUserType{UserID: "user-123"}, + SessionID: "session-1", + }, + expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-Oe1dtz", "Errors.Missing.InstanceID"), + }, + { + testName: "when permission check fails should return permission denied error", + permissionChecker: func(ctrl *gomock.Controller) domain.PermissionRepository { + repo := domainmock.NewMockPermissionChecker(ctrl) + + repo.EXPECT(). + CheckSessionPermission(gomock.Any(), domain.SessionWritePermission, "session-1"). + Times(1). + Return(permissionErr) + + return repo + }, + cmd: &domain.UserCheckCommand{ + InputCheckUser: &domain.CheckUserType{UserID: "user-123"}, + SessionID: "session-1", + InstanceID: "instance-1", + }, + expectedError: zerrors.ThrowPermissionDenied(permissionErr, "DOM-4qz3mt", "Errors.PermissionDenied"), + }, + { + testName: "when search type is UserId should fetch user by ID", + permissionChecker: func(ctrl *gomock.Controller) domain.PermissionRepository { + repo := domainmock.NewMockPermissionChecker(ctrl) + + repo.EXPECT(). + CheckSessionPermission(gomock.Any(), domain.SessionWritePermission, "session-1"). + Times(1). + Return(nil) + + return repo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + repo := domainmock.NewUserRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions( + database.WithCondition( + repo.IDCondition("user-123"), + ), + )). + Times(1). + Return(&domain.User{ + ID: "user-123", + OrganizationID: "org-1", + State: domain.UserStateActive, + }, nil) + return repo + }, + cmd: &domain.UserCheckCommand{ + InputCheckUser: &domain.CheckUserType{UserID: "user-123"}, + SessionID: "session-1", + InstanceID: "instance-1", + }, + expectedError: nil, + }, + { + testName: "when user not active should return precondition failed error", + permissionChecker: func(ctrl *gomock.Controller) domain.PermissionRepository { + repo := domainmock.NewMockPermissionChecker(ctrl) + + repo.EXPECT(). + CheckSessionPermission(gomock.Any(), domain.SessionWritePermission, "session-1"). + Times(1). + Return(nil) + + return repo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + repo := domainmock.NewUserRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions( + database.WithCondition( + repo.IDCondition("user-123"), + ), + )). + Times(1). + Return(&domain.User{ + ID: "user-123", + OrganizationID: "org-1", + State: domain.UserStateInactive, + }, nil) + return repo + }, + cmd: &domain.UserCheckCommand{ + InputCheckUser: &domain.CheckUserType{UserID: "user-123"}, + SessionID: "session-1", + InstanceID: "instance-1", + }, + expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-vgDIu9", "Errors.User.NotActive"), + }, + { + testName: "when user retrieval fails should return error", + permissionChecker: func(ctrl *gomock.Controller) domain.PermissionRepository { + repo := domainmock.NewMockPermissionChecker(ctrl) + + repo.EXPECT(). + CheckSessionPermission(gomock.Any(), domain.SessionWritePermission, "session-1"). + Times(1). + Return(nil) + + return repo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + repo := domainmock.NewUserRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions( + database.WithCondition( + repo.IDCondition("user-123"), + ), + )). + Times(1). + Return(nil, getErr) + return repo + }, + cmd: &domain.UserCheckCommand{ + InputCheckUser: &domain.CheckUserType{UserID: "user-123"}, + SessionID: "session-1", + InstanceID: "instance-1", + }, + expectedError: zerrors.ThrowInternal(getErr, "DOM-Y846I0", "failed fetching user"), + }, + { + testName: "when user not found should return not found error", + permissionChecker: func(ctrl *gomock.Controller) domain.PermissionRepository { + repo := domainmock.NewMockPermissionChecker(ctrl) + + repo.EXPECT(). + CheckSessionPermission(gomock.Any(), domain.SessionWritePermission, "session-1"). + Times(1). + Return(nil) + + return repo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + repo := domainmock.NewUserRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions( + database.WithCondition( + repo.IDCondition("user-123"), + ), + )). + Times(1). + Return(nil, notFoundErr) + return repo + }, + cmd: &domain.UserCheckCommand{ + InputCheckUser: &domain.CheckUserType{UserID: "user-123"}, + SessionID: "session-1", + InstanceID: "instance-1", + }, + expectedError: zerrors.ThrowNotFound(notFoundErr, "DOM-lcZeXI", "user not found"), + }, + { + testName: "when search type is LoginName should attempt to fetch by login name", + permissionChecker: func(ctrl *gomock.Controller) domain.PermissionRepository { + repo := domainmock.NewMockPermissionChecker(ctrl) + + repo.EXPECT(). + CheckSessionPermission(gomock.Any(), domain.SessionWritePermission, "session-1"). + Times(1). + Return(nil) + + return repo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + repo := domainmock.NewUserRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions( + database.WithCondition( + repo.LoginNameCondition(database.TextOperationEqual, "user@example.com"), + ), + )). + Times(1). + Return(&domain.User{ + ID: "user-123", + LoginNames: []domain.LoginName{{LoginName: "user@example.com"}}, + OrganizationID: "org-1", + State: domain.UserStateActive, + }, nil) + return repo + }, + cmd: &domain.UserCheckCommand{ + InputCheckUser: &domain.CheckUserType{LoginName: "user@example.com"}, + SessionID: "session-1", + InstanceID: "instance-1", + }, + expectedError: nil, + }, + { + testName: "when search type is unknown should return invalid argument error", + permissionChecker: func(ctrl *gomock.Controller) domain.PermissionRepository { + repo := domainmock.NewMockPermissionChecker(ctrl) + + repo.EXPECT(). + CheckSessionPermission(gomock.Any(), domain.SessionWritePermission, "session-1"). + Times(1). + Return(nil) + + return repo + }, + cmd: &domain.UserCheckCommand{ + InputCheckUser: &domain.CheckUserType{}, + SessionID: "session-1", + InstanceID: "instance-1", + }, + expectedError: zerrors.ThrowInvalidArgumentf(nil, "DOM-7B2m0b", "no valid query option"), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + // Given + ctx := authz.NewMockContext(tc.cmd.InstanceID, "", "") + ctrl := gomock.NewController(t) + cmd := domain.NewUserCheckCommand(tc.cmd.SessionID, tc.cmd.InstanceID, tc.cmd.InputCheckUser) + + opts := &domain.InvokeOpts{ + Invoker: domain.NewTransactionInvoker(nil), + } + domain.WithQueryExecutor(new(noopdb.Pool))(opts) + if tc.userRepo != nil { + domain.WithUserRepo(tc.userRepo(ctrl))(opts) + } + if tc.permissionChecker != nil { + domain.WithPermissionChecker(tc.permissionChecker(ctrl))(opts) + } + + // Test + err := cmd.Validate(ctx, opts) + + // Verify + assert.ErrorIs(t, err, tc.expectedError) + }) + } +} + +func TestUserCheckCommand_Execute(t *testing.T) { + t.Parallel() + + sessionNotFoundErr := database.NewNoRowFoundError(nil) + getErr := errors.New("get error") + updateErr := errors.New("update error") + + tt := []struct { + testName string + sessionRepo func(ctrl *gomock.Controller) domain.SessionRepository + cmd *domain.UserCheckCommand + expectedError error + expectedPreferredLanguage *language.Tag + }{ + { + testName: "when CheckUser is nil should return no error", + cmd: &domain.UserCheckCommand{}, + expectedError: nil, + }, + { + testName: "when session retrieval fails should return error", + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions( + database.WithCondition( + repo.IDCondition("session-1"), + ), + )). + Times(1). + Return(nil, getErr) + return repo + }, + cmd: &domain.UserCheckCommand{ + InputCheckUser: &domain.CheckUserType{UserID: "user-123"}, + SessionID: "session-1", + FetchedUser: domain.User{ + ID: "user-123", + OrganizationID: "org-1", + }, + }, + expectedError: zerrors.ThrowInternal(getErr, "DOM-To1rLz", "failed fetching session"), + }, + { + testName: "when session not found should return not found error", + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions( + database.WithCondition( + repo.IDCondition("session-1"), + ), + )). + Times(1). + Return(nil, sessionNotFoundErr) + return repo + }, + cmd: &domain.UserCheckCommand{ + InputCheckUser: &domain.CheckUserType{UserID: "user-123"}, + SessionID: "session-1", + FetchedUser: domain.User{ + ID: "user-123", + OrganizationID: "org-1", + Human: &domain.HumanUser{ + PreferredLanguage: language.Italian, + }, + }, + }, + expectedError: zerrors.ThrowNotFound(sessionNotFoundErr, "DOM-rbdCv3", "session not found"), + }, + { + testName: "when user change is attempted should return invalid argument error", + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions( + database.WithCondition( + repo.IDCondition("session-1"), + ), + )). + Times(1). + Return(&domain.Session{ + ID: "session-1", + UserID: "user-456", + }, nil) + return repo + }, + cmd: &domain.UserCheckCommand{ + InputCheckUser: &domain.CheckUserType{UserID: "user-123"}, + SessionID: "session-1", + InstanceID: "instance-1", + FetchedUser: domain.User{ + ID: "user-123", + OrganizationID: "org-1", + }, + }, + expectedError: zerrors.ThrowInvalidArgument(nil, "DOM-78g1TV", "user change not possible"), + }, + { + testName: "when session update fails should return error", + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions( + database.WithCondition( + repo.IDCondition("session-1"), + ), + )). + Times(1). + Return(&domain.Session{ + ID: "session-1", + UserID: "user-123", + }, nil) + + repo.EXPECT(). + Update(gomock.Any(), gomock.Any(), repo.IDCondition("session-1"), repo.SetFactor(&domain.SessionFactorUser{ + UserID: "user-123", + LastVerifiedAt: time.Now(), + })). + Times(1). + Return(int64(0), updateErr) + return repo + }, + cmd: &domain.UserCheckCommand{ + InputCheckUser: &domain.CheckUserType{UserID: "user-123"}, + SessionID: "session-1", + InstanceID: "instance-1", + FetchedUser: domain.User{ + ID: "user-123", + OrganizationID: "org-1", + }, + }, + expectedError: zerrors.ThrowInternal(updateErr, "DOM-netNam", "failed updating session"), + }, + { + testName: "when session update returns no rows should return not found error", + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions( + database.WithCondition( + repo.IDCondition("session-1"), + ), + )). + Times(1). + Return(&domain.Session{ + ID: "session-1", + UserID: "", + }, nil) + + repo.EXPECT(). + Update(gomock.Any(), gomock.Any(), repo.IDCondition("session-1"), repo.SetFactor(&domain.SessionFactorUser{ + UserID: "user-123", + LastVerifiedAt: time.Now(), + })). + Times(1). + Return(int64(0), nil) + return repo + }, + cmd: &domain.UserCheckCommand{ + InputCheckUser: &domain.CheckUserType{UserID: "user-123"}, + SessionID: "session-1", + InstanceID: "instance-1", + FetchedUser: domain.User{ + ID: "user-123", + OrganizationID: "org-1", + }, + }, + expectedError: zerrors.ThrowNotFound(nil, "DOM-FszyWS", "session not found"), + }, + { + testName: "when session update returns multiple rows should return internal error", + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions( + database.WithCondition( + repo.IDCondition("session-1"), + ), + )). + Times(1). + Return(&domain.Session{ + ID: "session-1", + UserID: "", + }, nil) + + repo.EXPECT(). + Update(gomock.Any(), gomock.Any(), repo.IDCondition("session-1"), repo.SetFactor(&domain.SessionFactorUser{ + UserID: "user-123", + LastVerifiedAt: time.Now(), + })). + Times(1). + Return(int64(2), nil) + return repo + }, + cmd: &domain.UserCheckCommand{ + InputCheckUser: &domain.CheckUserType{UserID: "user-123"}, + SessionID: "session-1", + InstanceID: "instance-1", + FetchedUser: domain.User{ + ID: "user-123", + OrganizationID: "org-1", + }, + }, + expectedError: zerrors.ThrowInternal(domain.NewMultipleObjectsUpdatedError(1, 2), "DOM-SsIwDt", "unexpected number of rows updated"), + }, + { + testName: "when session has no user and user is set should execute successfully", + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions( + database.WithCondition( + repo.IDCondition("session-1"), + ), + )). + Times(1). + Return(&domain.Session{ + ID: "session-1", + UserID: "", + }, nil) + + repo.EXPECT(). + Update(gomock.Any(), gomock.Any(), repo.IDCondition("session-1"), repo.SetFactor(&domain.SessionFactorUser{ + UserID: "user-123", + LastVerifiedAt: time.Now(), + })). + Times(1). + Return(int64(1), nil) + return repo + }, + cmd: &domain.UserCheckCommand{ + InputCheckUser: &domain.CheckUserType{UserID: "user-123"}, + SessionID: "session-1", + InstanceID: "instance-1", + FetchedUser: domain.User{ + ID: "user-123", + OrganizationID: "org-1", + Human: &domain.HumanUser{ + PreferredLanguage: language.Albanian, + }, + }, + }, + expectedPreferredLanguage: &language.Albanian, + }, + { + testName: "when session user matches fetched user should execute successfully", + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions( + database.WithCondition( + repo.IDCondition("session-1"), + ), + )). + Times(1). + Return(&domain.Session{ + ID: "session-1", + UserID: "user-123", + }, nil) + + repo.EXPECT(). + Update(gomock.Any(), gomock.Any(), repo.IDCondition("session-1"), repo.SetFactor(&domain.SessionFactorUser{ + UserID: "user-123", + LastVerifiedAt: time.Now(), + })). + Times(1). + Return(int64(1), nil) + return repo + }, + cmd: &domain.UserCheckCommand{ + InputCheckUser: &domain.CheckUserType{UserID: "user-123"}, + SessionID: "session-1", + InstanceID: "instance-1", + FetchedUser: domain.User{ + ID: "user-123", + OrganizationID: "org-1", + }, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + // Given + ctx := authz.NewMockContext("instance-1", "", "") + ctrl := gomock.NewController(t) + opts := &domain.InvokeOpts{ + Invoker: domain.NewTransactionInvoker(nil), + } + domain.WithQueryExecutor(new(noopdb.Pool))(opts) + if tc.sessionRepo != nil { + domain.WithSessionRepo(tc.sessionRepo(ctrl))(opts) + } + + // Test + err := tc.cmd.Execute(ctx, opts) + + // Verify + assert.ErrorIs(t, err, tc.expectedError) + if tc.expectedError == nil && tc.cmd.InputCheckUser != nil { + assert.NotZero(t, tc.cmd.UserCheckedAt) + assert.Equal(t, tc.expectedPreferredLanguage, tc.cmd.PreferredUserLanguage) + } + }) + } +} + +func TestUserCheckCommand_Events(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + cmd *domain.UserCheckCommand + expectedEventLen int + expectedError error + }{ + { + testName: "when CheckUser is nil should return nil", + cmd: &domain.UserCheckCommand{}, + expectedEventLen: 0, + expectedError: nil, + }, + { + testName: "when CheckUser is set should return user checked event", + cmd: &domain.UserCheckCommand{ + SessionID: "session-1", + InstanceID: "instance-1", + FetchedUser: domain.User{ID: "user-123", OrganizationID: "org-1"}, + UserCheckedAt: time.Now(), + InputCheckUser: &domain.CheckUserType{UserID: "user-123"}, + }, + expectedEventLen: 1, + expectedError: nil, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + // Given + ctx := authz.NewMockContext(tc.cmd.InstanceID, "", "") + opts := &domain.InvokeOpts{ + Invoker: domain.NewTransactionInvoker(nil), + } + + // Test + events, err := tc.cmd.Events(ctx, opts) + + // Verify + assert.Equal(t, tc.expectedError, err) + require.Len(t, events, tc.expectedEventLen) + if tc.expectedEventLen != 0 { + usrCheckedEvent, ok := events[0].(*session.UserCheckedEvent) + require.True(t, ok) + + assert.Equal(t, tc.cmd.FetchedUser.ID, usrCheckedEvent.UserID) + assert.Equal(t, tc.cmd.FetchedUser.OrganizationID, usrCheckedEvent.UserResourceOwner) + assert.NotZero(t, usrCheckedEvent.CheckedAt) + } + }) + } +} + +func TestNewUserCheckCommand(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + sessionID string + instanceID string + checkUser *domain.CheckUserType + expected *domain.UserCheckCommand + }{ + { + testName: "should create command with provided sessionID, instanceID and checkUserType", + sessionID: "session-1", + instanceID: "instance-1", + checkUser: &domain.CheckUserType{}, + expected: &domain.UserCheckCommand{ + SessionID: "session-1", + InstanceID: "instance-1", + InputCheckUser: &domain.CheckUserType{}, + }, + }, + { + testName: "should create command with empty sessionID", + sessionID: "", + instanceID: "instance-1", + checkUser: &domain.CheckUserType{}, + expected: &domain.UserCheckCommand{ + SessionID: "", + InstanceID: "instance-1", + InputCheckUser: &domain.CheckUserType{}, + }, + }, + { + testName: "should create command with empty instanceID", + sessionID: "session-1", + instanceID: "", + checkUser: &domain.CheckUserType{}, + expected: &domain.UserCheckCommand{ + SessionID: "session-1", + InstanceID: "", + InputCheckUser: &domain.CheckUserType{}, + }, + }, + { + testName: "should create command with nil checkUserType", + sessionID: "session-1", + instanceID: "instance-1", + expected: &domain.UserCheckCommand{ + SessionID: "session-1", + InstanceID: "instance-1", + }, + }, + { + testName: "should create command with all empty", + sessionID: "", + instanceID: "", + expected: &domain.UserCheckCommand{ + SessionID: "", + InstanceID: "", + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + // Test + cmd := domain.NewUserCheckCommand(tc.sessionID, tc.instanceID, tc.checkUser) + + // Verify + assert.Equal(t, tc.expected.SessionID, cmd.SessionID) + assert.Equal(t, tc.expected.InstanceID, cmd.InstanceID) + assert.Equal(t, tc.expected.InputCheckUser, cmd.InputCheckUser) + assert.Zero(t, cmd.UserCheckedAt) + assert.Nil(t, cmd.PreferredUserLanguage) + assert.Zero(t, cmd.FetchedUser) + }) + } +} diff --git a/backend/v3/storage/database/change.go b/backend/v3/storage/database/change.go index a5427b938..7fe2b4f43 100644 --- a/backend/v3/storage/database/change.go +++ b/backend/v3/storage/database/change.go @@ -1,6 +1,7 @@ package database import ( + "fmt" "reflect" "slices" @@ -126,15 +127,15 @@ func (c Changes) Write(builder *StatementBuilder) error { // Matches implements [gomock.Matcher]. func (c Changes) Matches(x any) bool { - toMatch, ok := x.(*Changes) + toMatch, ok := x.(Changes) if !ok { return false } - if len(c) != len(*toMatch) { + if len(c) != len(toMatch) { return false } for i := range c { - if !c[i].Matches((*toMatch)[i]) { + if !c[i].Matches(toMatch[i]) { return false } } @@ -384,7 +385,26 @@ func (c *cteChange) Matches(x any) bool { if expectedCTEBuilder.String() != actualCTEBuilder.String() { return false } - return slices.Equal(expectedCTEBuilder.Args(), actualCTEBuilder.Args()) + + if len(expectedCTEBuilder.Args()) != len(actualCTEBuilder.Args()) { + return false + } + + for i, e := range expectedCTEBuilder.Args() { + /* This comparison is an oversimplification because it compares only the types. If the type + * is a struct, we would need to type assert that and come up with a custom comparator. + * In some cases, like for [domain.SessionFactorUser] it can become quite tricky because + * it includes a [time.Time] value, for which you will need to do comparisons in range rather + * than equal matches. Of course, this is just an example, but we could have much more complex + * structs that makes it very hard to forecast what kind of code we need to write here + */ + expectedType, actualType := fmt.Sprintf("%T", e), fmt.Sprintf("%T", actualCTEBuilder.Args()[i]) + if expectedType != actualType { + return false + } + } + + return true } // SetName implements [CTEChange].