feat(session): User Check API with relation tables (#11777)

# Which Problems Are Solved

As part of #11035 , this PR implements the user check logic for session
validation

# How the Problems Are Solved

- Add user and session repositories as invoke options, as well as their
mocks to prepare the ground for testing
- Implement the logic for the UserCheck command, following what has been
done on eventstore side
- Add a session permission chek interface to be used during the
`Validate()` part of the Commander

# Additional Context

This is a cherry-picked PR + minor changes, coming from
https://github.com/zitadel/zitadel/pull/11164
- Relates to #11035

---------

Co-authored-by: Silvan <[email protected]>
This commit is contained in:
Marco A.
2026-03-10 09:10:31 +00:00
committed by GitHub
co-authored by Silvan
parent 86ec54b22f
commit 8b90ddb0b5
10 changed files with 1176 additions and 4 deletions
@@ -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
}
@@ -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: "[email protected]"},
},
expectedDomainCheckUser: &domain.CheckUserType{LoginName: "[email protected]"},
},
}
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)
})
}
}
+20
View File
@@ -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 {
+38
View File
@@ -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
}
+47
View File
@@ -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)
}
+51
View File
@@ -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()
}
+11
View File
@@ -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 {
+168
View File
@@ -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)
@@ -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, "[email protected]"),
),
)).
Times(1).
Return(&domain.User{
ID: "user-123",
LoginNames: []domain.LoginName{{LoginName: "[email protected]"}},
OrganizationID: "org-1",
State: domain.UserStateActive,
}, nil)
return repo
},
cmd: &domain.UserCheckCommand{
InputCheckUser: &domain.CheckUserType{LoginName: "[email protected]"},
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)
})
}
}
+24 -4
View File
@@ -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].