527 lines
16 KiB
Go
527 lines
16 KiB
Go
//go:build unit
|
|
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
|
"github.com/Wei-Shaw/sub2api/ent/enttest"
|
|
"github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"entgo.io/ent/dialect"
|
|
entsql "entgo.io/ent/dialect/sql"
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
func newAuthPendingIdentityServiceTestClient(t *testing.T) (*AuthPendingIdentityService, *dbent.Client) {
|
|
t.Helper()
|
|
|
|
db, err := sql.Open("sqlite", "file:auth_pending_identity_service?mode=memory&cache=shared")
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
_, err = db.Exec("PRAGMA foreign_keys = ON")
|
|
require.NoError(t, err)
|
|
|
|
drv := entsql.OpenDB(dialect.SQLite, db)
|
|
client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
|
|
t.Cleanup(func() { _ = client.Close() })
|
|
|
|
return NewAuthPendingIdentityService(client), client
|
|
}
|
|
|
|
func TestAuthPendingIdentityService_CreatePendingSessionStoresSeparatedState(t *testing.T) {
|
|
svc, client := newAuthPendingIdentityServiceTestClient(t)
|
|
ctx := context.Background()
|
|
|
|
targetUser, err := client.User.Create().
|
|
SetEmail("pending-target@example.com").
|
|
SetPasswordHash("hash").
|
|
SetRole(RoleUser).
|
|
SetStatus(StatusActive).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
session, err := svc.CreatePendingSession(ctx, CreatePendingAuthSessionInput{
|
|
Intent: "bind_current_user",
|
|
Identity: PendingAuthIdentityKey{
|
|
ProviderType: "wechat",
|
|
ProviderKey: "wechat-open",
|
|
ProviderSubject: "union-123",
|
|
},
|
|
TargetUserID: &targetUser.ID,
|
|
RedirectTo: "/profile",
|
|
ResolvedEmail: "user@example.com",
|
|
BrowserSessionKey: "browser-1",
|
|
UpstreamIdentityClaims: map[string]any{"nickname": "wx-user", "avatar_url": "https://cdn.example/avatar.png"},
|
|
LocalFlowState: map[string]any{"step": "email_required"},
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, session.SessionToken)
|
|
require.Equal(t, "bind_current_user", session.Intent)
|
|
require.Equal(t, "wechat", session.ProviderType)
|
|
require.NotNil(t, session.TargetUserID)
|
|
require.Equal(t, targetUser.ID, *session.TargetUserID)
|
|
require.Equal(t, "wx-user", session.UpstreamIdentityClaims["nickname"])
|
|
require.Equal(t, "email_required", session.LocalFlowState["step"])
|
|
}
|
|
|
|
func TestAuthPendingIdentityService_CompletionCodeIsBrowserBoundAndOneTime(t *testing.T) {
|
|
svc, _ := newAuthPendingIdentityServiceTestClient(t)
|
|
ctx := context.Background()
|
|
|
|
session, err := svc.CreatePendingSession(ctx, CreatePendingAuthSessionInput{
|
|
Intent: "login",
|
|
Identity: PendingAuthIdentityKey{
|
|
ProviderType: "linuxdo",
|
|
ProviderKey: "linuxdo-main",
|
|
ProviderSubject: "subject-1",
|
|
},
|
|
BrowserSessionKey: "browser-expected",
|
|
UpstreamIdentityClaims: map[string]any{"nickname": "linux-user"},
|
|
LocalFlowState: map[string]any{"step": "pending"},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
issued, err := svc.IssueCompletionCode(ctx, IssuePendingAuthCompletionCodeInput{
|
|
PendingAuthSessionID: session.ID,
|
|
BrowserSessionKey: "browser-expected",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, issued.Code)
|
|
|
|
_, err = svc.ConsumeCompletionCode(ctx, issued.Code, "browser-other")
|
|
require.ErrorIs(t, err, ErrPendingAuthBrowserMismatch)
|
|
|
|
consumed, err := svc.ConsumeCompletionCode(ctx, issued.Code, "browser-expected")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, consumed.ConsumedAt)
|
|
require.Empty(t, consumed.CompletionCodeHash)
|
|
require.Nil(t, consumed.CompletionCodeExpiresAt)
|
|
|
|
_, err = svc.ConsumeCompletionCode(ctx, issued.Code, "browser-expected")
|
|
require.ErrorIs(t, err, ErrPendingAuthCodeInvalid)
|
|
}
|
|
|
|
func TestAuthPendingIdentityService_CompletionCodeExpires(t *testing.T) {
|
|
svc, client := newAuthPendingIdentityServiceTestClient(t)
|
|
ctx := context.Background()
|
|
|
|
session, err := svc.CreatePendingSession(ctx, CreatePendingAuthSessionInput{
|
|
Intent: "login",
|
|
Identity: PendingAuthIdentityKey{
|
|
ProviderType: "oidc",
|
|
ProviderKey: "https://issuer.example",
|
|
ProviderSubject: "subject-1",
|
|
},
|
|
BrowserSessionKey: "browser-expired",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
issued, err := svc.IssueCompletionCode(ctx, IssuePendingAuthCompletionCodeInput{
|
|
PendingAuthSessionID: session.ID,
|
|
BrowserSessionKey: "browser-expired",
|
|
TTL: time.Second,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = client.PendingAuthSession.UpdateOneID(session.ID).
|
|
SetCompletionCodeExpiresAt(time.Now().UTC().Add(-time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
_, err = svc.ConsumeCompletionCode(ctx, issued.Code, "browser-expired")
|
|
require.ErrorIs(t, err, ErrPendingAuthCodeExpired)
|
|
}
|
|
|
|
func TestAuthPendingIdentityService_UpsertAdoptionDecision(t *testing.T) {
|
|
svc, client := newAuthPendingIdentityServiceTestClient(t)
|
|
ctx := context.Background()
|
|
|
|
user, err := client.User.Create().
|
|
SetEmail("adoption@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-open").
|
|
SetProviderSubject("union-adoption").
|
|
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-open",
|
|
ProviderSubject: "union-adoption",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
first, err := svc.UpsertAdoptionDecision(ctx, PendingIdentityAdoptionDecisionInput{
|
|
PendingAuthSessionID: session.ID,
|
|
AdoptDisplayName: true,
|
|
AdoptAvatar: false,
|
|
})
|
|
require.NoError(t, err)
|
|
require.True(t, first.AdoptDisplayName)
|
|
require.False(t, first.AdoptAvatar)
|
|
require.Nil(t, first.IdentityID)
|
|
|
|
second, err := svc.UpsertAdoptionDecision(ctx, PendingIdentityAdoptionDecisionInput{
|
|
PendingAuthSessionID: session.ID,
|
|
IdentityID: &identity.ID,
|
|
AdoptDisplayName: true,
|
|
AdoptAvatar: true,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, first.ID, second.ID)
|
|
require.NotNil(t, second.IdentityID)
|
|
require.Equal(t, identity.ID, *second.IdentityID)
|
|
require.True(t, second.AdoptAvatar)
|
|
}
|
|
|
|
func TestAuthPendingIdentityService_UpsertAdoptionDecision_ReassignsExistingIdentityReference(t *testing.T) {
|
|
svc, client := newAuthPendingIdentityServiceTestClient(t)
|
|
ctx := context.Background()
|
|
|
|
user, err := client.User.Create().
|
|
SetEmail("adoption-reassign@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-open").
|
|
SetProviderSubject("union-reassign").
|
|
SetMetadata(map[string]any{}).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
firstSession, err := svc.CreatePendingSession(ctx, CreatePendingAuthSessionInput{
|
|
Intent: "bind_current_user",
|
|
Identity: PendingAuthIdentityKey{
|
|
ProviderType: "wechat",
|
|
ProviderKey: "wechat-open",
|
|
ProviderSubject: "union-reassign",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
firstDecision, err := svc.UpsertAdoptionDecision(ctx, PendingIdentityAdoptionDecisionInput{
|
|
PendingAuthSessionID: firstSession.ID,
|
|
IdentityID: &identity.ID,
|
|
AdoptDisplayName: true,
|
|
AdoptAvatar: false,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, firstDecision.IdentityID)
|
|
require.Equal(t, identity.ID, *firstDecision.IdentityID)
|
|
|
|
secondSession, err := svc.CreatePendingSession(ctx, CreatePendingAuthSessionInput{
|
|
Intent: "bind_current_user",
|
|
Identity: PendingAuthIdentityKey{
|
|
ProviderType: "wechat",
|
|
ProviderKey: "wechat-open",
|
|
ProviderSubject: "union-reassign",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
secondDecision, err := svc.UpsertAdoptionDecision(ctx, PendingIdentityAdoptionDecisionInput{
|
|
PendingAuthSessionID: secondSession.ID,
|
|
IdentityID: &identity.ID,
|
|
AdoptDisplayName: false,
|
|
AdoptAvatar: true,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, secondDecision.IdentityID)
|
|
require.Equal(t, identity.ID, *secondDecision.IdentityID)
|
|
|
|
reloadedFirst, err := client.IdentityAdoptionDecision.Get(ctx, firstDecision.ID)
|
|
require.NoError(t, err)
|
|
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")
|
|
|
|
svc, client := newAuthPendingIdentityServiceTestClient(t)
|
|
ctx := context.Background()
|
|
|
|
user, err := client.User.Create().
|
|
SetEmail("legacy-null-session@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("legacy-null-session").
|
|
SetMetadata(map[string]any{}).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
_, err = client.ExecContext(
|
|
ctx,
|
|
`INSERT INTO identity_adoption_decisions
|
|
(identity_id, adopt_display_name, adopt_avatar, decided_at, created_at, updated_at, pending_auth_session_id)
|
|
VALUES (?, ?, ?, ?, ?, ?, NULL)`,
|
|
identity.ID,
|
|
true,
|
|
false,
|
|
time.Now().UTC(),
|
|
time.Now().UTC(),
|
|
time.Now().UTC(),
|
|
)
|
|
require.NoError(t, err)
|
|
legacyDecision, err := client.IdentityAdoptionDecision.Query().
|
|
Where(identityadoptiondecision.IdentityIDEQ(identity.ID)).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, legacyDecision.IdentityID)
|
|
|
|
session, err := svc.CreatePendingSession(ctx, CreatePendingAuthSessionInput{
|
|
Intent: "bind_current_user",
|
|
Identity: PendingAuthIdentityKey{
|
|
ProviderType: "wechat",
|
|
ProviderKey: "wechat-main",
|
|
ProviderSubject: "legacy-null-session",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
decision, err := svc.UpsertAdoptionDecision(ctx, PendingIdentityAdoptionDecisionInput{
|
|
PendingAuthSessionID: session.ID,
|
|
IdentityID: &identity.ID,
|
|
AdoptDisplayName: false,
|
|
AdoptAvatar: true,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, decision.IdentityID)
|
|
require.Equal(t, identity.ID, *decision.IdentityID)
|
|
|
|
reloadedLegacy, err := client.IdentityAdoptionDecision.Get(ctx, legacyDecision.ID)
|
|
require.NoError(t, err)
|
|
require.Nil(t, reloadedLegacy.IdentityID)
|
|
}
|
|
|
|
func TestAuthPendingIdentityService_ConsumeBrowserSession(t *testing.T) {
|
|
svc, _ := newAuthPendingIdentityServiceTestClient(t)
|
|
ctx := context.Background()
|
|
|
|
session, err := svc.CreatePendingSession(ctx, CreatePendingAuthSessionInput{
|
|
Intent: "login",
|
|
Identity: PendingAuthIdentityKey{
|
|
ProviderType: "linuxdo",
|
|
ProviderKey: "linuxdo",
|
|
ProviderSubject: "subject-session-token",
|
|
},
|
|
BrowserSessionKey: "browser-session",
|
|
LocalFlowState: map[string]any{
|
|
"completion_response": map[string]any{
|
|
"access_token": "token",
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = svc.ConsumeBrowserSession(ctx, session.SessionToken, "browser-other")
|
|
require.ErrorIs(t, err, ErrPendingAuthBrowserMismatch)
|
|
|
|
consumed, err := svc.ConsumeBrowserSession(ctx, session.SessionToken, "browser-session")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, consumed.ConsumedAt)
|
|
|
|
_, err = svc.ConsumeBrowserSession(ctx, session.SessionToken, "browser-session")
|
|
require.ErrorIs(t, err, ErrPendingAuthSessionConsumed)
|
|
}
|
|
|
|
func TestAuthPendingIdentityService_ConsumeBrowserSessionRejectsStaleLoadedSessionReplay(t *testing.T) {
|
|
svc, _ := newAuthPendingIdentityServiceTestClient(t)
|
|
ctx := context.Background()
|
|
|
|
session, err := svc.CreatePendingSession(ctx, CreatePendingAuthSessionInput{
|
|
Intent: "login",
|
|
Identity: PendingAuthIdentityKey{
|
|
ProviderType: "linuxdo",
|
|
ProviderKey: "linuxdo",
|
|
ProviderSubject: "stale-replay-subject",
|
|
},
|
|
BrowserSessionKey: "browser-session",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
loaded, err := svc.getBrowserSession(ctx, session.SessionToken)
|
|
require.NoError(t, err)
|
|
|
|
consumed, err := svc.consumeSession(ctx, loaded, "browser-session", ErrPendingAuthSessionExpired, ErrPendingAuthSessionConsumed)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, consumed.ConsumedAt)
|
|
|
|
_, err = svc.consumeSession(ctx, loaded, "browser-session", ErrPendingAuthSessionExpired, ErrPendingAuthSessionConsumed)
|
|
require.ErrorIs(t, err, ErrPendingAuthSessionConsumed)
|
|
}
|
|
|
|
func TestAuthPendingIdentityService_ConsumeBrowserSessionScrubsLegacyCompletionTokens(t *testing.T) {
|
|
svc, client := newAuthPendingIdentityServiceTestClient(t)
|
|
ctx := context.Background()
|
|
|
|
session, err := svc.CreatePendingSession(ctx, CreatePendingAuthSessionInput{
|
|
Intent: "login",
|
|
Identity: PendingAuthIdentityKey{
|
|
ProviderType: "linuxdo",
|
|
ProviderKey: "linuxdo",
|
|
ProviderSubject: "legacy-token-subject",
|
|
},
|
|
BrowserSessionKey: "browser-session",
|
|
LocalFlowState: map[string]any{
|
|
"completion_response": map[string]any{
|
|
"access_token": "legacy-access-token",
|
|
"refresh_token": "legacy-refresh-token",
|
|
"expires_in": float64(3600),
|
|
"token_type": "Bearer",
|
|
"redirect": "/dashboard",
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
consumed, err := svc.ConsumeBrowserSession(ctx, session.SessionToken, "browser-session")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, consumed.ConsumedAt)
|
|
|
|
stored, err := client.PendingAuthSession.Get(ctx, session.ID)
|
|
require.NoError(t, err)
|
|
|
|
completion, ok := stored.LocalFlowState["completion_response"].(map[string]any)
|
|
require.True(t, ok)
|
|
require.NotContains(t, completion, "access_token")
|
|
require.NotContains(t, completion, "refresh_token")
|
|
require.NotContains(t, completion, "expires_in")
|
|
require.NotContains(t, completion, "token_type")
|
|
require.Equal(t, "/dashboard", completion["redirect"])
|
|
}
|