auth_email_oauth_test.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  1. package handler
  2. import (
  3. "context"
  4. "net/http"
  5. "net/http/httptest"
  6. "net/url"
  7. "strings"
  8. "testing"
  9. "time"
  10. dbent "github.com/Wei-Shaw/sub2api/ent"
  11. "github.com/Wei-Shaw/sub2api/ent/authidentity"
  12. "github.com/Wei-Shaw/sub2api/ent/redeemcode"
  13. dbuser "github.com/Wei-Shaw/sub2api/ent/user"
  14. "github.com/Wei-Shaw/sub2api/internal/config"
  15. "github.com/Wei-Shaw/sub2api/internal/service"
  16. "github.com/gin-gonic/gin"
  17. "github.com/stretchr/testify/require"
  18. )
  19. func TestEmailOAuthCallbackRequiresPendingRegistrationWhenInvitationEnabled(t *testing.T) {
  20. handler, client := newOAuthPendingFlowTestHandler(t, true)
  21. ctx := context.Background()
  22. state := "github-oauth-state"
  23. recorder := httptest.NewRecorder()
  24. c, _ := gin.CreateTestContext(recorder)
  25. req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/github/callback?code=code-1&state="+url.QueryEscape(state), nil)
  26. req.AddCookie(&http.Cookie{Name: emailOAuthStateCookieName, Value: encodeCookieValue(state)})
  27. req.AddCookie(&http.Cookie{Name: emailOAuthRedirectCookie, Value: encodeCookieValue("/dashboard")})
  28. req.AddCookie(&http.Cookie{Name: emailOAuthProviderCookie, Value: encodeCookieValue("github")})
  29. c.Request = req
  30. profile := &emailOAuthProfile{
  31. Subject: "github-123",
  32. Email: "fresh@example.com",
  33. EmailVerified: true,
  34. Username: "fresh",
  35. DisplayName: "Fresh User",
  36. AvatarURL: "https://cdn.example/fresh.png",
  37. Metadata: map[string]any{
  38. "login": "fresh",
  39. },
  40. }
  41. handler.emailOAuthCallbackWithProfile(c, "github", config.EmailOAuthProviderConfig{
  42. Enabled: true,
  43. ClientID: "github-client",
  44. ClientSecret: "github-secret",
  45. RedirectURL: "https://app.example/api/v1/auth/oauth/github/callback",
  46. FrontendRedirectURL: "/auth/oauth/callback",
  47. }, "/auth/oauth/callback", "/dashboard", profile)
  48. require.Equal(t, http.StatusFound, recorder.Code)
  49. location := recorder.Header().Get("Location")
  50. require.Contains(t, location, "/auth/oauth/callback")
  51. require.NotContains(t, location, "access_token=")
  52. userCount, err := client.User.Query().Where(dbuser.EmailEQ("fresh@example.com")).Count(ctx)
  53. require.NoError(t, err)
  54. require.Zero(t, userCount)
  55. session, err := client.PendingAuthSession.Query().Only(ctx)
  56. require.NoError(t, err)
  57. require.Equal(t, "github", session.ProviderType)
  58. require.Equal(t, "github", session.ProviderKey)
  59. require.Equal(t, "github-123", session.ProviderSubject)
  60. require.Equal(t, "fresh@example.com", session.ResolvedEmail)
  61. require.Equal(t, "/dashboard", session.RedirectTo)
  62. require.Nil(t, session.TargetUserID)
  63. completion, ok := readCompletionResponse(session.LocalFlowState)
  64. require.True(t, ok)
  65. require.Equal(t, oauthPendingChoiceStep, completion["step"])
  66. require.Equal(t, "invitation_required", completion["error"])
  67. require.Equal(t, true, completion["invitation_required"])
  68. require.Equal(t, "fresh@example.com", completion["email"])
  69. require.Equal(t, "fresh@example.com", completion["resolved_email"])
  70. require.Equal(t, true, completion["create_account_allowed"])
  71. require.NotEmpty(t, findSetCookieValue(recorder.Result().Cookies(), oauthPendingSessionCookieName))
  72. require.NotEmpty(t, findSetCookieValue(recorder.Result().Cookies(), oauthPendingBrowserCookieName))
  73. }
  74. func TestEmailOAuthCallbackExistingEmailLogsInWhenInvitationEnabled(t *testing.T) {
  75. handler, client := newOAuthPendingFlowTestHandler(t, true)
  76. ctx := context.Background()
  77. user, err := client.User.Create().
  78. SetEmail("existing@example.com").
  79. SetUsername("existing").
  80. SetPasswordHash("hash").
  81. SetRole(service.RoleUser).
  82. SetStatus(service.StatusActive).
  83. Save(ctx)
  84. require.NoError(t, err)
  85. recorder := httptest.NewRecorder()
  86. c, _ := gin.CreateTestContext(recorder)
  87. c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/google/callback", nil)
  88. handler.emailOAuthCallbackWithProfile(c, "google", config.EmailOAuthProviderConfig{
  89. Enabled: true,
  90. ClientID: "google-client",
  91. ClientSecret: "google-secret",
  92. RedirectURL: "https://app.example/api/v1/auth/oauth/google/callback",
  93. FrontendRedirectURL: "/auth/oauth/callback",
  94. }, "/auth/oauth/callback", "/dashboard", &emailOAuthProfile{
  95. Subject: "google-123",
  96. Email: "existing@example.com",
  97. EmailVerified: true,
  98. Username: "existing",
  99. })
  100. require.Equal(t, http.StatusFound, recorder.Code)
  101. location := recorder.Header().Get("Location")
  102. require.Contains(t, location, "access_token=")
  103. require.Contains(t, location, "redirect=%252Fdashboard")
  104. sessionCount, err := client.PendingAuthSession.Query().Count(ctx)
  105. require.NoError(t, err)
  106. require.Zero(t, sessionCount)
  107. identityCount, err := client.AuthIdentity.Query().Where(
  108. authidentity.ProviderTypeEQ("google"),
  109. authidentity.ProviderSubjectEQ("google-123"),
  110. ).Count(ctx)
  111. require.NoError(t, err)
  112. require.Equal(t, 1, identityCount)
  113. _ = user
  114. }
  115. func TestEmailOAuthCallbackCreatesPasswordRegistrationSessionForNewEmail(t *testing.T) {
  116. affiliateRepo := newOAuthEmailAffiliateRepoStub(map[string]int64{"AFF123": 1001})
  117. handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
  118. settingValues: map[string]string{
  119. service.SettingKeyAffiliateEnabled: "true",
  120. },
  121. affiliateFactory: func(_ *dbent.Client, settingSvc *service.SettingService) *service.AffiliateService {
  122. return service.NewAffiliateService(affiliateRepo, settingSvc, nil, nil)
  123. },
  124. })
  125. ctx := context.Background()
  126. recorder := httptest.NewRecorder()
  127. c, _ := gin.CreateTestContext(recorder)
  128. req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/github/callback", nil)
  129. req.AddCookie(&http.Cookie{Name: emailOAuthAffiliateCookie, Value: encodeCookieValue("AFF123")})
  130. c.Request = req
  131. handler.emailOAuthCallbackWithProfile(c, "github", config.EmailOAuthProviderConfig{
  132. Enabled: true,
  133. ClientID: "github-client",
  134. ClientSecret: "github-secret",
  135. RedirectURL: "https://app.example/api/v1/auth/oauth/github/callback",
  136. FrontendRedirectURL: "/auth/oauth/callback",
  137. }, "/auth/oauth/callback", "/dashboard", &emailOAuthProfile{
  138. Subject: "github-aff-user",
  139. Email: "aff-user@example.com",
  140. EmailVerified: true,
  141. Username: "aff-user",
  142. })
  143. require.Equal(t, http.StatusFound, recorder.Code)
  144. require.NotContains(t, recorder.Header().Get("Location"), "access_token=")
  145. userCount, err := client.User.Query().Where(dbuser.EmailEQ("aff-user@example.com")).Count(ctx)
  146. require.NoError(t, err)
  147. require.Zero(t, userCount)
  148. require.Empty(t, affiliateRepo.ensureUserIDs)
  149. require.Empty(t, affiliateRepo.bindCalls)
  150. session, err := client.PendingAuthSession.Query().Only(ctx)
  151. require.NoError(t, err)
  152. require.Equal(t, "aff-user@example.com", session.ResolvedEmail)
  153. require.Equal(t, "AFF123", pendingSessionStringValue(session.UpstreamIdentityClaims, "aff_code"))
  154. completion, ok := readCompletionResponse(session.LocalFlowState)
  155. require.True(t, ok)
  156. require.Equal(t, oauthPendingChoiceStep, completion["step"])
  157. require.Equal(t, "registration_completion_required", completion["error"])
  158. require.Equal(t, false, completion["invitation_required"])
  159. require.Equal(t, true, completion["create_account_allowed"])
  160. require.Equal(t, true, completion["force_email_on_signup"])
  161. require.Equal(t, "aff-user@example.com", completion["resolved_email"])
  162. }
  163. func TestEmailOAuthStartPreservesPromoCodeInPendingSession(t *testing.T) {
  164. handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
  165. settingValues: map[string]string{
  166. service.SettingKeyGitHubOAuthEnabled: "true",
  167. service.SettingKeyGitHubOAuthClientID: "github-client",
  168. service.SettingKeyGitHubOAuthClientSecret: "github-secret",
  169. service.SettingKeyGitHubOAuthRedirectURL: "https://app.example/api/v1/auth/oauth/github/callback",
  170. },
  171. })
  172. ctx := context.Background()
  173. startRecorder := httptest.NewRecorder()
  174. startCtx, _ := gin.CreateTestContext(startRecorder)
  175. startCtx.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/github/start?promo_code=WELCOME2024", nil)
  176. handler.GitHubOAuthStart(startCtx)
  177. require.Equal(t, http.StatusFound, startRecorder.Code)
  178. promoCookie := findCookie(startRecorder.Result().Cookies(), oauthPromoCodeCookieName)
  179. require.NotNil(t, promoCookie)
  180. require.Equal(t, "WELCOME2024", decodeCookieValueForTest(t, promoCookie.Value))
  181. callbackRecorder := httptest.NewRecorder()
  182. callbackCtx, _ := gin.CreateTestContext(callbackRecorder)
  183. callbackReq := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/github/callback", nil)
  184. callbackReq.AddCookie(promoCookie)
  185. callbackCtx.Request = callbackReq
  186. handler.emailOAuthCallbackWithProfile(callbackCtx, "github", config.EmailOAuthProviderConfig{
  187. FrontendRedirectURL: "/auth/oauth/callback",
  188. }, "/auth/oauth/callback", "/dashboard", &emailOAuthProfile{
  189. Subject: "github-promo-user",
  190. Email: "promo-user@example.com",
  191. EmailVerified: true,
  192. Username: "promo-user",
  193. })
  194. require.Equal(t, http.StatusFound, callbackRecorder.Code)
  195. session, err := client.PendingAuthSession.Query().Only(ctx)
  196. require.NoError(t, err)
  197. require.Equal(t, "WELCOME2024", pendingOAuthPromoCode(session))
  198. }
  199. func TestCompleteEmailOAuthRegistrationUsesAffiliateCodeFromPendingSession(t *testing.T) {
  200. affiliateRepo := newOAuthEmailAffiliateRepoStub(map[string]int64{"AFF456": 2002})
  201. handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
  202. invitationEnabled: true,
  203. settingValues: map[string]string{
  204. service.SettingKeyAffiliateEnabled: "true",
  205. },
  206. affiliateFactory: func(_ *dbent.Client, settingSvc *service.SettingService) *service.AffiliateService {
  207. return service.NewAffiliateService(affiliateRepo, settingSvc, nil, nil)
  208. },
  209. })
  210. ctx := context.Background()
  211. invitation, err := client.RedeemCode.Create().
  212. SetCode("INVITE456").
  213. SetType(service.RedeemTypeInvitation).
  214. SetStatus(service.StatusUnused).
  215. SetValue(0).
  216. Save(ctx)
  217. require.NoError(t, err)
  218. session, err := client.PendingAuthSession.Create().
  219. SetSessionToken("email-oauth-aff-session-token").
  220. SetIntent(oauthIntentLogin).
  221. SetProviderType("google").
  222. SetProviderKey("google").
  223. SetProviderSubject("google-aff-user").
  224. SetResolvedEmail("pending-aff@example.com").
  225. SetRedirectTo("/dashboard").
  226. SetBrowserSessionKey("browser-aff-key").
  227. SetUpstreamIdentityClaims(map[string]any{
  228. "email": "pending-aff@example.com",
  229. "email_verified": true,
  230. "username": "pending-aff",
  231. "provider": "google",
  232. "provider_key": "google",
  233. "provider_subject": "google-aff-user",
  234. "aff_code": "AFF456",
  235. }).
  236. SetLocalFlowState(map[string]any{
  237. "step": oauthPendingChoiceStep,
  238. "error": "invitation_required",
  239. }).
  240. SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
  241. Save(ctx)
  242. require.NoError(t, err)
  243. recorder := httptest.NewRecorder()
  244. c, _ := gin.CreateTestContext(recorder)
  245. req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/google/complete-registration", strings.NewReader(`{"password":"secret-123","invitation_code":"INVITE456","email":"tampered@example.com"}`))
  246. req.Header.Set("Content-Type", "application/json")
  247. req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
  248. req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("browser-aff-key")})
  249. c.Request = req
  250. handler.completeEmailOAuthRegistration(c, "google")
  251. require.Equal(t, http.StatusOK, recorder.Code)
  252. user, err := client.User.Query().Where(dbuser.EmailEQ("pending-aff@example.com")).Only(ctx)
  253. require.NoError(t, err)
  254. require.NotEmpty(t, user.PasswordHash)
  255. require.NotEqual(t, "secret-123", user.PasswordHash)
  256. tamperedCount, err := client.User.Query().Where(dbuser.EmailEQ("tampered@example.com")).Count(ctx)
  257. require.NoError(t, err)
  258. require.Zero(t, tamperedCount)
  259. require.Equal(t, []oauthEmailAffiliateBindCall{{userID: user.ID, inviterID: 2002}}, affiliateRepo.bindCalls)
  260. storedInvitation, err := client.RedeemCode.Query().Where(redeemcode.IDEQ(invitation.ID)).Only(ctx)
  261. require.NoError(t, err)
  262. require.NotNil(t, storedInvitation.UsedBy)
  263. require.Equal(t, user.ID, *storedInvitation.UsedBy)
  264. }
  265. func TestCompleteEmailOAuthRegistrationRequiresPassword(t *testing.T) {
  266. handler, client := newOAuthPendingFlowTestHandler(t, false)
  267. ctx := context.Background()
  268. session, err := client.PendingAuthSession.Create().
  269. SetSessionToken("email-oauth-password-session-token").
  270. SetIntent(oauthIntentLogin).
  271. SetProviderType("github").
  272. SetProviderKey("github").
  273. SetProviderSubject("github-password-user").
  274. SetResolvedEmail("password-required@example.com").
  275. SetRedirectTo("/dashboard").
  276. SetBrowserSessionKey("browser-password-key").
  277. SetUpstreamIdentityClaims(map[string]any{
  278. "email": "password-required@example.com",
  279. "email_verified": true,
  280. "username": "password-required",
  281. "provider": "github",
  282. "provider_key": "github",
  283. "provider_subject": "github-password-user",
  284. }).
  285. SetLocalFlowState(map[string]any{
  286. "step": oauthPendingChoiceStep,
  287. "error": "registration_completion_required",
  288. }).
  289. SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
  290. Save(ctx)
  291. require.NoError(t, err)
  292. recorder := httptest.NewRecorder()
  293. c, _ := gin.CreateTestContext(recorder)
  294. req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/github/complete-registration", strings.NewReader(`{}`))
  295. req.Header.Set("Content-Type", "application/json")
  296. req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
  297. req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("browser-password-key")})
  298. c.Request = req
  299. handler.completeEmailOAuthRegistration(c, "github")
  300. require.Equal(t, http.StatusBadRequest, recorder.Code)
  301. userCount, err := client.User.Query().Where(dbuser.EmailEQ("password-required@example.com")).Count(ctx)
  302. require.NoError(t, err)
  303. require.Zero(t, userCount)
  304. }
  305. func TestParseGitHubOAuthProfileRejectsPublicEmailWhenEmailsEndpointFails(t *testing.T) {
  306. emailServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  307. http.Error(w, "missing scope", http.StatusForbidden)
  308. }))
  309. t.Cleanup(emailServer.Close)
  310. profile, err := parseGitHubOAuthProfile(context.Background(), config.EmailOAuthProviderConfig{
  311. EmailsURL: emailServer.URL,
  312. }, &emailOAuthTokenResponse{AccessToken: "token"}, `{"id":123,"login":"octo","email":"public@example.com"}`)
  313. require.Error(t, err)
  314. require.Nil(t, profile)
  315. require.Contains(t, err.Error(), "github emails endpoint status 403")
  316. }
  317. type oauthEmailAffiliateBindCall struct {
  318. userID int64
  319. inviterID int64
  320. }
  321. type oauthEmailAffiliateRepoStub struct {
  322. codeOwners map[string]int64
  323. ensureUserIDs []int64
  324. bindCalls []oauthEmailAffiliateBindCall
  325. }
  326. func newOAuthEmailAffiliateRepoStub(codeOwners map[string]int64) *oauthEmailAffiliateRepoStub {
  327. return &oauthEmailAffiliateRepoStub{codeOwners: codeOwners}
  328. }
  329. func (r *oauthEmailAffiliateRepoStub) EnsureUserAffiliate(_ context.Context, userID int64) (*service.AffiliateSummary, error) {
  330. r.ensureUserIDs = append(r.ensureUserIDs, userID)
  331. return &service.AffiliateSummary{UserID: userID, AffCode: "SELF"}, nil
  332. }
  333. func (r *oauthEmailAffiliateRepoStub) GetAffiliateByCode(_ context.Context, code string) (*service.AffiliateSummary, error) {
  334. userID, ok := r.codeOwners[strings.ToUpper(strings.TrimSpace(code))]
  335. if !ok {
  336. return nil, service.ErrAffiliateProfileNotFound
  337. }
  338. return &service.AffiliateSummary{UserID: userID, AffCode: strings.ToUpper(strings.TrimSpace(code))}, nil
  339. }
  340. func (r *oauthEmailAffiliateRepoStub) BindInviter(_ context.Context, userID, inviterID int64) (bool, error) {
  341. r.bindCalls = append(r.bindCalls, oauthEmailAffiliateBindCall{userID: userID, inviterID: inviterID})
  342. return true, nil
  343. }
  344. func (r *oauthEmailAffiliateRepoStub) AccrueQuota(context.Context, int64, int64, float64, int, *int64) (bool, error) {
  345. panic("unexpected AccrueQuota call")
  346. }
  347. func (r *oauthEmailAffiliateRepoStub) GetAccruedRebateFromInvitee(context.Context, int64, int64) (float64, error) {
  348. panic("unexpected GetAccruedRebateFromInvitee call")
  349. }
  350. func (r *oauthEmailAffiliateRepoStub) ThawFrozenQuota(context.Context, int64) (float64, error) {
  351. panic("unexpected ThawFrozenQuota call")
  352. }
  353. func (r *oauthEmailAffiliateRepoStub) TransferQuotaToBalance(context.Context, int64) (float64, float64, error) {
  354. panic("unexpected TransferQuotaToBalance call")
  355. }
  356. func (r *oauthEmailAffiliateRepoStub) ListInvitees(context.Context, int64, int) ([]service.AffiliateInvitee, error) {
  357. panic("unexpected ListInvitees call")
  358. }
  359. func (r *oauthEmailAffiliateRepoStub) UpdateUserAffCode(context.Context, int64, string) error {
  360. panic("unexpected UpdateUserAffCode call")
  361. }
  362. func (r *oauthEmailAffiliateRepoStub) ResetUserAffCode(context.Context, int64) (string, error) {
  363. panic("unexpected ResetUserAffCode call")
  364. }
  365. func (r *oauthEmailAffiliateRepoStub) SetUserRebateRate(context.Context, int64, *float64) error {
  366. panic("unexpected SetUserRebateRate call")
  367. }
  368. func (r *oauthEmailAffiliateRepoStub) BatchSetUserRebateRate(context.Context, []int64, *float64) error {
  369. panic("unexpected BatchSetUserRebateRate call")
  370. }
  371. func (r *oauthEmailAffiliateRepoStub) ListUsersWithCustomSettings(context.Context, service.AffiliateAdminFilter) ([]service.AffiliateAdminEntry, int64, error) {
  372. panic("unexpected ListUsersWithCustomSettings call")
  373. }
  374. func (r *oauthEmailAffiliateRepoStub) ListAffiliateInviteRecords(context.Context, service.AffiliateRecordFilter) ([]service.AffiliateInviteRecord, int64, error) {
  375. panic("unexpected ListAffiliateInviteRecords call")
  376. }
  377. func (r *oauthEmailAffiliateRepoStub) ListAffiliateRebateRecords(context.Context, service.AffiliateRecordFilter) ([]service.AffiliateRebateRecord, int64, error) {
  378. panic("unexpected ListAffiliateRebateRecords call")
  379. }
  380. func (r *oauthEmailAffiliateRepoStub) ListAffiliateTransferRecords(context.Context, service.AffiliateRecordFilter) ([]service.AffiliateTransferRecord, int64, error) {
  381. panic("unexpected ListAffiliateTransferRecords call")
  382. }
  383. func (r *oauthEmailAffiliateRepoStub) GetAffiliateUserOverview(context.Context, int64) (*service.AffiliateUserOverview, error) {
  384. panic("unexpected GetAffiliateUserOverview call")
  385. }
  386. func findSetCookieValue(cookies []*http.Cookie, name string) string {
  387. for _, cookie := range cookies {
  388. if cookie != nil && strings.EqualFold(cookie.Name, name) && cookie.MaxAge >= 0 {
  389. return cookie.Value
  390. }
  391. }
  392. return ""
  393. }