fix(auth): harden oauth identity upgrade paths
This commit is contained in:
@@ -5,6 +5,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -259,6 +260,107 @@ func TestAuthPendingIdentityService_UpsertAdoptionDecision_ReassignsExistingIden
|
||||
require.Nil(t, reloadedFirst.IdentityID)
|
||||
}
|
||||
|
||||
func TestAuthPendingIdentityService_UpsertAdoptionDecision_IsIdempotentUnderConcurrency(t *testing.T) {
|
||||
svc, client := newAuthPendingIdentityServiceTestClient(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := client.User.Create().
|
||||
SetEmail("adoption-concurrent@example.com").
|
||||
SetPasswordHash("hash").
|
||||
SetRole(RoleUser).
|
||||
SetStatus(StatusActive).
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
identity, err := client.AuthIdentity.Create().
|
||||
SetUserID(user.ID).
|
||||
SetProviderType("wechat").
|
||||
SetProviderKey("wechat-main").
|
||||
SetProviderSubject("union-concurrent").
|
||||
SetMetadata(map[string]any{}).
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
session, err := svc.CreatePendingSession(ctx, CreatePendingAuthSessionInput{
|
||||
Intent: "bind_current_user",
|
||||
Identity: PendingAuthIdentityKey{
|
||||
ProviderType: "wechat",
|
||||
ProviderKey: "wechat-main",
|
||||
ProviderSubject: "union-concurrent",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
firstCreateStarted := make(chan struct{})
|
||||
releaseFirstCreate := make(chan struct{})
|
||||
var firstCreate sync.Once
|
||||
client.IdentityAdoptionDecision.Use(func(next dbent.Mutator) dbent.Mutator {
|
||||
return dbent.MutateFunc(func(ctx context.Context, m dbent.Mutation) (dbent.Value, error) {
|
||||
blocked := false
|
||||
if m.Op().Is(dbent.OpCreate) {
|
||||
firstCreate.Do(func() {
|
||||
blocked = true
|
||||
close(firstCreateStarted)
|
||||
})
|
||||
}
|
||||
if blocked {
|
||||
<-releaseFirstCreate
|
||||
}
|
||||
return next.Mutate(ctx, m)
|
||||
})
|
||||
})
|
||||
|
||||
type adoptionResult struct {
|
||||
decision *dbent.IdentityAdoptionDecision
|
||||
err error
|
||||
}
|
||||
|
||||
input := PendingIdentityAdoptionDecisionInput{
|
||||
PendingAuthSessionID: session.ID,
|
||||
IdentityID: &identity.ID,
|
||||
AdoptDisplayName: true,
|
||||
AdoptAvatar: true,
|
||||
}
|
||||
|
||||
results := make(chan adoptionResult, 2)
|
||||
go func() {
|
||||
decision, err := svc.UpsertAdoptionDecision(ctx, input)
|
||||
results <- adoptionResult{decision: decision, err: err}
|
||||
}()
|
||||
|
||||
<-firstCreateStarted
|
||||
|
||||
go func() {
|
||||
decision, err := svc.UpsertAdoptionDecision(ctx, input)
|
||||
results <- adoptionResult{decision: decision, err: err}
|
||||
}()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
close(releaseFirstCreate)
|
||||
|
||||
first := <-results
|
||||
second := <-results
|
||||
|
||||
require.NoError(t, first.err)
|
||||
require.NoError(t, second.err)
|
||||
require.NotNil(t, first.decision)
|
||||
require.NotNil(t, second.decision)
|
||||
require.Equal(t, first.decision.ID, second.decision.ID)
|
||||
|
||||
count, err := client.IdentityAdoptionDecision.Query().
|
||||
Where(identityadoptiondecision.PendingAuthSessionIDEQ(session.ID)).
|
||||
Count(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, count)
|
||||
|
||||
loaded, err := client.IdentityAdoptionDecision.Query().
|
||||
Where(identityadoptiondecision.PendingAuthSessionIDEQ(session.ID)).
|
||||
Only(ctx)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, loaded.IdentityID)
|
||||
require.Equal(t, identity.ID, *loaded.IdentityID)
|
||||
}
|
||||
|
||||
func TestAuthPendingIdentityService_UpsertAdoptionDecision_ClearsLegacyNullSessionReference(t *testing.T) {
|
||||
t.Skip("legacy NULL pending_auth_session_id rows only exist in production PostgreSQL history; sqlite unit schema rejects NULL")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user