feat: harden support, localize knowledge and add operator availability [skip ci]
Source-only publication of reviewed commit 64e96a7f423673dc29ad56485be9fc79bce8af1c.
Passed in isolation: focused frontend/backend tests, race checks, TypeScript typecheck, lint, Go compilation, Flowgram build, and deterministic SDK build/tests.
Not accepted as a production release: Next.js export/native assembly remains blocked by environment memory limits; browser interaction and MySQL staging acceptance remain unverified. No production server, database/schema, runtime configuration, deployment, release tag, or workflow execution was performed.
Expected source tree: 0a4a7528e9.
This commit is contained in:
450
internal/services/customer_identity_security_test.go
Normal file
450
internal/services/customer_identity_security_test.go
Normal file
@@ -0,0 +1,450 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user