Files
zitadel/backend/v3/storage/database/repository/session_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

1502 lines
41 KiB
Go

package repository_test
import (
"net"
"net/http"
"slices"
"strconv"
"testing"
"time"
"github.com/brianvoe/gofakeit/v6"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/backend/v3/storage/database"
"github.com/zitadel/zitadel/backend/v3/storage/database/repository"
"github.com/zitadel/zitadel/internal/crypto"
zdomain "github.com/zitadel/zitadel/internal/domain"
)
func TestSession_Create(t *testing.T) {
beforeCreate := time.Now()
tx, err := pool.Begin(t.Context(), nil)
require.NoError(t, err)
defer func() {
err := tx.Rollback(t.Context())
if err != nil {
t.Logf("error during rollback: %v", err)
}
}()
sessionRepo := repository.SessionRepository()
instanceRepo := repository.InstanceRepository()
instanceId := gofakeit.Name()
instance := domain.Instance{
ID: instanceId,
Name: gofakeit.Name(),
DefaultOrgID: "defaultOrgId",
IAMProjectID: "iamProject",
ConsoleClientID: "managementConsoleClient",
ConsoleAppID: "managementConsoleApp",
DefaultLanguage: "defaultLanguage",
}
err = instanceRepo.Create(t.Context(), tx, &instance)
require.NoError(t, err)
tests := []struct {
name string
session *domain.Session
err error
}{
{
name: "create valid session",
session: &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
CreatedAt: beforeCreate,
UpdatedAt: beforeCreate,
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
UserAgent: &domain.SessionUserAgent{
FingerprintID: gu.Ptr(gofakeit.Name()),
Description: gu.Ptr(gofakeit.Name()),
IP: net.IPv4(127, 0, 0, 1),
Header: http.Header{"User-Agent": []string{"user-agent"}},
},
},
err: nil,
},
{
name: "create valid session no dates",
session: &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
UserAgent: &domain.SessionUserAgent{
FingerprintID: gu.Ptr(gofakeit.Name()),
Description: gu.Ptr(gofakeit.Name()),
IP: net.IPv4(127, 0, 0, 1),
Header: http.Header{"User-Agent": []string{"user-agent"}},
},
},
err: nil,
},
{
name: "create session user agent fingerprint only",
session: &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
UserAgent: &domain.SessionUserAgent{
FingerprintID: gu.Ptr(gofakeit.Name()),
},
},
err: nil,
},
{
name: "create session empty fingerprint",
session: &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
UserAgent: &domain.SessionUserAgent{
FingerprintID: gu.Ptr(""),
},
},
err: new(database.CheckError),
},
{
name: "create session without user agent",
session: &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
},
err: nil,
},
{
name: "create session without lifetime",
session: &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
CreatorID: gofakeit.Name(),
},
err: nil,
},
{
name: "create session without creatorID",
session: &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
},
err: nil,
},
{
name: "create session without id",
session: &domain.Session{
InstanceID: instanceId,
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
},
err: new(database.CheckError),
},
{
name: "create session without instanceID",
session: &domain.Session{
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
},
err: new(database.ForeignKeyError),
},
{
name: "create session without existent instanceID",
session: &domain.Session{
InstanceID: gofakeit.Name(),
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
},
err: new(database.ForeignKeyError),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
savepoint, err := tx.Begin(t.Context())
require.NoError(t, err)
defer func() {
err = savepoint.Rollback(t.Context())
if err != nil {
t.Logf("error during rollback: %v", err)
}
}()
err = sessionRepo.Create(t.Context(), savepoint, tt.session)
assert.ErrorIs(t, err, tt.err)
if err != nil {
return
}
afterCreate := time.Now()
// check session values
createdSession, err := sessionRepo.Get(t.Context(), savepoint,
database.WithCondition(sessionRepo.PrimaryKeyCondition(tt.session.InstanceID, tt.session.ID)),
)
require.NoError(t, err)
assert.Equal(t, tt.session.ID, createdSession.ID)
assert.Equal(t, tt.session.InstanceID, createdSession.InstanceID)
assert.Equal(t, tt.session.Lifetime, createdSession.Lifetime)
assert.Equal(t, createdSession.UpdatedAt.Add(tt.session.Lifetime), createdSession.Expiration)
assert.Equal(t, tt.session.CreatorID, createdSession.CreatorID)
assert.Equal(t, tt.session.UserAgent, createdSession.UserAgent)
assert.WithinRange(t, createdSession.CreatedAt, beforeCreate.Add(-time.Second), afterCreate.Add(time.Second))
assert.WithinRange(t, createdSession.UpdatedAt, beforeCreate.Add(-time.Second), afterCreate.Add(time.Second))
})
}
}
func TestSession_Update(t *testing.T) {
beforeUpdate := time.Now()
tx, err := pool.Begin(t.Context(), nil)
require.NoError(t, err)
defer func() {
err := tx.Rollback(t.Context())
if err != nil {
t.Logf("error during rollback: %v", err)
}
}()
instanceRepo := repository.InstanceRepository()
sessionRepo := repository.SessionRepository()
// create instance
instanceId := gofakeit.Name()
instance := domain.Instance{
ID: instanceId,
Name: gofakeit.Name(),
DefaultOrgID: "defaultOrgId",
IAMProjectID: "iamProject",
ConsoleClientID: "managementConsoleClient",
ConsoleAppID: "managementConsoleApp",
DefaultLanguage: "defaultLanguage",
}
err = instanceRepo.Create(t.Context(), tx, &instance)
require.NoError(t, err)
orgID := createOrganization(t, tx, instanceId)
userID := createHumanUser(t, tx, instanceId, orgID)
testNow := time.Now()
tests := []struct {
name string
testFunc func(t *testing.T) *domain.Session
update []database.Change
rowsAffected int64
err error
}{
{
name: "update no changes",
testFunc: func(t *testing.T) *domain.Session {
return &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
}
},
update: []database.Change{},
rowsAffected: 0,
err: database.ErrNoChanges,
},
{
name: "update non existent session",
testFunc: func(t *testing.T) *domain.Session {
return &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
}
},
update: []database.Change{
sessionRepo.SetLifetime(time.Hour * 48),
},
rowsAffected: 0,
},
{
name: "update session updated at",
testFunc: func(t *testing.T) *domain.Session {
session := &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
}
err := sessionRepo.Create(t.Context(), tx, session)
require.NoError(t, err)
// update with updated values
session.UpdatedAt = testNow
return session
},
update: []database.Change{
sessionRepo.SetUpdatedAt(testNow),
},
rowsAffected: 1,
},
{
name: "update session lifetime",
testFunc: func(t *testing.T) *domain.Session {
session := &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
}
err := sessionRepo.Create(t.Context(), tx, session)
require.NoError(t, err)
// update with updated values
session.Lifetime = time.Hour * 48
return session
},
update: []database.Change{
sessionRepo.SetLifetime(time.Hour * 48),
},
rowsAffected: 1,
},
{
name: "update session token",
testFunc: func(t *testing.T) *domain.Session {
session := &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
}
err := sessionRepo.Create(t.Context(), tx, session)
require.NoError(t, err)
// update with updated values
session.TokenID = "new-token"
return session
},
update: []database.Change{
sessionRepo.SetToken("new-token"),
},
rowsAffected: 1,
},
{
name: "update session passkey challenge",
testFunc: func(t *testing.T) *domain.Session {
session := &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
}
err := sessionRepo.Create(t.Context(), tx, session)
require.NoError(t, err)
// update with updated values
session.Challenges = []domain.SessionChallenge{
&domain.SessionChallengePasskey{
LastChallengedAt: testNow,
Challenge: "challenge",
AllowedCredentialIDs: [][]byte{[]byte("allowed-id-1"), []byte("allowed-id-2")},
UserVerification: zdomain.UserVerificationRequirementRequired,
RPID: "rp-id",
},
}
return session
},
update: []database.Change{
sessionRepo.SetChallenge(&domain.SessionChallengePasskey{
LastChallengedAt: testNow,
Challenge: "challenge",
AllowedCredentialIDs: [][]byte{[]byte("allowed-id-1"), []byte("allowed-id-2")},
UserVerification: zdomain.UserVerificationRequirementRequired,
RPID: "rp-id",
}),
},
rowsAffected: 1,
},
{
name: "update session otp sms challenge",
testFunc: func(t *testing.T) *domain.Session {
session := &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
}
err := sessionRepo.Create(t.Context(), tx, session)
require.NoError(t, err)
// update with updated values
session.Challenges = []domain.SessionChallenge{
&domain.SessionChallengeOTPSMS{
LastChallengedAt: testNow,
Code: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "key-id",
Crypted: []byte("code"),
},
Expiry: time.Minute * 10,
CodeReturned: false,
GeneratorID: "",
TriggeredAtOrigin: "https://example.com",
},
}
return session
},
update: []database.Change{
sessionRepo.SetChallenge(&domain.SessionChallengeOTPSMS{
LastChallengedAt: testNow,
Code: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "key-id",
Crypted: []byte("code"),
},
Expiry: time.Minute * 10,
CodeReturned: false,
GeneratorID: "",
TriggeredAtOrigin: "https://example.com",
}),
},
rowsAffected: 1,
},
{
name: "update session otp email challenge",
testFunc: func(t *testing.T) *domain.Session {
session := &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
}
err := sessionRepo.Create(t.Context(), tx, session)
require.NoError(t, err)
// update with updated values
session.Challenges = []domain.SessionChallenge{
&domain.SessionChallengeOTPEmail{
LastChallengedAt: testNow,
Code: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "key-id",
Crypted: []byte("code"),
},
Expiry: time.Minute * 10,
CodeReturned: false,
TriggeredAtOrigin: "https://example.com",
},
}
return session
},
update: []database.Change{
sessionRepo.SetChallenge(&domain.SessionChallengeOTPEmail{
LastChallengedAt: testNow,
Code: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "key-id",
Crypted: []byte("code"),
},
Expiry: time.Minute * 10,
CodeReturned: false,
TriggeredAtOrigin: "https://example.com",
}),
},
rowsAffected: 1,
},
{
name: "update session user factor",
testFunc: func(t *testing.T) *domain.Session {
session := &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
}
err := sessionRepo.Create(t.Context(), tx, session)
require.NoError(t, err)
// update with updated values
session.Factors = []domain.SessionFactor{
&domain.SessionFactorUser{
UserID: userID,
LastVerifiedAt: testNow,
},
}
return session
},
update: []database.Change{
sessionRepo.SetFactor(&domain.SessionFactorUser{
UserID: userID,
LastVerifiedAt: testNow,
}),
},
rowsAffected: 1,
},
{
name: "update session password factor",
testFunc: func(t *testing.T) *domain.Session {
session := &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
}
err := sessionRepo.Create(t.Context(), tx, session)
require.NoError(t, err)
// update with updated values
session.Factors = []domain.SessionFactor{
&domain.SessionFactorPassword{
LastVerifiedAt: testNow,
},
}
return session
},
update: []database.Change{
sessionRepo.SetFactor(&domain.SessionFactorPassword{
LastVerifiedAt: testNow,
}),
},
rowsAffected: 1,
},
{
name: "update session identity provider intent factor",
testFunc: func(t *testing.T) *domain.Session {
session := &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
}
err := sessionRepo.Create(t.Context(), tx, session)
require.NoError(t, err)
// update with updated values
session.Factors = []domain.SessionFactor{
&domain.SessionFactorIdentityProviderIntent{
LastVerifiedAt: testNow,
},
}
return session
},
update: []database.Change{
sessionRepo.SetFactor(&domain.SessionFactorIdentityProviderIntent{
LastVerifiedAt: testNow,
}),
},
rowsAffected: 1,
},
{
name: "update session passkey factor",
testFunc: func(t *testing.T) *domain.Session {
session := &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
}
err := sessionRepo.Create(t.Context(), tx, session)
require.NoError(t, err)
// update with updated values
session.Factors = []domain.SessionFactor{
&domain.SessionFactorPasskey{
LastVerifiedAt: testNow,
UserVerified: true,
},
}
return session
},
update: []database.Change{
sessionRepo.SetFactor(&domain.SessionFactorPasskey{
LastVerifiedAt: testNow,
UserVerified: true,
}),
},
rowsAffected: 1,
},
{
name: "update session totp factor",
testFunc: func(t *testing.T) *domain.Session {
session := &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
}
err := sessionRepo.Create(t.Context(), tx, session)
require.NoError(t, err)
// update with updated values
session.Factors = []domain.SessionFactor{
&domain.SessionFactorTOTP{
LastVerifiedAt: testNow,
},
}
return session
},
update: []database.Change{
sessionRepo.SetFactor(&domain.SessionFactorTOTP{
LastVerifiedAt: testNow,
}),
},
rowsAffected: 1,
},
{
name: "update session otp sms factor",
testFunc: func(t *testing.T) *domain.Session {
session := &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
}
err := sessionRepo.Create(t.Context(), tx, session)
require.NoError(t, err)
// update with updated values
session.Factors = []domain.SessionFactor{
&domain.SessionFactorOTPSMS{
LastVerifiedAt: testNow,
},
}
return session
},
update: []database.Change{
sessionRepo.SetFactor(&domain.SessionFactorOTPSMS{
LastVerifiedAt: testNow,
}),
},
rowsAffected: 1,
},
{
name: "update session otp email factor",
testFunc: func(t *testing.T) *domain.Session {
session := &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
}
err := sessionRepo.Create(t.Context(), tx, session)
require.NoError(t, err)
// update with updated values
session.Factors = []domain.SessionFactor{
&domain.SessionFactorOTPEmail{
LastVerifiedAt: testNow,
},
}
return session
},
update: []database.Change{
sessionRepo.SetFactor(&domain.SessionFactorOTPEmail{
LastVerifiedAt: testNow,
}),
},
rowsAffected: 1,
},
{
name: "update session recovery code factor",
testFunc: func(t *testing.T) *domain.Session {
session := &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
}
err := sessionRepo.Create(t.Context(), tx, session)
require.NoError(t, err)
// update with updated values
session.Factors = []domain.SessionFactor{
&domain.SessionFactorRecoveryCode{
LastVerifiedAt: testNow,
},
}
return session
},
update: []database.Change{
sessionRepo.SetFactor(&domain.SessionFactorRecoveryCode{
LastVerifiedAt: testNow,
}),
},
rowsAffected: 1,
},
{
name: "update session metadata",
testFunc: func(t *testing.T) *domain.Session {
session := &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
}
err := sessionRepo.Create(t.Context(), tx, session)
require.NoError(t, err)
// update with updated values
session.Metadata = []*domain.SessionMetadata{
{
Metadata: domain.Metadata{
InstanceID: instanceId,
Key: "key1",
Value: []byte("value1"),
},
SessionID: session.ID,
},
{
Metadata: domain.Metadata{
InstanceID: instanceId,
Key: "key2",
Value: []byte("value2"),
},
SessionID: session.ID,
},
}
return session
},
update: []database.Change{
sessionRepo.SetMetadata([]*domain.SessionMetadata{
{
Metadata: domain.Metadata{
Key: "key1",
Value: []byte("value1"),
},
},
{
Metadata: domain.Metadata{
Key: "key2",
Value: []byte("value2"),
},
},
}),
},
rowsAffected: 1,
},
{
name: "update session multiple fields",
testFunc: func(t *testing.T) *domain.Session {
session := &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
}
err := sessionRepo.Create(t.Context(), tx, session)
require.NoError(t, err)
// update with updated values
session.UpdatedAt = testNow
session.Lifetime = time.Hour * 48
session.TokenID = "new-token"
session.Factors = []domain.SessionFactor{
&domain.SessionFactorUser{
UserID: userID,
LastVerifiedAt: testNow,
},
&domain.SessionFactorPassword{
LastVerifiedAt: testNow,
},
}
session.Challenges = []domain.SessionChallenge{
&domain.SessionChallengeOTPSMS{
LastChallengedAt: testNow,
Code: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "key-id",
Crypted: []byte("code"),
},
Expiry: time.Minute * 10,
CodeReturned: false,
GeneratorID: "",
TriggeredAtOrigin: "https://example.com",
},
}
session.Metadata = []*domain.SessionMetadata{
{
Metadata: domain.Metadata{
InstanceID: instanceId,
Key: "key1",
Value: []byte("value1"),
},
SessionID: session.ID,
},
{
Metadata: domain.Metadata{
InstanceID: instanceId,
Key: "key2",
Value: []byte("value2"),
},
SessionID: session.ID,
},
}
return session
},
update: []database.Change{
sessionRepo.SetUpdatedAt(testNow),
sessionRepo.SetLifetime(time.Hour * 48),
sessionRepo.SetFactor(&domain.SessionFactorUser{
UserID: userID,
LastVerifiedAt: testNow,
}),
sessionRepo.SetFactor(&domain.SessionFactorPassword{
LastVerifiedAt: testNow,
}),
sessionRepo.SetChallenge(&domain.SessionChallengeOTPSMS{
LastChallengedAt: testNow,
Code: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "key-id",
Crypted: []byte("code"),
},
Expiry: time.Minute * 10,
CodeReturned: false,
GeneratorID: "",
TriggeredAtOrigin: "https://example.com",
}),
sessionRepo.SetMetadata([]*domain.SessionMetadata{
{
Metadata: domain.Metadata{
Key: "key1",
Value: []byte("value1"),
},
},
{
Metadata: domain.Metadata{
Key: "key2",
Value: []byte("value2"),
},
},
}),
sessionRepo.SetToken("new-token"),
},
rowsAffected: 1,
},
{
name: "update session clear factor",
testFunc: func(t *testing.T) *domain.Session {
session := &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
}
err := sessionRepo.Create(t.Context(), tx, session)
require.NoError(t, err)
updatedRows, err := sessionRepo.Update(t.Context(), tx,
sessionRepo.PrimaryKeyCondition(session.InstanceID, session.ID),
sessionRepo.SetFactor(&domain.SessionFactorUser{
UserID: userID,
LastVerifiedAt: testNow,
}),
sessionRepo.SetFactor(&domain.SessionFactorPassword{
LastVerifiedAt: testNow,
}),
)
require.NoError(t, err)
require.Equal(t, int64(1), updatedRows)
// update with updated values
session.Factors = []domain.SessionFactor{
&domain.SessionFactorUser{
UserID: userID,
LastVerifiedAt: testNow,
},
}
return session
},
update: []database.Change{
sessionRepo.ClearFactor(domain.SessionFactorTypePassword),
},
rowsAffected: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
createdSession := tt.testFunc(t)
// update org
rowsAffected, err := sessionRepo.Update(t.Context(), tx,
sessionRepo.PrimaryKeyCondition(createdSession.InstanceID, createdSession.ID),
tt.update...,
)
afterUpdate := time.Now()
require.ErrorIs(t, err, tt.err)
assert.Equal(t, tt.rowsAffected, rowsAffected)
if rowsAffected == 0 {
return
}
// check session values
session, err := sessionRepo.Get(t.Context(), tx,
database.WithCondition(
sessionRepo.PrimaryKeyCondition(createdSession.InstanceID, createdSession.ID),
),
)
require.NoError(t, err)
assert.Equal(t, createdSession.ID, session.ID)
assert.Equal(t, createdSession.Lifetime, session.Lifetime)
assert.Equal(t, createdSession.TokenID, session.TokenID)
assert.WithinRange(t, session.Expiration, beforeUpdate.Add(createdSession.Lifetime), afterUpdate.Add(createdSession.Lifetime))
assert.Equal(t, createdSession.CreatorID, session.CreatorID)
assert.Equal(t, createdSession.UserAgent, session.UserAgent)
assert.WithinRange(t, session.UpdatedAt, beforeUpdate, afterUpdate)
assert.EqualExportedValues(t, createdSession.Challenges, session.Challenges)
slices.SortFunc(session.Factors, func(a, b domain.SessionFactor) int { // sort to make comparison possible
return int(a.SessionFactorType()) - int(b.SessionFactorType())
})
assert.EqualExportedValues(t, createdSession.Factors, session.Factors)
assert.EqualExportedValues(t, createdSession.Metadata, session.Metadata)
})
}
}
func TestSession_Delete(t *testing.T) {
start := time.Now()
tx, err := pool.Begin(t.Context(), nil)
require.NoError(t, err)
defer func() {
err := tx.Rollback(t.Context())
if err != nil {
t.Logf("error during rollback: %v", err)
}
}()
instanceRepo := repository.InstanceRepository()
sessionRepo := repository.SessionRepository()
// create instance
instanceId := gofakeit.Name()
instance := domain.Instance{
ID: instanceId,
Name: gofakeit.Name(),
DefaultOrgID: "defaultOrgId",
IAMProjectID: "iamProject",
ConsoleClientID: "managementConsoleClient",
ConsoleAppID: "managementConsoleApp",
DefaultLanguage: "defaultLanguage",
}
err = instanceRepo.Create(t.Context(), tx, &instance)
require.NoError(t, err)
type fields struct {
testFunc func(t *testing.T) []*domain.Session
}
type args struct {
condition func([]*domain.Session) database.Condition
permissionCondition func([]*domain.Session) database.Condition
}
type res struct {
noOfDeletedRows int64
deletedAt time.Time
err error
}
type test struct {
name string
fields fields
args args
res res
}
tests := []test{
{
name: "delete existent session",
fields: fields{
testFunc: func(t *testing.T) []*domain.Session {
session := &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
}
err := sessionRepo.Create(t.Context(), tx, session)
require.NoError(t, err)
return []*domain.Session{session}
},
},
args: args{
condition: func(sessions []*domain.Session) database.Condition {
return sessionRepo.PrimaryKeyCondition(instanceId, sessions[0].ID)
},
},
res: res{
noOfDeletedRows: 1,
deletedAt: time.Now(),
},
},
{
name: "delete non existent session",
fields: fields{
testFunc: func(t *testing.T) []*domain.Session {
return []*domain.Session{}
},
},
args: args{
condition: func(sessions []*domain.Session) database.Condition {
return sessionRepo.PrimaryKeyCondition(instanceId, gofakeit.UUID())
},
},
res: res{
noOfDeletedRows: 0,
},
},
{
name: "delete previously existent session",
fields: fields{
testFunc: func(t *testing.T) []*domain.Session {
session := &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
}
err := sessionRepo.Create(t.Context(), tx, session)
require.NoError(t, err)
_, _, err = sessionRepo.Delete(t.Context(), tx, sessionRepo.PrimaryKeyCondition(session.InstanceID, session.ID), nil)
require.NoError(t, err)
return []*domain.Session{session}
},
},
args: args{
condition: func(sessions []*domain.Session) database.Condition {
return sessionRepo.PrimaryKeyCondition(instanceId, sessions[0].ID)
},
},
res: res{
noOfDeletedRows: 0,
deletedAt: time.Now(),
},
},
{
name: "delete multiple sessions",
fields: fields{
testFunc: func(t *testing.T) []*domain.Session {
session := &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
}
err := sessionRepo.Create(t.Context(), tx, session)
require.NoError(t, err)
session2 := &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
}
err = sessionRepo.Create(t.Context(), tx, session2)
require.NoError(t, err)
return []*domain.Session{session, session2}
},
},
args: args{
condition: func(sessions []*domain.Session) database.Condition {
return sessionRepo.InstanceIDCondition(instanceId)
},
},
res: res{
noOfDeletedRows: 2,
deletedAt: time.Time{},
},
},
{
name: "delete without permission",
fields: fields{
testFunc: func(t *testing.T) []*domain.Session {
session := &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
TokenID: gofakeit.UUID(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
}
err := sessionRepo.Create(t.Context(), tx, session)
require.NoError(t, err)
return []*domain.Session{session}
},
},
args: args{
condition: func(sessions []*domain.Session) database.Condition {
return sessionRepo.InstanceIDCondition(instanceId)
},
permissionCondition: func(sessions []*domain.Session) database.Condition {
return sessionRepo.TokenIDCondition("invalid")
},
},
res: res{
noOfDeletedRows: 0,
deletedAt: time.Time{},
err: database.NewPermissionError(nil),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var sessions []*domain.Session
if tt.fields.testFunc != nil {
sessions = tt.fields.testFunc(t)
}
var condition, permissionCondition database.Condition
if tt.args.condition != nil {
condition = tt.args.condition(sessions)
}
if tt.args.permissionCondition != nil {
permissionCondition = tt.args.permissionCondition(sessions)
}
// let's create a snapshot before deletion to make sure we could rollback in case of error
snapshot, err := tx.Begin(t.Context())
require.NoError(t, err)
// delete session
deletedRows, deletedAt, err := sessionRepo.Delete(t.Context(), tx,
condition, permissionCondition,
)
require.ErrorIs(t, err, tt.res.err)
assert.Equal(t, tt.res.noOfDeletedRows, deletedRows)
if tt.res.deletedAt.IsZero() {
assert.Zero(t, deletedAt)
} else {
assert.WithinRange(t, deletedAt, start, time.Now())
}
if err != nil {
err = snapshot.Rollback(t.Context())
require.NoError(t, err)
}
// verify session deletion
deletedSessions, err := sessionRepo.List(t.Context(), tx,
database.WithCondition(condition),
)
require.NoError(t, err)
expectedSessions := 0
// in case of permission error, sessions are not deleted,
// so expectedSessions should be equal to the number of created sessions
if tt.res.err != nil {
expectedSessions = len(sessions)
}
assert.Len(t, deletedSessions, expectedSessions)
})
}
}
func TestSession_Get(t *testing.T) {
tx, err := pool.Begin(t.Context(), nil)
require.NoError(t, err)
defer func() {
err := tx.Rollback(t.Context())
if err != nil {
t.Logf("error during rollback: %v", err)
}
}()
instanceRepo := repository.InstanceRepository()
sessionRepo := repository.SessionRepository()
// create instance
instanceId := gofakeit.Name()
instance := domain.Instance{
ID: instanceId,
Name: gofakeit.Name(),
DefaultOrgID: "defaultOrgId",
IAMProjectID: "iamProject",
ConsoleClientID: "managementConsoleClient",
ConsoleAppID: "managementConsoleApp",
DefaultLanguage: "defaultLanguage",
}
err = instanceRepo.Create(t.Context(), tx, &instance)
require.NoError(t, err)
session := &domain.Session{
InstanceID: instanceId,
ID: gofakeit.Name(),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
}
err = sessionRepo.Create(t.Context(), tx, session)
require.NoError(t, err)
tests := []struct {
name string
condition database.Condition
err error
}{
{
name: "get existent session by primary key",
condition: sessionRepo.PrimaryKeyCondition(instanceId, session.ID),
err: nil,
},
{
name: "get without instanceID condition",
condition: sessionRepo.IDCondition(session.ID),
err: new(database.MissingConditionError),
},
{
name: "get non existent session",
condition: sessionRepo.PrimaryKeyCondition(instanceId, gofakeit.Name()),
err: new(database.NoRowFoundError),
},
// other conditions are tested in the List tests
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// get session
got, err := sessionRepo.Get(t.Context(), tx, database.WithCondition(tt.condition))
assert.ErrorIs(t, err, tt.err)
if err != nil {
return
}
// check session values
assert.Equal(t, session.ID, got.ID)
assert.Equal(t, session.InstanceID, got.InstanceID)
assert.Equal(t, session.Lifetime, got.Lifetime)
assert.Equal(t, session.CreatorID, got.CreatorID)
})
}
}
func TestSession_List(t *testing.T) {
tx, err := pool.Begin(t.Context(), nil)
require.NoError(t, err)
defer func() {
err := tx.Rollback(t.Context())
if err != nil {
t.Logf("error during rollback: %v", err)
}
}()
instanceRepo := repository.InstanceRepository()
sessionRepo := repository.SessionRepository()
// create instance
instanceId := gofakeit.Name()
instance := domain.Instance{
ID: instanceId,
Name: gofakeit.Name(),
DefaultOrgID: "defaultOrgId",
IAMProjectID: "iamProject",
ConsoleClientID: "managementConsoleClient",
ConsoleAppID: "managementConsoleApp",
DefaultLanguage: "defaultLanguage",
}
err = instanceRepo.Create(t.Context(), tx, &instance)
require.NoError(t, err)
orgID := createOrganization(t, tx, instanceId)
userIDs := []string{createHumanUser(t, tx, instanceId, orgID), createHumanUser(t, tx, instanceId, orgID)}
// create sessions
sessions := make([]*domain.Session, 5)
for i := range 5 {
session := &domain.Session{
InstanceID: instanceId,
ID: strconv.Itoa(i),
Lifetime: time.Hour * 24,
CreatorID: gofakeit.Name(),
CreatedAt: time.Now(),
UserAgent: &domain.SessionUserAgent{
FingerprintID: gu.Ptr(gofakeit.UUID()),
Description: gu.Ptr(gofakeit.Name()),
IP: net.ParseIP(gofakeit.IPv4Address()),
Header: http.Header{"user-agent": []string{gofakeit.Name()}},
},
}
err = sessionRepo.Create(t.Context(), tx, session)
require.NoError(t, err)
session.Expiration = session.CreatedAt.Add(session.Lifetime)
changes := make([]database.Change, 0, 4)
updated := time.Now()
changes = append(changes, sessionRepo.SetUpdatedAt(updated))
session.UpdatedAt = updated
metadata := []*domain.SessionMetadata{
{
Metadata: domain.Metadata{
InstanceID: instanceId,
Key: "key" + strconv.Itoa(i),
Value: []byte{uint8(i)},
},
SessionID: session.ID,
},
}
session.Metadata = metadata
changes = append(changes, sessionRepo.SetMetadata(metadata))
userFactor := &domain.SessionFactorUser{
UserID: userIDs[i%2],
LastVerifiedAt: time.Now(),
}
session.Factors.AppendTo(userFactor)
changes = append(changes, sessionRepo.SetFactor(userFactor))
if i < 3 {
passwordFactor := &domain.SessionFactorPassword{
LastVerifiedAt: time.Now(),
}
session.Factors.AppendTo(passwordFactor)
changes = append(changes, sessionRepo.SetFactor(passwordFactor))
}
_, err = sessionRepo.Update(t.Context(), tx, sessionRepo.PrimaryKeyCondition(instanceId, session.ID),
changes...,
)
require.NoError(t, err)
session.UserID = userFactor.UserID
sessions[i] = session
}
tests := []struct {
name string
options []database.QueryOption
expected []*domain.Session
err error
}{
{
name: "list sessions by instanceID",
options: []database.QueryOption{
database.WithCondition(
sessionRepo.InstanceIDCondition(instanceId),
),
database.WithOrderByAscending(sessionRepo.IDColumn()),
},
expected: sessions,
err: nil,
},
{
name: "list single session by id",
options: []database.QueryOption{
database.WithCondition(
database.And(
sessionRepo.IDCondition(sessions[0].ID),
sessionRepo.InstanceIDCondition(instanceId),
),
),
},
expected: []*domain.Session{sessions[0]},
err: nil,
},
{
name: "list sessions with non matching condition",
options: []database.QueryOption{
database.WithCondition(
database.And(
sessionRepo.IDCondition("non-existent-id"),
sessionRepo.InstanceIDCondition(instanceId),
),
),
},
expected: []*domain.Session{},
err: nil,
},
{
name: "list sessions without conditions",
options: nil,
expected: nil,
err: new(database.MissingConditionError),
},
{
name: "list sessions by user agent fingerprintID",
options: []database.QueryOption{
database.WithCondition(
database.And(
sessionRepo.UserAgentIDCondition(gu.Value(sessions[1].UserAgent.FingerprintID)),
sessionRepo.InstanceIDCondition(instanceId),
),
),
},
expected: []*domain.Session{sessions[1]},
err: nil,
},
{
name: "list sessions by userID",
options: []database.QueryOption{
database.WithCondition(
database.And(
sessionRepo.UserIDCondition(sessions[0].Factors.GetUserFactor().UserID),
sessionRepo.InstanceIDCondition(instanceId),
),
),
database.WithOrderByAscending(sessionRepo.IDColumn()),
database.WithLimit(2),
},
expected: []*domain.Session{sessions[0], sessions[2]},
err: nil,
},
{
name: "list sessions by creatorID",
options: []database.QueryOption{
database.WithCondition(
database.And(
sessionRepo.CreatorIDCondition(sessions[3].CreatorID),
sessionRepo.InstanceIDCondition(instanceId),
),
),
},
expected: []*domain.Session{sessions[3]},
},
{
name: "list sessions by expiration",
options: []database.QueryOption{
database.WithCondition(
database.And(
sessionRepo.ExpirationCondition(database.NumberOperationGreaterThanOrEqual, sessions[3].Expiration),
sessionRepo.InstanceIDCondition(instanceId),
),
),
database.WithOrderByAscending(sessionRepo.IDColumn()),
},
expected: []*domain.Session{sessions[3], sessions[4]},
},
{
name: "list sessions by created at",
options: []database.QueryOption{
database.WithCondition(
database.And(
sessionRepo.CreatedAtCondition(database.NumberOperationLessThan, sessions[3].CreatedAt),
sessionRepo.InstanceIDCondition(instanceId),
),
),
database.WithOrderByAscending(sessionRepo.IDColumn()),
},
expected: []*domain.Session{sessions[0], sessions[1], sessions[2]},
},
{
name: "list sessions by factor type",
options: []database.QueryOption{
database.WithCondition(
database.And(
sessionRepo.ExistsFactor(sessionRepo.FactorConditions().FactorTypeCondition(domain.SessionFactorTypePassword)),
sessionRepo.InstanceIDCondition(instanceId),
),
),
database.WithOrderByAscending(sessionRepo.IDColumn()),
},
expected: []*domain.Session{sessions[0], sessions[1], sessions[2]},
},
{
name: "list sessions by factor verification time",
options: []database.QueryOption{
database.WithCondition(
database.And(
sessionRepo.ExistsFactor(sessionRepo.FactorConditions().LastVerifiedBeforeCondition(sessions[2].Factors.GetUserFactor().LastVerifiedAt)),
sessionRepo.InstanceIDCondition(instanceId),
),
),
database.WithOrderByAscending(sessionRepo.IDColumn()),
},
expected: []*domain.Session{sessions[0], sessions[1]},
},
{
name: "list sessions by metadata key",
options: []database.QueryOption{
database.WithCondition(
database.And(
sessionRepo.ExistsMetadata(sessionRepo.MetadataConditions().KeyCondition(database.TextOperationEqual, "key3")),
sessionRepo.InstanceIDCondition(instanceId),
),
),
database.WithOrderByAscending(sessionRepo.IDColumn()),
},
expected: []*domain.Session{sessions[3]},
},
{
name: "list sessions by metadata value",
options: []database.QueryOption{
database.WithCondition(
database.And(
sessionRepo.ExistsMetadata(sessionRepo.MetadataConditions().ValueCondition(database.BytesOperationEqual, []byte{3})),
sessionRepo.InstanceIDCondition(instanceId),
),
),
database.WithOrderByAscending(sessionRepo.IDColumn()),
},
expected: []*domain.Session{sessions[3]},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// list sessions
got, err := sessionRepo.List(t.Context(), tx, tt.options...)
assert.ErrorIs(t, err, tt.err)
if err != nil {
return
}
assert.Len(t, got, len(tt.expected))
for _, session := range got {
slices.SortFunc(session.Factors, func(a, b domain.SessionFactor) int { // sort to make comparison possible
return int(a.SessionFactorType()) - int(b.SessionFactorType())
})
}
assert.EqualExportedValues(t, tt.expected, got)
})
}
}