grok_oauth_handler_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. //go:build unit
  2. package admin
  3. import (
  4. "context"
  5. "errors"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "strings"
  10. "sync"
  11. "testing"
  12. "time"
  13. "github.com/Wei-Shaw/sub2api/internal/config"
  14. "github.com/gin-gonic/gin"
  15. "github.com/stretchr/testify/require"
  16. "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
  17. "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
  18. "github.com/Wei-Shaw/sub2api/internal/service"
  19. )
  20. type grokQuotaHandlerAccountRepo struct {
  21. service.AccountRepository
  22. account *service.Account
  23. updates map[int64]map[string]any
  24. }
  25. func (r *grokQuotaHandlerAccountRepo) GetByID(_ context.Context, id int64) (*service.Account, error) {
  26. if r.account != nil && r.account.ID == id {
  27. return r.account, nil
  28. }
  29. return nil, service.ErrAccountNotFound
  30. }
  31. func (r *grokQuotaHandlerAccountRepo) UpdateExtra(_ context.Context, id int64, updates map[string]any) error {
  32. if r.updates == nil {
  33. r.updates = make(map[int64]map[string]any)
  34. }
  35. r.updates[id] = updates
  36. return nil
  37. }
  38. type grokQuotaHandlerUpstream struct {
  39. mu sync.Mutex
  40. requests []*http.Request
  41. bodies [][]byte
  42. }
  43. type grokOAuthReconcilerStub struct {
  44. input service.GrokOAuthReconcileInput
  45. calls int
  46. result *service.GrokOAuthReconcileResult
  47. err error
  48. }
  49. func (s *grokOAuthReconcilerStub) ReconcileGrokOAuth(_ context.Context, input service.GrokOAuthReconcileInput) (*service.GrokOAuthReconcileResult, error) {
  50. s.calls++
  51. s.input = input
  52. return s.result, s.err
  53. }
  54. func (u *grokQuotaHandlerUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  55. var body []byte
  56. if req.Body != nil {
  57. body, _ = io.ReadAll(req.Body)
  58. }
  59. u.mu.Lock()
  60. u.requests = append(u.requests, req)
  61. u.bodies = append(u.bodies, body)
  62. u.mu.Unlock()
  63. if req.URL.Path == "/v1/responses" {
  64. return &http.Response{
  65. StatusCode: http.StatusOK,
  66. Header: http.Header{
  67. "X-Ratelimit-Limit-Requests": []string{"10"},
  68. "X-Ratelimit-Remaining-Requests": []string{"8"},
  69. },
  70. Body: io.NopCloser(strings.NewReader(`{"id":"resp_probe"}`)),
  71. }, nil
  72. }
  73. payload := `{"config":{"billingPeriodStart":"2026-07-01T00:00:00Z","billingPeriodEnd":"2026-08-01T00:00:00Z"}}`
  74. if req.URL.RawQuery == "format=credits" {
  75. payload = `{"config":{"currentPeriod":{"type":"WEEKLY","start":"2026-07-09T03:25:00Z","end":"2026-07-16T03:25:00Z"}}}`
  76. }
  77. return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(payload))}, nil
  78. }
  79. func (u *grokQuotaHandlerUpstream) DoWithTLS(
  80. req *http.Request,
  81. proxyURL string,
  82. accountID int64,
  83. accountConcurrency int,
  84. _ *tlsfingerprint.Profile,
  85. ) (*http.Response, error) {
  86. return u.Do(req, proxyURL, accountID, accountConcurrency)
  87. }
  88. func TestGrokOAuthHandlerQueryQuotaProbesUpstream(t *testing.T) {
  89. gin.SetMode(gin.TestMode)
  90. repo := &grokQuotaHandlerAccountRepo{account: &service.Account{
  91. ID: 42,
  92. Platform: service.PlatformGrok,
  93. Type: service.AccountTypeOAuth,
  94. Status: service.StatusActive,
  95. Schedulable: true,
  96. Concurrency: 1,
  97. Credentials: map[string]any{
  98. "access_token": "access-token",
  99. "refresh_token": "refresh-token",
  100. "expires_at": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339),
  101. },
  102. }}
  103. upstream := &grokQuotaHandlerUpstream{}
  104. quotaService := service.NewGrokQuotaService(repo, nil, service.NewGrokTokenProvider(repo, nil), upstream, nil)
  105. handler := NewGrokOAuthHandler(nil, nil, quotaService, nil)
  106. router := gin.New()
  107. router.GET("/api/v1/admin/grok/accounts/:id/quota", handler.QueryQuota)
  108. rec := httptest.NewRecorder()
  109. req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/grok/accounts/42/quota", nil)
  110. router.ServeHTTP(rec, req)
  111. require.Equal(t, http.StatusOK, rec.Code)
  112. require.Contains(t, rec.Body.String(), `"source":"hybrid_probe"`)
  113. require.Contains(t, rec.Body.String(), `"billing":`)
  114. require.Contains(t, rec.Body.String(), `"snapshot":`)
  115. require.Contains(t, rec.Body.String(), `"headers_observed":true`)
  116. require.NotContains(t, rec.Body.String(), "access-token")
  117. require.Eventually(t, func() bool {
  118. upstream.mu.Lock()
  119. defer upstream.mu.Unlock()
  120. return len(upstream.requests) == 4
  121. }, time.Second, 10*time.Millisecond)
  122. upstream.mu.Lock()
  123. requests := append([]*http.Request(nil), upstream.requests...)
  124. bodies := append([][]byte(nil), upstream.bodies...)
  125. upstream.mu.Unlock()
  126. require.Len(t, requests, 4)
  127. responsesProbeSeen := false
  128. modelsSyncSeen := false
  129. for i, upstreamReq := range requests {
  130. require.Equal(t, "Bearer access-token", upstreamReq.Header.Get("Authorization"))
  131. if upstreamReq.URL.String() == xai.DefaultCLIBaseURL+"/responses" {
  132. responsesProbeSeen = true
  133. require.Equal(t, "application/json, text/event-stream", upstreamReq.Header.Get("Accept"))
  134. require.Contains(t, string(bodies[i]), `"model":"grok-4.5"`)
  135. require.Contains(t, string(bodies[i]), `"input":"hi"`)
  136. require.Contains(t, string(bodies[i]), `"stream":true`)
  137. require.NotContains(t, string(bodies[i]), `"max_output_tokens"`)
  138. require.NotContains(t, string(bodies[i]), `"store"`)
  139. }
  140. if upstreamReq.URL.String() == xai.DefaultCLIBaseURL+"/models" {
  141. modelsSyncSeen = true
  142. }
  143. }
  144. require.True(t, responsesProbeSeen)
  145. require.True(t, modelsSyncSeen)
  146. require.NotNil(t, repo.updates[42])
  147. }
  148. func TestGrokOAuthHandlerResetQuotaReturnsUnsupported(t *testing.T) {
  149. gin.SetMode(gin.TestMode)
  150. repo := &grokQuotaHandlerAccountRepo{account: &service.Account{
  151. ID: 43,
  152. Platform: service.PlatformGrok,
  153. Type: service.AccountTypeOAuth,
  154. }}
  155. quotaService := service.NewGrokQuotaService(repo, nil, nil, nil, nil)
  156. handler := NewGrokOAuthHandler(nil, nil, quotaService, nil)
  157. router := gin.New()
  158. router.POST("/api/v1/admin/grok/accounts/:id/reset-quota", handler.ResetQuota)
  159. rec := httptest.NewRecorder()
  160. req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/grok/accounts/43/reset-quota", nil)
  161. router.ServeHTTP(rec, req)
  162. require.Equal(t, http.StatusNotImplemented, rec.Code)
  163. require.Contains(t, rec.Body.String(), `"reason":"GROK_QUOTA_RESET_UNSUPPORTED"`)
  164. require.NotContains(t, rec.Body.String(), "access-token")
  165. }
  166. func TestGrokOAuthHandlerRuntimeSanityDoesNotExposeSecrets(t *testing.T) {
  167. gin.SetMode(gin.TestMode)
  168. t.Setenv(xai.EnvBaseURL, "http://127.0.0.1:8080/v1?access_token=secret")
  169. t.Setenv(xai.EnvClientID, "client-secret-like-value")
  170. handler := NewGrokOAuthHandler(nil, nil, nil, nil)
  171. router := gin.New()
  172. router.GET("/api/v1/admin/grok/runtime-sanity", handler.RuntimeSanity)
  173. rec := httptest.NewRecorder()
  174. req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/grok/runtime-sanity", nil)
  175. router.ServeHTTP(rec, req)
  176. require.Equal(t, http.StatusOK, rec.Code)
  177. require.Contains(t, rec.Body.String(), `"public_gateway_scope":"responses_only"`)
  178. require.Contains(t, rec.Body.String(), `"valid":false`)
  179. require.NotContains(t, rec.Body.String(), "access_token")
  180. require.NotContains(t, rec.Body.String(), "secret")
  181. require.NotContains(t, rec.Body.String(), "client-secret-like-value")
  182. }
  183. type grokOAuthHandlerClient struct{}
  184. func (c *grokOAuthHandlerClient) ExchangeCode(context.Context, string, string, string, string, string) (*xai.TokenResponse, error) {
  185. return nil, errors.New("unexpected exchange")
  186. }
  187. func (c *grokOAuthHandlerClient) RefreshToken(context.Context, string, string, string) (*xai.TokenResponse, error) {
  188. return &xai.TokenResponse{AccessToken: "access-token", RefreshToken: "refresh-token", ExpiresIn: 3600}, nil
  189. }
  190. func (c *grokOAuthHandlerClient) LoginWithPassword(_ context.Context, email, _ string, _ string) (*service.GrokPasswordLoginResult, error) {
  191. return &service.GrokPasswordLoginResult{
  192. Email: email,
  193. SSOToken: "sso-from-password",
  194. }, nil
  195. }
  196. func (c *grokOAuthHandlerClient) ConvertSSOToBuild(context.Context, string, string) (*xai.TokenResponse, error) {
  197. return &xai.TokenResponse{AccessToken: "access-token", RefreshToken: "refresh-token", ExpiresIn: 3600}, nil
  198. }
  199. func TestGrokOAuthHandlerValidateSSOTokenReturnsTokenInfo(t *testing.T) {
  200. gin.SetMode(gin.TestMode)
  201. oauthClient := &grokOAuthHandlerClient{}
  202. oauthService := service.NewGrokOAuthService(nil, oauthClient)
  203. defer oauthService.Stop()
  204. handler := NewGrokOAuthHandler(oauthService, nil, nil, nil)
  205. router := gin.New()
  206. router.POST("/api/v1/admin/grok/oauth/sso-token", handler.ValidateSSOToken)
  207. rec := httptest.NewRecorder()
  208. req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/grok/oauth/sso-token", strings.NewReader(`{"sso_token":"sso-token"}`))
  209. req.Header.Set("Content-Type", "application/json")
  210. router.ServeHTTP(rec, req)
  211. require.Equal(t, http.StatusOK, rec.Code)
  212. require.Contains(t, rec.Body.String(), `"access_token":"access-token"`)
  213. require.NotContains(t, rec.Body.String(), `"sso_token"`)
  214. }
  215. func TestGrokOAuthHandlerAuthorizePasswordReturnsTokenInfoWithoutPassword(t *testing.T) {
  216. gin.SetMode(gin.TestMode)
  217. oauthClient := &grokOAuthHandlerClient{}
  218. cfg := &config.Config{}
  219. cfg.Gateway.Grok.PasswordAuthEnabled = true
  220. oauthService := service.NewGrokOAuthService(nil, oauthClient, cfg)
  221. defer oauthService.Stop()
  222. handler := NewGrokOAuthHandler(oauthService, nil, nil, nil)
  223. router := gin.New()
  224. router.POST("/api/v1/admin/grok/oauth/password", handler.AuthorizePassword)
  225. rec := httptest.NewRecorder()
  226. req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/grok/oauth/password", strings.NewReader(`{"email":"user@example.com","password":"super-secret"}`))
  227. req.Header.Set("Content-Type", "application/json")
  228. router.ServeHTTP(rec, req)
  229. require.Equal(t, http.StatusOK, rec.Code)
  230. require.Contains(t, rec.Body.String(), `"access_token":"access-token"`)
  231. require.NotContains(t, rec.Body.String(), "super-secret")
  232. }
  233. func TestGrokOAuthHandlerPasswordCapabilityDefaultsToDisabled(t *testing.T) {
  234. gin.SetMode(gin.TestMode)
  235. oauthService := service.NewGrokOAuthService(nil, &grokOAuthHandlerClient{})
  236. defer oauthService.Stop()
  237. handler := NewGrokOAuthHandler(oauthService, nil, nil, nil)
  238. router := gin.New()
  239. router.GET("/api/v1/admin/grok/oauth/capabilities", handler.GetCapabilities)
  240. rec := httptest.NewRecorder()
  241. router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/admin/grok/oauth/capabilities", nil))
  242. require.Equal(t, http.StatusOK, rec.Code)
  243. require.Contains(t, rec.Body.String(), `"password_auth_enabled":false`)
  244. }
  245. func TestGrokSSOImportExpiryUsesTokenExpiryWithoutRefreshToken(t *testing.T) {
  246. tokenExpiry := time.Now().Add(6 * time.Hour).Unix()
  247. expiresAt, autoPause := grokSSOImportExpiry(nil, nil, &service.GrokTokenInfo{
  248. ExpiresAt: tokenExpiry,
  249. })
  250. require.NotNil(t, expiresAt)
  251. require.Equal(t, tokenExpiry, *expiresAt)
  252. require.NotNil(t, autoPause)
  253. require.True(t, *autoPause)
  254. }
  255. func TestGrokSSOImportExpiryUsesEarlierRequestedExpiryWithoutRefreshToken(t *testing.T) {
  256. requestedExpiry := time.Now().Add(2 * time.Hour).Unix()
  257. tokenExpiry := time.Now().Add(6 * time.Hour).Unix()
  258. requestedAutoPause := false
  259. expiresAt, autoPause := grokSSOImportExpiry(&requestedExpiry, &requestedAutoPause, &service.GrokTokenInfo{
  260. ExpiresAt: tokenExpiry,
  261. })
  262. require.NotNil(t, expiresAt)
  263. require.Equal(t, requestedExpiry, *expiresAt)
  264. require.NotNil(t, autoPause)
  265. require.True(t, *autoPause)
  266. }
  267. func TestGrokSSOImportExpiryPreservesRequestSettingsWithRefreshToken(t *testing.T) {
  268. requestedExpiry := time.Now().Add(2 * time.Hour).Unix()
  269. requestedAutoPause := false
  270. expiresAt, autoPause := grokSSOImportExpiry(&requestedExpiry, &requestedAutoPause, &service.GrokTokenInfo{
  271. RefreshToken: "refresh-token",
  272. ExpiresAt: time.Now().Add(6 * time.Hour).Unix(),
  273. })
  274. require.Same(t, &requestedExpiry, expiresAt)
  275. require.Same(t, &requestedAutoPause, autoPause)
  276. }
  277. func TestGrokSSOImportCredentialsPreservesRequestedBaseURL(t *testing.T) {
  278. built := map[string]any{
  279. "access_token": "at-1",
  280. "base_url": xai.DefaultCLIBaseURL,
  281. }
  282. reqCredentials := map[string]any{
  283. "base_url": "https://relay.example.com/v1",
  284. "header_override_enabled": true,
  285. "header_overrides": map[string]any{"x-relay-key": "k"},
  286. }
  287. credentials := grokSSOImportCredentials(built, reqCredentials)
  288. // token 字段以兑换结果为准;base_url 是运营侧配置,必须保留请求里的自定义地址
  289. require.Equal(t, "at-1", credentials["access_token"])
  290. require.Equal(t, "https://relay.example.com/v1", credentials["base_url"])
  291. require.Equal(t, true, credentials["header_override_enabled"])
  292. require.Equal(t, map[string]any{"x-relay-key": "k"}, credentials["header_overrides"])
  293. // 入参不被污染(req.Credentials 会被多个 worker 并发读取)
  294. require.Equal(t, "https://relay.example.com/v1", reqCredentials["base_url"])
  295. }
  296. func TestGrokSSOImportCredentialsDefaultsToOfficialBaseURL(t *testing.T) {
  297. built := map[string]any{
  298. "access_token": "at-1",
  299. "base_url": xai.DefaultCLIBaseURL,
  300. }
  301. credentials := grokSSOImportCredentials(built, nil)
  302. require.Equal(t, xai.DefaultCLIBaseURL, credentials["base_url"])
  303. credentials = grokSSOImportCredentials(map[string]any{
  304. "access_token": "at-2",
  305. "base_url": xai.DefaultCLIBaseURL,
  306. }, map[string]any{"base_url": " "})
  307. require.Equal(t, xai.DefaultCLIBaseURL, credentials["base_url"])
  308. require.Equal(t, "at-2", credentials["access_token"])
  309. }
  310. func TestGrokSSOImportWorkerHandlesMissingOAuthService(t *testing.T) {
  311. h := &GrokOAuthHandler{}
  312. result := h.safeCreateAccountFromSSOToken(context.Background(), GrokSSOToOAuthRequest{}, "token", 2, 3)
  313. require.False(t, result.created)
  314. require.Equal(t, 2, result.item.Index)
  315. require.Contains(t, result.item.Error, "GROK_OAUTH_CLIENT_NOT_CONFIGURED")
  316. }
  317. func TestGrokOAuthHandlerReconcileDefaultsToDryRun(t *testing.T) {
  318. gin.SetMode(gin.TestMode)
  319. reconciler := &grokOAuthReconcilerStub{result: &service.GrokOAuthReconcileResult{
  320. DryRun: true,
  321. Scanned: 2,
  322. Actionable: 1,
  323. WouldBlock: 1,
  324. Items: []service.GrokOAuthReconcileItem{{AccountID: 42, Reason: service.GrokOAuthReconcileReasonMissingRefreshToken, Action: service.GrokOAuthReconcileActionBlock, Outcome: service.GrokOAuthReconcileOutcomePlanned}},
  325. NextAfterID: 0,
  326. }}
  327. handler := NewGrokOAuthHandler(nil, nil, nil, reconciler)
  328. router := gin.New()
  329. router.POST("/api/v1/admin/grok/oauth/reconcile", handler.ReconcileOAuthAccounts)
  330. rec := httptest.NewRecorder()
  331. req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/grok/oauth/reconcile", strings.NewReader(`{}`))
  332. req.Header.Set("Content-Type", "application/json")
  333. router.ServeHTTP(rec, req)
  334. require.Equal(t, http.StatusOK, rec.Code)
  335. require.Equal(t, 1, reconciler.calls)
  336. require.True(t, reconciler.input.DryRun)
  337. require.False(t, reconciler.input.Apply)
  338. require.Contains(t, rec.Body.String(), `"reason":"missing_refresh_token"`)
  339. require.NotContains(t, rec.Body.String(), `"refresh_token":`)
  340. require.NotContains(t, rec.Body.String(), `"access_token":`)
  341. }
  342. func TestGrokOAuthHandlerReconcileRequiresExplicitApply(t *testing.T) {
  343. gin.SetMode(gin.TestMode)
  344. reconciler := &grokOAuthReconcilerStub{}
  345. handler := NewGrokOAuthHandler(nil, nil, nil, reconciler)
  346. router := gin.New()
  347. router.POST("/api/v1/admin/grok/oauth/reconcile", handler.ReconcileOAuthAccounts)
  348. rec := httptest.NewRecorder()
  349. req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/grok/oauth/reconcile", strings.NewReader(`{"dry_run":false}`))
  350. req.Header.Set("Content-Type", "application/json")
  351. router.ServeHTTP(rec, req)
  352. require.Equal(t, http.StatusBadRequest, rec.Code)
  353. require.Zero(t, reconciler.calls)
  354. require.NotContains(t, rec.Body.String(), "credentials")
  355. }
  356. func TestGrokOAuthHandlerReconcileExplicitApply(t *testing.T) {
  357. gin.SetMode(gin.TestMode)
  358. reconciler := &grokOAuthReconcilerStub{result: &service.GrokOAuthReconcileResult{DryRun: false, Refreshed: 1}}
  359. handler := NewGrokOAuthHandler(nil, nil, nil, reconciler)
  360. router := gin.New()
  361. router.POST("/api/v1/admin/grok/oauth/reconcile", handler.ReconcileOAuthAccounts)
  362. rec := httptest.NewRecorder()
  363. req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/grok/oauth/reconcile", strings.NewReader(`{"apply":true,"dry_run":false,"after_id":10,"limit":25,"refresh_window_seconds":3600}`))
  364. req.Header.Set("Content-Type", "application/json")
  365. router.ServeHTTP(rec, req)
  366. require.Equal(t, http.StatusOK, rec.Code)
  367. require.Equal(t, 1, reconciler.calls)
  368. require.True(t, reconciler.input.Apply)
  369. require.False(t, reconciler.input.DryRun)
  370. require.Equal(t, int64(10), reconciler.input.AfterID)
  371. require.Equal(t, 25, reconciler.input.Limit)
  372. require.Equal(t, time.Hour, reconciler.input.RefreshWindow)
  373. }