Files
zitadel/backend/v3/domain/session_check_user_test.go
T
8b90ddb0b5 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]>
2026-03-10 09:10:31 +00:00

736 lines
22 KiB
Go

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)
})
}
}