Files
zitadel/backend/v3/storage/database/repository/session_test.go
T
Marco A.andGitHub 14acc60edd feat: ListSessions to relational (#11987)
# Which Problems Are Solved

This PR introduces the possibility of using the relational DB when
calling the ListSession gRPC endpoint.

# How the Problems Are Solved

  - Implement the Querier interface for ListSession:
- Create a `domain` model for the Session object to be able to map the
gRPC session into it.
- Match the permission filters on ES side to be sure the same sessions
are returned
- Implement converter methods to go from `gRPC` to `domain` models (both
ways)
- Fix the session sql relational migration: rework the trigger to update
the `expiration` field to perform the update only when the input
`lifetime` has a non-zero value.
  - Link the Querier implementation with the gRPC endpoint
- Run the same integration tests against the relational DB, making sure
the outcome is the same. ⚠️ Due to missing permission checks, some
divergence is expected. Hence, specific code to circumvent the
differences has been put in place

# Additional Context

- Closes #11039
2026-04-16 10:58:17 +02:00

1576 lines
43 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"
"golang.org/x/text/language"
"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)
if tt.session.Lifetime != 0 {
assert.Equal(t, createdSession.UpdatedAt.Add(tt.session.Lifetime), createdSession.Expiration)
} else {
assert.Zero(t, 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().LoadUserData()
orgRepo := repository.OrganizationRepository()
orgDomainRepo := repository.OrganizationDomainRepository()
userRepo := repository.UserRepository()
settingsRepo := repository.DomainSettingsRepository()
// 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)
// Create Organization
orgID := gofakeit.UUID()
org := domain.Organization{
ID: orgID,
Name: gofakeit.Name(),
InstanceID: instanceId,
State: domain.OrgStateActive,
}
err = orgRepo.Create(t.Context(), tx, &org)
require.NoError(t, err)
domainName := gofakeit.DomainName()
err = orgDomainRepo.Add(t.Context(), tx, &domain.AddOrganizationDomain{
InstanceID: instanceId,
OrgID: orgID,
Domain: domainName,
IsVerified: true,
IsPrimary: true,
})
require.NoError(t, err)
err = settingsRepo.Set(t.Context(), tx, &domain.DomainSettings{
Settings: domain.Settings{
InstanceID: instanceId,
OrganizationID: &orgID,
Type: domain.SettingTypeDomain,
State: domain.SettingStateActive,
},
DomainSettingsAttributes: domain.DomainSettingsAttributes{
LoginNameIncludesDomain: gu.Ptr(false),
},
})
require.NoError(t, err)
preferredLoginName := gofakeit.Name()
userID := gofakeit.UUID()
displayName := gofakeit.Username()
user := domain.User{
InstanceID: instanceId,
OrganizationID: orgID,
ID: userID,
Username: preferredLoginName,
State: domain.UserStateActive,
Human: &domain.HumanUser{
FirstName: gofakeit.Name(),
LastName: gofakeit.Name(),
Nickname: gofakeit.Username(),
DisplayName: displayName,
PreferredLanguage: language.English,
Gender: domain.HumanGenderMale,
},
}
err = userRepo.Create(t.Context(), tx, &user)
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)
_, err = sessionRepo.Update(t.Context(), tx, sessionRepo.PrimaryKeyCondition(instanceId, session.ID), sessionRepo.SetFactor(&domain.SessionFactorUser{
UserID: userID,
LastVerifiedAt: time.Now(),
}))
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)
require.NotNil(t, got.UserOrganizationID)
assert.Equal(t, orgID, *got.UserOrganizationID)
assert.Equal(t, preferredLoginName, got.UserPreferredLoginName)
require.NotNil(t, got.UserDisplayName)
assert.Equal(t, displayName, *got.UserDisplayName)
})
}
}
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)
})
}
}