Files
zitadel/backend/v3/domain/session_check_user_test.go
T
a2bf528ca0 feat(domain): introduce error slugs (#12030)
# Which Problems Are Solved

As part of https://github.com/zitadel/zitadel/issues/11917 we want to
introduce error slugs so (API) clients can rely on stable,
machine-readable errors and act accordingly.

# How the Problems Are Solved

- Added a `NewSlug` helper function in the domain package.
- Added `ErrorDetails` to the `ZitadelError`
- Added an `zitadel.error.v2.ErrorDetail` proto message
- Updated the connectRPC error interceptor to map new slug based errors
to the new `ErrorDetail`
- Defined some common slugs and error functions like internal errors
- Defined (session) specific slugs used in the `DeleteSession` and
`CheckUser` functions and replaced old implementations
- Updated integration tests to check specific errors if the relation
database feature is enabled
- Updated doc and guideline to reflect the latest changes and decisions
- Updated DeleteSession endpoint API to list possible slugs

# Additional Changes

None

# Additional Context

- closes #11957

---------

Co-authored-by: Wim Van Laer <[email protected]>
2026-04-13 09:47:04 +02:00

736 lines
21 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: domain.ErrIDMissing(),
},
{
testName: "when instance ID is not set should return error",
cmd: &domain.UserCheckCommand{
InputCheckUser: &domain.CheckUserType{UserID: "user-123"},
SessionID: "session-1",
},
expectedError: domain.ErrInstanceIDMissing(),
},
{
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: domain.ErrUserNotActive("user-123", ""),
},
{
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: domain.ErrInternal(getErr, ""),
},
{
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: domain.ErrUserNotFound(notFoundErr, "user-123"),
},
{
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: domain.ErrInvalidRequest(""),
},
}
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: domain.ErrInternal(getErr, ""),
},
{
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: domain.ErrSessionNotFound(sessionNotFoundErr, "session-1"),
},
{
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: domain.ErrSessionUserChange(),
},
{
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: domain.ErrInternal(updateErr, "failed to update the 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: domain.ErrSessionNotFound(nil, "session-1"),
},
{
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: domain.ErrMoreThanOneRowAffected("unexpected number of rows updated", 2),
},
{
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)
})
}
}