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