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) }) } }