Files
zitadel/backend/v3/domain/session_delete_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

347 lines
9.1 KiB
Go

package domain_test
import (
"context"
"errors"
"testing"
"time"
"github.com/brianvoe/gofakeit/v6"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
"github.com/zitadel/zitadel/backend/v3/domain"
domainmock "github.com/zitadel/zitadel/backend/v3/domain/mock"
"github.com/zitadel/zitadel/backend/v3/storage/database"
noopdb "github.com/zitadel/zitadel/backend/v3/storage/database/dialect/noop"
"github.com/zitadel/zitadel/internal/api/authz"
zdomain "github.com/zitadel/zitadel/internal/domain"
)
func TestDeleteSessionCommand_Validate(t *testing.T) {
t.Parallel()
ctx := authz.NewMockContext("inst-1", "org-1", gofakeit.UUID())
type args struct {
sessionID string
sessionToken string
mustCheckToken bool
}
type fields struct {
sessionRepo func(ctrl *gomock.Controller) domain.SessionRepository
permissionCheck func(ctrl *gomock.Controller) domain.PermissionChecker
}
tt := []struct {
name string
args args
fields fields
expectedError error
}{
{
name: "when sessionID is empty should return invalid argument error",
args: args{
sessionID: "",
},
fields: fields{},
expectedError: domain.ErrIDMissing(),
},
{
name: "when sessionID is provided must validate successfully",
args: args{
sessionID: "session",
},
fields: fields{},
expectedError: nil,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// Given
d := domain.NewDeleteSessionCommand(tc.args.sessionID, tc.args.sessionToken, tc.args.mustCheckToken)
ctrl := gomock.NewController(t)
opts := domain.DefaultOpts(nil)
domain.WithQueryExecutor(new(noopdb.Pool))(opts)
if tc.fields.sessionRepo != nil {
domain.WithSessionRepo(tc.fields.sessionRepo(ctrl))(opts)
}
if tc.fields.permissionCheck != nil {
domain.WithPermissionChecker(tc.fields.permissionCheck(ctrl))(opts)
}
// Test
err := d.Validate(ctx, opts)
// Verify
assert.Equal(t, tc.expectedError, err)
})
}
}
func TestDeleteSessionCommand_Execute(t *testing.T) {
t.Parallel()
userID := gofakeit.UUID()
ctx := authz.NewMockContext("inst-1", "org-1", userID)
deleteErr := errors.New("delete error")
type args struct {
mockTx func(ctrl *gomock.Controller) database.QueryExecutor
sessionRepo func(ctrl *gomock.Controller) domain.SessionRepository
sessionTokenDecryptor func(t *testing.T) domain.SessionTokenDecryptor
}
type fields struct {
sessionID string
sessionToken string
mustCheckPermission bool
}
type res struct {
error error
deletedAt bool
events int
}
tt := []struct {
testName string
args args
fields fields
res res
}{
{
testName: "when delete session fails should return error",
args: args{
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
repo.EXPECT().
Delete(gomock.Any(), gomock.Any(),
repo.PrimaryKeyCondition("inst-1", "session-1"),
nil,
).
Times(1).
Return(int64(0), time.Time{}, deleteErr)
return repo
},
},
fields: fields{
sessionID: "session-1",
},
res: res{
error: deleteErr,
},
},
{
testName: "when no token is provided but must check permission, should use userID check",
args: args{
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
repo.EXPECT().
Delete(gomock.Any(), gomock.Any(),
repo.PrimaryKeyCondition("inst-1", "session-1"),
database.Or(
repo.UserIDCondition(userID),
database.PermissionCheck(zdomain.PermissionSessionDelete, true),
),
).
Times(1).
Return(int64(0), time.Time{}, nil)
return repo
},
},
fields: fields{
sessionID: "session-1",
mustCheckPermission: true,
},
res: res{
error: nil,
},
},
{
testName: "when token is provided and must check permission, should use tokenID check",
args: args{
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
repo.EXPECT().
Delete(gomock.Any(), gomock.Any(),
repo.PrimaryKeyCondition("inst-1", "session-1"),
database.Or(
repo.TokenIDCondition("token-1"),
database.PermissionCheck(zdomain.PermissionSessionDelete, true),
),
).
Times(1).
Return(int64(0), time.Time{}, nil)
return repo
},
sessionTokenDecryptor: mockSessionTokenDecryptor("session-token-1", "session-1", "token-1"),
},
fields: fields{
sessionID: "session-1",
sessionToken: "session-token-1",
mustCheckPermission: true,
},
res: res{
error: nil,
},
},
{
testName: "when delete session without permission should return error",
args: args{
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
repo.EXPECT().
Delete(gomock.Any(), gomock.Any(),
repo.PrimaryKeyCondition("inst-1", "session-1"),
database.Or(
repo.UserIDCondition(userID),
database.PermissionCheck(zdomain.PermissionSessionDelete, true),
),
).
Times(1).
Return(int64(0), time.Time{}, database.NewPermissionError(nil))
return repo
},
},
fields: fields{
sessionID: "session-1",
mustCheckPermission: true,
},
res: res{
error: domain.ErrAuthMissingPermission(nil, "", domain.SessionDeletePermission),
},
},
{
testName: "when more than one row deleted must return internal error",
args: args{
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
repo.EXPECT().
Delete(gomock.Any(), gomock.Any(),
repo.PrimaryKeyCondition("inst-1", "session-1"),
nil,
).
Times(1).
Return(int64(2), time.Time{}, nil)
return repo
},
},
fields: fields{
sessionID: "session-1",
},
res: res{
error: domain.ErrMoreThanOneRowAffected("expected 1 session to be deleted, got 2", 2),
},
},
{
testName: "when no rows deleted should execute successfully",
args: args{
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
repo.EXPECT().
Delete(gomock.Any(), gomock.Any(),
repo.PrimaryKeyCondition("inst-1", "session-1"),
nil,
).
Times(1).
Return(int64(0), time.Time{}, nil)
return repo
},
},
fields: fields{
sessionID: "session-1",
},
res: res{
error: nil,
},
},
{
testName: "when one row deleted should execute successfully and set DeletedAt",
args: args{
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
repo.EXPECT().
Delete(gomock.Any(), gomock.Any(),
repo.PrimaryKeyCondition("inst-1", "session-1"),
nil,
).
Times(1).
Return(int64(1), time.Now(), nil)
return repo
},
},
fields: fields{
sessionID: "session-1",
},
res: res{
deletedAt: true,
events: 1,
},
},
{
testName: "when session already deleted should execute successfully, set DeletedAt but no event",
args: args{
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
repo.EXPECT().
Delete(gomock.Any(), gomock.Any(),
repo.PrimaryKeyCondition("inst-1", "session-1"),
nil,
).
Times(1).
Return(int64(0), time.Now(), nil)
return repo
},
},
fields: fields{
sessionID: "session-1",
},
res: res{
deletedAt: true,
events: 0,
},
},
}
for _, tc := range tt {
t.Run(tc.testName, func(t *testing.T) {
t.Parallel()
// Given
cmd := domain.NewDeleteSessionCommand(tc.fields.sessionID, tc.fields.sessionToken, tc.fields.mustCheckPermission)
ctrl := gomock.NewController(t)
opts := domain.DefaultOpts(nil)
domain.WithQueryExecutor(new(noopdb.Pool))(opts)
if tc.args.mockTx != nil {
domain.WithQueryExecutor(tc.args.mockTx(ctrl))(opts)
}
if tc.args.sessionRepo != nil {
domain.WithSessionRepo(tc.args.sessionRepo(ctrl))(opts)
}
if tc.args.sessionTokenDecryptor != nil {
domain.WithSessionTokenDecryptor(tc.args.sessionTokenDecryptor(t))(opts)
}
// Test
err := cmd.Execute(ctx, opts)
// Verify
assert.ErrorIs(t, err, tc.res.error)
assert.Equal(t, tc.res.deletedAt, !cmd.DeletedAt.IsZero())
// Test events
cmds, err := cmd.Events(ctx, opts)
assert.NoError(t, err)
assert.Len(t, cmds, tc.res.events)
})
}
}
func mockSessionTokenDecryptor(expectedSessionToken, returnSessionID, returnSessionToken string) func(t *testing.T) domain.SessionTokenDecryptor {
return func(t *testing.T) domain.SessionTokenDecryptor {
return func(ctx context.Context, sessionToken string) (sessionID, tokenID string, err error) {
if sessionToken != expectedSessionToken {
return "", "", domain.ErrSessionTokenInvalid(nil)
}
return returnSessionID, returnSessionToken, nil
}
}
}