auth_pending_identity_service_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "database/sql"
  6. "sync"
  7. "testing"
  8. "time"
  9. dbent "github.com/Wei-Shaw/sub2api/ent"
  10. "github.com/Wei-Shaw/sub2api/ent/enttest"
  11. "github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
  12. "github.com/stretchr/testify/require"
  13. "entgo.io/ent/dialect"
  14. entsql "entgo.io/ent/dialect/sql"
  15. _ "modernc.org/sqlite"
  16. )
  17. func newAuthPendingIdentityServiceTestClient(t *testing.T) (*AuthPendingIdentityService, *dbent.Client) {
  18. t.Helper()
  19. db, err := sql.Open("sqlite", "file:auth_pending_identity_service?mode=memory&cache=shared")
  20. require.NoError(t, err)
  21. t.Cleanup(func() { _ = db.Close() })
  22. _, err = db.Exec("PRAGMA foreign_keys = ON")
  23. require.NoError(t, err)
  24. drv := entsql.OpenDB(dialect.SQLite, db)
  25. client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
  26. t.Cleanup(func() { _ = client.Close() })
  27. return NewAuthPendingIdentityService(client), client
  28. }
  29. func TestAuthPendingIdentityService_CreatePendingSessionStoresSeparatedState(t *testing.T) {
  30. svc, client := newAuthPendingIdentityServiceTestClient(t)
  31. ctx := context.Background()
  32. targetUser, err := client.User.Create().
  33. SetEmail("pending-target@example.com").
  34. SetPasswordHash("hash").
  35. SetRole(RoleUser).
  36. SetStatus(StatusActive).
  37. Save(ctx)
  38. require.NoError(t, err)
  39. session, err := svc.CreatePendingSession(ctx, CreatePendingAuthSessionInput{
  40. Intent: "bind_current_user",
  41. Identity: PendingAuthIdentityKey{
  42. ProviderType: "wechat",
  43. ProviderKey: "wechat-open",
  44. ProviderSubject: "union-123",
  45. },
  46. TargetUserID: &targetUser.ID,
  47. RedirectTo: "/profile",
  48. ResolvedEmail: "user@example.com",
  49. BrowserSessionKey: "browser-1",
  50. UpstreamIdentityClaims: map[string]any{"nickname": "wx-user", "avatar_url": "https://cdn.example/avatar.png"},
  51. LocalFlowState: map[string]any{"step": "email_required"},
  52. })
  53. require.NoError(t, err)
  54. require.NotEmpty(t, session.SessionToken)
  55. require.Equal(t, "bind_current_user", session.Intent)
  56. require.Equal(t, "wechat", session.ProviderType)
  57. require.NotNil(t, session.TargetUserID)
  58. require.Equal(t, targetUser.ID, *session.TargetUserID)
  59. require.Equal(t, "wx-user", session.UpstreamIdentityClaims["nickname"])
  60. require.Equal(t, "email_required", session.LocalFlowState["step"])
  61. }
  62. func TestAuthPendingIdentityService_CompletionCodeIsBrowserBoundAndOneTime(t *testing.T) {
  63. svc, _ := newAuthPendingIdentityServiceTestClient(t)
  64. ctx := context.Background()
  65. session, err := svc.CreatePendingSession(ctx, CreatePendingAuthSessionInput{
  66. Intent: "login",
  67. Identity: PendingAuthIdentityKey{
  68. ProviderType: "linuxdo",
  69. ProviderKey: "linuxdo-main",
  70. ProviderSubject: "subject-1",
  71. },
  72. BrowserSessionKey: "browser-expected",
  73. UpstreamIdentityClaims: map[string]any{"nickname": "linux-user"},
  74. LocalFlowState: map[string]any{"step": "pending"},
  75. })
  76. require.NoError(t, err)
  77. issued, err := svc.IssueCompletionCode(ctx, IssuePendingAuthCompletionCodeInput{
  78. PendingAuthSessionID: session.ID,
  79. BrowserSessionKey: "browser-expected",
  80. })
  81. require.NoError(t, err)
  82. require.NotEmpty(t, issued.Code)
  83. _, err = svc.ConsumeCompletionCode(ctx, issued.Code, "browser-other")
  84. require.ErrorIs(t, err, ErrPendingAuthBrowserMismatch)
  85. consumed, err := svc.ConsumeCompletionCode(ctx, issued.Code, "browser-expected")
  86. require.NoError(t, err)
  87. require.NotNil(t, consumed.ConsumedAt)
  88. require.Empty(t, consumed.CompletionCodeHash)
  89. require.Nil(t, consumed.CompletionCodeExpiresAt)
  90. _, err = svc.ConsumeCompletionCode(ctx, issued.Code, "browser-expected")
  91. require.ErrorIs(t, err, ErrPendingAuthCodeInvalid)
  92. }
  93. func TestAuthPendingIdentityService_CompletionCodeExpires(t *testing.T) {
  94. svc, client := newAuthPendingIdentityServiceTestClient(t)
  95. ctx := context.Background()
  96. session, err := svc.CreatePendingSession(ctx, CreatePendingAuthSessionInput{
  97. Intent: "login",
  98. Identity: PendingAuthIdentityKey{
  99. ProviderType: "oidc",
  100. ProviderKey: "https://issuer.example",
  101. ProviderSubject: "subject-1",
  102. },
  103. BrowserSessionKey: "browser-expired",
  104. })
  105. require.NoError(t, err)
  106. issued, err := svc.IssueCompletionCode(ctx, IssuePendingAuthCompletionCodeInput{
  107. PendingAuthSessionID: session.ID,
  108. BrowserSessionKey: "browser-expired",
  109. TTL: time.Second,
  110. })
  111. require.NoError(t, err)
  112. _, err = client.PendingAuthSession.UpdateOneID(session.ID).
  113. SetCompletionCodeExpiresAt(time.Now().UTC().Add(-time.Minute)).
  114. Save(ctx)
  115. require.NoError(t, err)
  116. _, err = svc.ConsumeCompletionCode(ctx, issued.Code, "browser-expired")
  117. require.ErrorIs(t, err, ErrPendingAuthCodeExpired)
  118. }
  119. func TestAuthPendingIdentityService_UpsertAdoptionDecision(t *testing.T) {
  120. svc, client := newAuthPendingIdentityServiceTestClient(t)
  121. ctx := context.Background()
  122. user, err := client.User.Create().
  123. SetEmail("adoption@example.com").
  124. SetPasswordHash("hash").
  125. SetRole(RoleUser).
  126. SetStatus(StatusActive).
  127. Save(ctx)
  128. require.NoError(t, err)
  129. identity, err := client.AuthIdentity.Create().
  130. SetUserID(user.ID).
  131. SetProviderType("wechat").
  132. SetProviderKey("wechat-open").
  133. SetProviderSubject("union-adoption").
  134. SetMetadata(map[string]any{}).
  135. Save(ctx)
  136. require.NoError(t, err)
  137. session, err := svc.CreatePendingSession(ctx, CreatePendingAuthSessionInput{
  138. Intent: "bind_current_user",
  139. Identity: PendingAuthIdentityKey{
  140. ProviderType: "wechat",
  141. ProviderKey: "wechat-open",
  142. ProviderSubject: "union-adoption",
  143. },
  144. })
  145. require.NoError(t, err)
  146. first, err := svc.UpsertAdoptionDecision(ctx, PendingIdentityAdoptionDecisionInput{
  147. PendingAuthSessionID: session.ID,
  148. AdoptDisplayName: true,
  149. AdoptAvatar: false,
  150. })
  151. require.NoError(t, err)
  152. require.True(t, first.AdoptDisplayName)
  153. require.False(t, first.AdoptAvatar)
  154. require.Nil(t, first.IdentityID)
  155. second, err := svc.UpsertAdoptionDecision(ctx, PendingIdentityAdoptionDecisionInput{
  156. PendingAuthSessionID: session.ID,
  157. IdentityID: &identity.ID,
  158. AdoptDisplayName: true,
  159. AdoptAvatar: true,
  160. })
  161. require.NoError(t, err)
  162. require.Equal(t, first.ID, second.ID)
  163. require.NotNil(t, second.IdentityID)
  164. require.Equal(t, identity.ID, *second.IdentityID)
  165. require.True(t, second.AdoptAvatar)
  166. }
  167. func TestAuthPendingIdentityService_UpsertAdoptionDecision_ReassignsExistingIdentityReference(t *testing.T) {
  168. svc, client := newAuthPendingIdentityServiceTestClient(t)
  169. ctx := context.Background()
  170. user, err := client.User.Create().
  171. SetEmail("adoption-reassign@example.com").
  172. SetPasswordHash("hash").
  173. SetRole(RoleUser).
  174. SetStatus(StatusActive).
  175. Save(ctx)
  176. require.NoError(t, err)
  177. identity, err := client.AuthIdentity.Create().
  178. SetUserID(user.ID).
  179. SetProviderType("wechat").
  180. SetProviderKey("wechat-open").
  181. SetProviderSubject("union-reassign").
  182. SetMetadata(map[string]any{}).
  183. Save(ctx)
  184. require.NoError(t, err)
  185. firstSession, err := svc.CreatePendingSession(ctx, CreatePendingAuthSessionInput{
  186. Intent: "bind_current_user",
  187. Identity: PendingAuthIdentityKey{
  188. ProviderType: "wechat",
  189. ProviderKey: "wechat-open",
  190. ProviderSubject: "union-reassign",
  191. },
  192. })
  193. require.NoError(t, err)
  194. firstDecision, err := svc.UpsertAdoptionDecision(ctx, PendingIdentityAdoptionDecisionInput{
  195. PendingAuthSessionID: firstSession.ID,
  196. IdentityID: &identity.ID,
  197. AdoptDisplayName: true,
  198. AdoptAvatar: false,
  199. })
  200. require.NoError(t, err)
  201. require.NotNil(t, firstDecision.IdentityID)
  202. require.Equal(t, identity.ID, *firstDecision.IdentityID)
  203. secondSession, err := svc.CreatePendingSession(ctx, CreatePendingAuthSessionInput{
  204. Intent: "bind_current_user",
  205. Identity: PendingAuthIdentityKey{
  206. ProviderType: "wechat",
  207. ProviderKey: "wechat-open",
  208. ProviderSubject: "union-reassign",
  209. },
  210. })
  211. require.NoError(t, err)
  212. secondDecision, err := svc.UpsertAdoptionDecision(ctx, PendingIdentityAdoptionDecisionInput{
  213. PendingAuthSessionID: secondSession.ID,
  214. IdentityID: &identity.ID,
  215. AdoptDisplayName: false,
  216. AdoptAvatar: true,
  217. })
  218. require.NoError(t, err)
  219. require.NotNil(t, secondDecision.IdentityID)
  220. require.Equal(t, identity.ID, *secondDecision.IdentityID)
  221. reloadedFirst, err := client.IdentityAdoptionDecision.Get(ctx, firstDecision.ID)
  222. require.NoError(t, err)
  223. require.Nil(t, reloadedFirst.IdentityID)
  224. }
  225. func TestAuthPendingIdentityService_UpsertAdoptionDecision_IsIdempotentUnderConcurrency(t *testing.T) {
  226. svc, client := newAuthPendingIdentityServiceTestClient(t)
  227. ctx := context.Background()
  228. user, err := client.User.Create().
  229. SetEmail("adoption-concurrent@example.com").
  230. SetPasswordHash("hash").
  231. SetRole(RoleUser).
  232. SetStatus(StatusActive).
  233. Save(ctx)
  234. require.NoError(t, err)
  235. identity, err := client.AuthIdentity.Create().
  236. SetUserID(user.ID).
  237. SetProviderType("wechat").
  238. SetProviderKey("wechat-main").
  239. SetProviderSubject("union-concurrent").
  240. SetMetadata(map[string]any{}).
  241. Save(ctx)
  242. require.NoError(t, err)
  243. session, err := svc.CreatePendingSession(ctx, CreatePendingAuthSessionInput{
  244. Intent: "bind_current_user",
  245. Identity: PendingAuthIdentityKey{
  246. ProviderType: "wechat",
  247. ProviderKey: "wechat-main",
  248. ProviderSubject: "union-concurrent",
  249. },
  250. })
  251. require.NoError(t, err)
  252. firstCreateStarted := make(chan struct{})
  253. releaseFirstCreate := make(chan struct{})
  254. var firstCreate sync.Once
  255. client.IdentityAdoptionDecision.Use(func(next dbent.Mutator) dbent.Mutator {
  256. return dbent.MutateFunc(func(ctx context.Context, m dbent.Mutation) (dbent.Value, error) {
  257. blocked := false
  258. if m.Op().Is(dbent.OpCreate) {
  259. firstCreate.Do(func() {
  260. blocked = true
  261. close(firstCreateStarted)
  262. })
  263. }
  264. if blocked {
  265. <-releaseFirstCreate
  266. }
  267. return next.Mutate(ctx, m)
  268. })
  269. })
  270. type adoptionResult struct {
  271. decision *dbent.IdentityAdoptionDecision
  272. err error
  273. }
  274. input := PendingIdentityAdoptionDecisionInput{
  275. PendingAuthSessionID: session.ID,
  276. IdentityID: &identity.ID,
  277. AdoptDisplayName: true,
  278. AdoptAvatar: true,
  279. }
  280. results := make(chan adoptionResult, 2)
  281. go func() {
  282. decision, err := svc.UpsertAdoptionDecision(ctx, input)
  283. results <- adoptionResult{decision: decision, err: err}
  284. }()
  285. <-firstCreateStarted
  286. go func() {
  287. decision, err := svc.UpsertAdoptionDecision(ctx, input)
  288. results <- adoptionResult{decision: decision, err: err}
  289. }()
  290. time.Sleep(100 * time.Millisecond)
  291. close(releaseFirstCreate)
  292. first := <-results
  293. second := <-results
  294. require.NoError(t, first.err)
  295. require.NoError(t, second.err)
  296. require.NotNil(t, first.decision)
  297. require.NotNil(t, second.decision)
  298. require.Equal(t, first.decision.ID, second.decision.ID)
  299. count, err := client.IdentityAdoptionDecision.Query().
  300. Where(identityadoptiondecision.PendingAuthSessionIDEQ(session.ID)).
  301. Count(ctx)
  302. require.NoError(t, err)
  303. require.Equal(t, 1, count)
  304. loaded, err := client.IdentityAdoptionDecision.Query().
  305. Where(identityadoptiondecision.PendingAuthSessionIDEQ(session.ID)).
  306. Only(ctx)
  307. require.NoError(t, err)
  308. require.NotNil(t, loaded.IdentityID)
  309. require.Equal(t, identity.ID, *loaded.IdentityID)
  310. }
  311. func TestAuthPendingIdentityService_UpsertAdoptionDecision_ClearsLegacyNullSessionReference(t *testing.T) {
  312. t.Skip("legacy NULL pending_auth_session_id rows only exist in production PostgreSQL history; sqlite unit schema rejects NULL")
  313. svc, client := newAuthPendingIdentityServiceTestClient(t)
  314. ctx := context.Background()
  315. user, err := client.User.Create().
  316. SetEmail("legacy-null-session@example.com").
  317. SetPasswordHash("hash").
  318. SetRole(RoleUser).
  319. SetStatus(StatusActive).
  320. Save(ctx)
  321. require.NoError(t, err)
  322. identity, err := client.AuthIdentity.Create().
  323. SetUserID(user.ID).
  324. SetProviderType("wechat").
  325. SetProviderKey("wechat-main").
  326. SetProviderSubject("legacy-null-session").
  327. SetMetadata(map[string]any{}).
  328. Save(ctx)
  329. require.NoError(t, err)
  330. _, err = client.ExecContext(
  331. ctx,
  332. `INSERT INTO identity_adoption_decisions
  333. (identity_id, adopt_display_name, adopt_avatar, decided_at, created_at, updated_at, pending_auth_session_id)
  334. VALUES (?, ?, ?, ?, ?, ?, NULL)`,
  335. identity.ID,
  336. true,
  337. false,
  338. time.Now().UTC(),
  339. time.Now().UTC(),
  340. time.Now().UTC(),
  341. )
  342. require.NoError(t, err)
  343. legacyDecision, err := client.IdentityAdoptionDecision.Query().
  344. Where(identityadoptiondecision.IdentityIDEQ(identity.ID)).
  345. Only(ctx)
  346. require.NoError(t, err)
  347. require.NotNil(t, legacyDecision.IdentityID)
  348. session, err := svc.CreatePendingSession(ctx, CreatePendingAuthSessionInput{
  349. Intent: "bind_current_user",
  350. Identity: PendingAuthIdentityKey{
  351. ProviderType: "wechat",
  352. ProviderKey: "wechat-main",
  353. ProviderSubject: "legacy-null-session",
  354. },
  355. })
  356. require.NoError(t, err)
  357. decision, err := svc.UpsertAdoptionDecision(ctx, PendingIdentityAdoptionDecisionInput{
  358. PendingAuthSessionID: session.ID,
  359. IdentityID: &identity.ID,
  360. AdoptDisplayName: false,
  361. AdoptAvatar: true,
  362. })
  363. require.NoError(t, err)
  364. require.NotNil(t, decision.IdentityID)
  365. require.Equal(t, identity.ID, *decision.IdentityID)
  366. reloadedLegacy, err := client.IdentityAdoptionDecision.Get(ctx, legacyDecision.ID)
  367. require.NoError(t, err)
  368. require.Nil(t, reloadedLegacy.IdentityID)
  369. }
  370. func TestAuthPendingIdentityService_ConsumeBrowserSession(t *testing.T) {
  371. svc, _ := newAuthPendingIdentityServiceTestClient(t)
  372. ctx := context.Background()
  373. session, err := svc.CreatePendingSession(ctx, CreatePendingAuthSessionInput{
  374. Intent: "login",
  375. Identity: PendingAuthIdentityKey{
  376. ProviderType: "linuxdo",
  377. ProviderKey: "linuxdo",
  378. ProviderSubject: "subject-session-token",
  379. },
  380. BrowserSessionKey: "browser-session",
  381. LocalFlowState: map[string]any{
  382. "completion_response": map[string]any{
  383. "access_token": "token",
  384. },
  385. },
  386. })
  387. require.NoError(t, err)
  388. _, err = svc.ConsumeBrowserSession(ctx, session.SessionToken, "browser-other")
  389. require.ErrorIs(t, err, ErrPendingAuthBrowserMismatch)
  390. consumed, err := svc.ConsumeBrowserSession(ctx, session.SessionToken, "browser-session")
  391. require.NoError(t, err)
  392. require.NotNil(t, consumed.ConsumedAt)
  393. _, err = svc.ConsumeBrowserSession(ctx, session.SessionToken, "browser-session")
  394. require.ErrorIs(t, err, ErrPendingAuthSessionConsumed)
  395. }
  396. func TestAuthPendingIdentityService_ConsumeBrowserSessionRejectsStaleLoadedSessionReplay(t *testing.T) {
  397. svc, _ := newAuthPendingIdentityServiceTestClient(t)
  398. ctx := context.Background()
  399. session, err := svc.CreatePendingSession(ctx, CreatePendingAuthSessionInput{
  400. Intent: "login",
  401. Identity: PendingAuthIdentityKey{
  402. ProviderType: "linuxdo",
  403. ProviderKey: "linuxdo",
  404. ProviderSubject: "stale-replay-subject",
  405. },
  406. BrowserSessionKey: "browser-session",
  407. })
  408. require.NoError(t, err)
  409. loaded, err := svc.getBrowserSession(ctx, session.SessionToken)
  410. require.NoError(t, err)
  411. consumed, err := svc.consumeSession(ctx, loaded, "browser-session", ErrPendingAuthSessionExpired, ErrPendingAuthSessionConsumed)
  412. require.NoError(t, err)
  413. require.NotNil(t, consumed.ConsumedAt)
  414. _, err = svc.consumeSession(ctx, loaded, "browser-session", ErrPendingAuthSessionExpired, ErrPendingAuthSessionConsumed)
  415. require.ErrorIs(t, err, ErrPendingAuthSessionConsumed)
  416. }
  417. func TestAuthPendingIdentityService_ConsumeBrowserSessionScrubsLegacyCompletionTokens(t *testing.T) {
  418. svc, client := newAuthPendingIdentityServiceTestClient(t)
  419. ctx := context.Background()
  420. session, err := svc.CreatePendingSession(ctx, CreatePendingAuthSessionInput{
  421. Intent: "login",
  422. Identity: PendingAuthIdentityKey{
  423. ProviderType: "linuxdo",
  424. ProviderKey: "linuxdo",
  425. ProviderSubject: "legacy-token-subject",
  426. },
  427. BrowserSessionKey: "browser-session",
  428. LocalFlowState: map[string]any{
  429. "completion_response": map[string]any{
  430. "access_token": "legacy-access-token",
  431. "refresh_token": "legacy-refresh-token",
  432. "expires_in": float64(3600),
  433. "token_type": "Bearer",
  434. "redirect": "/dashboard",
  435. },
  436. },
  437. })
  438. require.NoError(t, err)
  439. consumed, err := svc.ConsumeBrowserSession(ctx, session.SessionToken, "browser-session")
  440. require.NoError(t, err)
  441. require.NotNil(t, consumed.ConsumedAt)
  442. stored, err := client.PendingAuthSession.Get(ctx, session.ID)
  443. require.NoError(t, err)
  444. completion, ok := stored.LocalFlowState["completion_response"].(map[string]any)
  445. require.True(t, ok)
  446. require.NotContains(t, completion, "access_token")
  447. require.NotContains(t, completion, "refresh_token")
  448. require.NotContains(t, completion, "expires_in")
  449. require.NotContains(t, completion, "token_type")
  450. require.Equal(t, "/dashboard", completion["redirect"])
  451. }