package services import ( "net/http/httptest" "path/filepath" "strings" "sync" "sync/atomic" "testing" "time" "agent-desk/internal/models" "agent-desk/internal/pkg/config" "agent-desk/internal/pkg/dto/response" "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/openidentity" "agent-desk/internal/repositories" "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "github.com/golang-jwt/jwt/v5" "github.com/mlogclub/simple/sqls" "gorm.io/gorm" "gorm.io/gorm/logger" "gorm.io/gorm/schema" ) func setupIdentitySecurityDB(t *testing.T) (*gorm.DB, *models.Channel) { t.Helper() db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "identity.db")), &gorm.Config{ NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}, Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { t.Fatal(err) } if err := db.AutoMigrate(&models.Customer{}, &models.CustomerIdentity{}, &models.Channel{}, &models.Conversation{}); err != nil { t.Fatal(err) } previousDB, previousConfig := sqls.DB(), config.GetCurrent() sqls.SetDB(db) config.SetCurrent(&config.Config{CustomerSession: config.CustomerSessionConfig{Secret: "test-only-customer-session-key", TTLMinutes: 120}}) t.Cleanup(func() { sqls.SetDB(previousDB) config.SetCurrent(previousConfig) sqlDB, _ := db.DB() _ = sqlDB.Close() }) channel := &models.Channel{ChannelID: "channel-a", ChannelType: enums.ChannelTypeWeb, Status: enums.StatusOk, ConfigJSON: "{}"} if err := db.Create(channel).Error; err != nil { t.Fatal(err) } return db, channel } func scopedTestExternal(channel *models.Channel, issuer, id string) openidentity.ExternalUser { source := enums.ExternalSourceUser if issuer == "guest" { source = enums.ExternalSourceGuest } return openidentity.ExternalUser{ExternalSource: source, ExternalID: id, IdentityScope: openidentity.IdentityScope(channel.ChannelID, issuer), ChannelID: channel.ID} } func verifySecurityCustomerSession(t *testing.T, channel *models.Channel, token string) *CustomerSessionVerifyResult { t.Helper() ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) ctx.Request = httptest.NewRequest("GET", "/api/ws/open", nil) ctx.Request.Header.Set("Authorization", "Bearer "+token) result, err := CustomerSessionService.VerifyRequest(ctx, channel) if err != nil { t.Fatalf("verify customer credential: %v", err) } return result } func TestCustomerIdentitySeparatesTrustIssuerAndChannel(t *testing.T) { db, channel := setupIdentitySecurityDB(t) otherChannel := &models.Channel{ChannelID: "channel-b", ChannelType: enums.ChannelTypeWeb, Status: enums.StatusOk, ConfigJSON: "{}"} if err := db.Create(otherChannel).Error; err != nil { t.Fatal(err) } customerIDs := map[int64]bool{} for _, input := range []struct { channel *models.Channel issuer string }{{channel, "hmac"}, {channel, "jwt"}, {channel, "guest"}, {otherChannel, "hmac"}} { exchanged, err := CustomerSessionService.Exchange(input.channel, scopedTestExternal(input.channel, input.issuer, "same-id")) if err != nil { t.Fatal(err) } if customerIDs[exchanged.Customer.ID] { t.Fatal("independent identity scopes were merged") } customerIDs[exchanged.Customer.ID] = true result := verifySecurityCustomerSession(t, input.channel, exchanged.CustomerSessionToken) if !CustomerSessionService.VerifyConnection(result) { t.Fatal("fresh scoped connection rejected") } wrongChannel := &models.Conversation{CustomerID: result.CustomerID, ChannelID: otherChannel.ID} if input.channel.ID != otherChannel.ID && ConversationService.IsCustomerConversationOwner(wrongChannel, *result.ExternalUser) { t.Fatal("customer acquired a different channel's conversation") } } } func TestGuestBootstrapCannotReclaimKnownAnonymousIdentity(t *testing.T) { _, channel := setupIdentitySecurityDB(t) first, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel, "guest", "caller-chosen-id")) if err != nil { t.Fatal(err) } stolenID := strings.TrimPrefix(first.IdentityKey, "guest:") if len(stolenID) != 64 || stolenID == "caller-chosen-id" { t.Fatal("guest identity was not server-generated") } second, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel, "guest", stolenID)) if err != nil { t.Fatal(err) } if first.Customer.ID == second.Customer.ID || first.IdentityKey == second.IdentityKey { t.Fatal("public guest bootstrap reclaimed another guest identity") } original := verifySecurityCustomerSession(t, channel, first.CustomerSessionToken) if original.CustomerID != first.Customer.ID { t.Fatal("signed guest credential did not retain its owner") } } func TestCustomerIdentityDuplicatesFailClosedWithoutCreatingAnotherCustomer(t *testing.T) { db, channel := setupIdentitySecurityDB(t) external := scopedTestExternal(channel, "hmac", "duplicate") for i := 0; i < 2; i++ { customer := &models.Customer{Status: enums.StatusOk} if err := db.Create(customer).Error; err != nil { t.Fatal(err) } identity := &models.CustomerIdentity{CustomerID: customer.ID, IdentityScope: external.IdentityScope, ExternalSource: external.ExternalSource, ExternalID: external.ExternalID, Status: enums.StatusOk} if err := db.Create(identity).Error; err != nil { t.Fatal(err) } } if _, err := CustomerSessionService.Exchange(channel, external); err == nil { t.Fatal("ambiguous identity was accepted") } var count int64 if err := db.Model(&models.Customer{}).Count(&count).Error; err != nil { t.Fatal(err) } if count != 2 { t.Fatalf("ambiguous mapping created another customer: %d", count) } identity, err := repositories.CustomerIdentityRepository.GetUniqueBy(db, external.IdentityScope, external.ExternalSource, external.ExternalID) if err == nil || identity != nil { t.Fatal("duplicate lookup did not fail closed") } } func TestCustomerSessionsRejectLegacyScopeAndLivePolicyChanges(t *testing.T) { db, channel := setupIdentitySecurityDB(t) exchanged, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel, "guest", "guest")) if err != nil { t.Fatal(err) } current := verifySecurityCustomerSession(t, channel, exchanged.CustomerSessionToken) claims, err := CustomerSessionService.verifyToken(exchanged.CustomerSessionToken) if err != nil { t.Fatal(err) } claims.IdentityScope = "" legacy, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(config.Current().CustomerSession.Secret)) if err != nil { t.Fatal(err) } if _, err := CustomerSessionService.verifyToken(legacy); err == nil { t.Fatal("unscoped legacy credential accepted") } if err := db.Model(channel).Update("config_json", `{"requireSignedIdentity":true}`).Error; err != nil { t.Fatal(err) } if CustomerSessionService.VerifyConnection(current) { t.Fatal("existing guest retained access after signed-only policy change") } channel.ConfigJSON = `{"requireSignedIdentity":true}` if _, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel, "guest", "guest")); err == nil { t.Fatal("signed-only channel accepted guest exchange") } } func TestCustomerConnectionRejectsExpiryDisabledCustomerAndIdentityChanges(t *testing.T) { for _, scenario := range []string{"expiry", "disabled-customer", "disabled-channel", "disabled-identity", "rebound-identity", "duplicate-identity"} { t.Run(scenario, func(t *testing.T) { db, channel := setupIdentitySecurityDB(t) exchanged, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel, "hmac", "signed")) if err != nil { t.Fatal(err) } current := verifySecurityCustomerSession(t, channel, exchanged.CustomerSessionToken) switch scenario { case "expiry": current.ExpiresAt = time.Now() case "disabled-customer": err = db.Model(&models.Customer{}).Where("id = ?", current.CustomerID).Update("status", enums.StatusDisabled).Error case "disabled-channel": err = db.Model(channel).Update("status", enums.StatusDisabled).Error case "disabled-identity": err = db.Model(&models.CustomerIdentity{}).Where("customer_id = ?", current.CustomerID).Update("status", enums.StatusDisabled).Error case "rebound-identity": err = db.Model(&models.CustomerIdentity{}).Where("customer_id = ?", current.CustomerID).Update("customer_id", current.CustomerID+100).Error case "duplicate-identity": err = db.Create(&models.CustomerIdentity{CustomerID: current.CustomerID + 100, ExternalSource: current.ExternalUser.ExternalSource, ExternalID: current.ExternalUser.ExternalID, IdentityScope: current.ExternalUser.IdentityScope, Status: enums.StatusOk}).Error } if err != nil { t.Fatal(err) } if CustomerSessionService.VerifyConnection(current) { t.Fatal("invalidated customer connection retained authority") } }) } } func TestCustomerRealtimeTopicsAndDeliveryRejectCrossScopeCollisions(t *testing.T) { db, channel := setupIdentitySecurityDB(t) ws := newWsService() signed, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel, "hmac", "known-id")) if err != nil { t.Fatal(err) } guest, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel, "guest", "known-id")) if err != nil { t.Fatal(err) } signedAuth := verifySecurityCustomerSession(t, channel, signed.CustomerSessionToken) guestAuth := verifySecurityCustomerSession(t, channel, guest.CustomerSessionToken) conversation := &models.Conversation{CustomerID: signed.Customer.ID, ChannelID: channel.ID} if err := db.Create(conversation).Error; err != nil { t.Fatal(err) } owner := &ClientSession{ID: "owner", Role: realtimeRoleUser, External: signedAuth.ExternalUser, CustomerSession: signedAuth, Topics: map[string]struct{}{}, Send: make(chan []byte, 4)} attacker := &ClientSession{ID: "guest", Role: realtimeRoleUser, External: guestAuth.ExternalUser, CustomerSession: guestAuth, Topics: map[string]struct{}{}, Send: make(chan []byte, 4)} if ws.customerTopic(signed.Customer.ID, channel.ID) == ws.customerTopic(guest.Customer.ID, channel.ID) { t.Fatal("trust namespaces collided") } if ws.customerTopic(signed.Customer.ID, channel.ID) == ws.customerTopic(signed.Customer.ID, channel.ID+1) { t.Fatal("channel namespaces collided") } ws.manager.Register(owner, ws.defaultTopics(owner)) // Defense in depth: even an accidentally stale/wrong topic registration may // not grant the guest authorization to the signed customer's conversation. ws.manager.Register(attacker, ws.routeConversationTopics(conversation)) ws.PublishAgentActivity(conversation, "step", "", "running") select { case <-owner.Send: default: t.Fatal("owner did not receive its event") } select { case <-attacker.Send: t.Fatal("guest received another customer's event") default: } if ws.canSubscribeConversation(attacker, conversation.ID) { t.Fatal("explicit foreign conversation subscription allowed") } } func TestScopedIdentityUniqueClaimPreservesLegacyRows(t *testing.T) { db, channel := setupIdentitySecurityDB(t) source := enums.ExternalSourceUser // Dirty legacy mappings remain migratable because their unique claim is NULL. for _, customerID := range []int64{100, 101} { if err := db.Create(&models.CustomerIdentity{CustomerID: customerID, ExternalSource: source, ExternalID: "same", Status: enums.StatusOk}).Error; err != nil { t.Fatal(err) } } scope := openidentity.IdentityScope(channel.ChannelID, "hmac") first := &models.CustomerIdentity{CustomerID: 102, IdentityScope: scope, ScopedKey: repositories.ScopedCustomerIdentityKey(scope, source, "same"), ExternalSource: source, ExternalID: "same", Status: enums.StatusOk} if err := db.Create(first).Error; err != nil { t.Fatal(err) } competing := &models.CustomerIdentity{CustomerID: 103, IdentityScope: scope, ScopedKey: repositories.ScopedCustomerIdentityKey(scope, source, "same"), ExternalSource: source, ExternalID: "same", Status: enums.StatusOk} if err := db.Create(competing).Error; err == nil { t.Fatal("database allowed a competing scoped identity claim") } otherScope := openidentity.IdentityScope(channel.ChannelID, "jwt") if err := db.Create(&models.CustomerIdentity{CustomerID: 104, IdentityScope: otherScope, ScopedKey: repositories.ScopedCustomerIdentityKey(otherScope, source, "same"), ExternalSource: source, ExternalID: "same", Status: enums.StatusOk}).Error; err != nil { t.Fatal("different issuer's claim was conflated") } found, err := repositories.CustomerIdentityRepository.GetUniqueBy(db, scope, source, "same") if err != nil || found == nil || found.CustomerID != 102 { t.Fatal("legacy mappings interfered with scoped identity") } } func TestWebIdentityPolicySurvivesOlderClientEdits(t *testing.T) { svc := newChannelService() raw, err := svc.preserveWebIdentityPolicy(`{"title":"New title"}`, `{"requireSignedIdentity":true}`) if err != nil { t.Fatal(err) } cfg, err := svc.ParseWebChannelConfig(raw) if err != nil || !cfg.RequireSignedIdentity { t.Fatal("older client edit silently disabled signed-only policy") } raw, err = svc.preserveWebIdentityPolicy(`{"requireSignedIdentity":false}`, `{"requireSignedIdentity":true}`) if err != nil { t.Fatal(err) } cfg, err = svc.ParseWebChannelConfig(raw) if err != nil || cfg.RequireSignedIdentity { t.Fatal("explicit policy change was not honored") } for _, raw := range []string{`{"requireSignedIdentity":null}`, `{"requireSignedIdentity":"false"}`, `{"requireSignedIdentity":0}`} { if _, err := svc.ParseWebChannelConfig(raw); err == nil { t.Fatal("non-boolean authentication policy accepted") } } } func TestCustomerSessionNearExpiryRefreshPreservesIdentity(t *testing.T) { db, channel := setupIdentitySecurityDB(t) exchanged, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel, "guest", "local-context")) if err != nil { t.Fatal(err) } claims, err := CustomerSessionService.verifyToken(exchanged.CustomerSessionToken) if err != nil { t.Fatal(err) } originalKey, originalScope, originalCustomer := claims.IdentityKey, claims.IdentityScope, claims.CustomerID claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(20 * time.Second)) nearExpiry, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(config.Current().CustomerSession.Secret)) if err != nil { t.Fatal(err) } refreshed := verifySecurityCustomerSession(t, channel, nearExpiry) if !refreshed.Refreshed || !refreshed.ExpiresAt.After(claims.ExpiresAt.Time) || refreshed.Token == nearExpiry { t.Fatal("near-expiry credential was not refreshed") } refreshedClaims, err := CustomerSessionService.verifyToken(refreshed.Token) if err != nil { t.Fatal(err) } if refreshedClaims.IdentityKey != originalKey || refreshedClaims.IdentityScope != originalScope || refreshedClaims.CustomerID != originalCustomer { t.Fatal("refresh changed anonymous identity") } var customers int64 if err := db.Model(&models.Customer{}).Count(&customers).Error; err != nil { t.Fatal(err) } if customers != 1 { t.Fatal("refresh allocated another customer") } claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(-time.Second)) expired, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(config.Current().CustomerSession.Secret)) if err != nil { t.Fatal(err) } ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) ctx.Request = httptest.NewRequest("POST", "/api/customer/session_refresh", nil) ctx.Request.Header.Set("Authorization", "Bearer "+expired) if _, err := CustomerSessionService.VerifyRequest(ctx, channel); err == nil { t.Fatal("expired anonymous credential was silently renewed") } } func TestConcurrentCustomerSessionExchangesKeepOneScopedOwner(t *testing.T) { db, channel := setupIdentitySecurityDB(t) if err := db.Exec("PRAGMA journal_mode=WAL").Error; err != nil { t.Fatal(err) } pool, err := db.DB() if err != nil { t.Fatal(err) } pool.SetMaxOpenConns(4) external := scopedTestExternal(channel, "hmac", "concurrent-id") var queries atomic.Int32 arrived := make(chan struct{}, 2) release := make(chan struct{}) var releaseOnce sync.Once releaseBoth := func() { releaseOnce.Do(func() { close(release) }) } var exchanges sync.WaitGroup defer func() { releaseBoth(); exchanges.Wait() }() callbackName := "test:concurrent_scoped_lookup" if err := db.Callback().Query().After("gorm:query").Register(callbackName, func(tx *gorm.DB) { if tx.Statement.Table != "t_customer_identity" || tx.Error != nil || tx.RowsAffected != 0 { return } if queries.Add(1) <= 2 { arrived <- struct{}{} <-release } }); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = db.Callback().Query().Remove(callbackName) }) type result struct { value *response.CustomerSessionExchangeResponse err error } results := make(chan result, 2) exchanges.Add(2) for i := 0; i < 2; i++ { go func() { defer exchanges.Done() value, err := CustomerSessionService.Exchange(channel, external) results <- result{value, err} }() } for i := 0; i < 2; i++ { select { case <-arrived: case <-time.After(2 * time.Second): releaseBoth() t.Fatal("concurrent transactions did not both reach initial identity lookup") } } releaseBoth() successes := 0 var owner int64 for i := 0; i < 2; i++ { select { case result := <-results: if result.err == nil { successes++ owner = result.value.Customer.ID } case <-time.After(2 * time.Second): t.Fatal("concurrent exchange did not finish") } } if successes != 1 { t.Fatalf("expected one transaction winner, got %d", successes) } var customers, identities int64 if err := db.Model(&models.Customer{}).Count(&customers).Error; err != nil { t.Fatal(err) } if err := db.Model(&models.CustomerIdentity{}).Count(&identities).Error; err != nil { t.Fatal(err) } if customers != 1 || identities != 1 { t.Fatal("losing transaction left an orphan or competing customer mapping") } retry, err := CustomerSessionService.Exchange(channel, external) if err != nil || retry.Customer.ID != owner { t.Fatal("retry did not resolve the unique winning owner") } }