Files
zitadel/backend/v3/domain/session_delete_test.go
T
ca4bba3992 feat: add delete session on relation tables (#11334)
# Which Problems Are Solved

As part of moving the session API to the relational database, this PR
adds the functionality to delete sessions through the session API v2 and
the OIDC end_session endpoint.

# How the Problems Are Solved

- added `SessionDeleteCommand` in domain package
- added possibility to pass the session token verifier to the domain
package
- use feature flag to switch API and OIDC usage from CQRS to relation
table
- added a `permissionCheck` condition to let permissions be checked
directly in the repository / sql
- part of it is still a placeholder until actual permission tables are
done
- added a `session_deleted` table to store (recently) deleted sessions
to handle necessary checks / cases like delete my own session, which
needs to be idempotent

# Additional Changes

Fixed `Matches` function of `existsCondition` (as missing an
implementation)

# Additional Context

- closes #11037

---------

Co-authored-by: Stefan Benz <[email protected]>
2026-03-19 08:35:53 +01:00

348 lines
9.3 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"
"github.com/zitadel/zitadel/internal/zerrors"
)
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: zerrors.ThrowInvalidArgument(nil, domain.ErrIDMissing, "Errors.IDMissing"),
},
{
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: new(database.PermissionError),
},
},
{
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: zerrors.ThrowInternalf(nil, domain.ErrMoreThanOneRowAffected, "expected 1 session to be deleted, got %d", 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 "", "", zerrors.ThrowInvalidArgumentf(nil, "SESS-S3gq1", "Errors.Session.TokenInvalid")
}
return returnSessionID, returnSessionToken, nil
}
}
}