| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526 |
- //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"])
- }
|