api_key_auth_google_test.go 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910
  1. package middleware
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "net/http"
  7. "net/http/httptest"
  8. "strings"
  9. "sync/atomic"
  10. "testing"
  11. "time"
  12. "github.com/Wei-Shaw/sub2api/internal/config"
  13. "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
  14. "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
  15. "github.com/Wei-Shaw/sub2api/internal/service"
  16. "github.com/gin-gonic/gin"
  17. "github.com/stretchr/testify/require"
  18. )
  19. func TestGoogleAPIKeyAuthRejectsOversizedCredentialsBeforeLookup(t *testing.T) {
  20. gin.SetMode(gin.TestMode)
  21. var calls atomic.Int32
  22. repo := fakeAPIKeyRepo{getByKey: func(context.Context, string) (*service.APIKey, error) {
  23. calls.Add(1)
  24. return nil, service.ErrAPIKeyNotFound
  25. }}
  26. cfg := &config.Config{RunMode: config.RunModeSimple}
  27. svc := service.NewAPIKeyService(repo, nil, nil, nil, nil, nil, cfg)
  28. r := gin.New()
  29. var reason IngressRejectReason
  30. var rejected bool
  31. r.Use(func(c *gin.Context) {
  32. c.Next()
  33. reason, rejected = GetIngressRejectReason(c)
  34. })
  35. r.Use(APIKeyAuthGoogle(svc, cfg))
  36. r.GET("/v1beta/models", func(c *gin.Context) { c.Status(http.StatusOK) })
  37. w := httptest.NewRecorder()
  38. req := httptest.NewRequest(http.MethodGet, "/v1beta/models", nil)
  39. req.Header.Set("x-goog-api-key", strings.Repeat("x", service.MaxAPIKeyCredentialBytes+1))
  40. r.ServeHTTP(w, req)
  41. require.Equal(t, http.StatusUnauthorized, w.Code)
  42. require.Zero(t, calls.Load())
  43. require.True(t, rejected)
  44. require.Equal(t, IngressRejectInvalidAPIKey, reason)
  45. }
  46. func TestGoogleAPIKeyAuthMarksLookupBulkheadRejection(t *testing.T) {
  47. gin.SetMode(gin.TestMode)
  48. repo := fakeAPIKeyRepo{getByKey: func(context.Context, string) (*service.APIKey, error) {
  49. return nil, service.ErrAPIKeyAuthOverloaded
  50. }}
  51. cfg := &config.Config{RunMode: config.RunModeSimple}
  52. svc := service.NewAPIKeyService(repo, nil, nil, nil, nil, nil, cfg)
  53. r := gin.New()
  54. var reason IngressRejectReason
  55. var rejected bool
  56. r.Use(func(c *gin.Context) {
  57. c.Next()
  58. reason, rejected = GetIngressRejectReason(c)
  59. })
  60. r.Use(APIKeyAuthGoogle(svc, cfg))
  61. r.GET("/v1beta/models", func(c *gin.Context) { c.Status(http.StatusOK) })
  62. w := httptest.NewRecorder()
  63. req := httptest.NewRequest(http.MethodGet, "/v1beta/models", nil)
  64. req.Header.Set("x-goog-api-key", "valid-shape")
  65. r.ServeHTTP(w, req)
  66. require.Equal(t, http.StatusServiceUnavailable, w.Code)
  67. require.True(t, rejected)
  68. require.Equal(t, IngressRejectAPIKeyAuthOverloaded, reason)
  69. }
  70. type fakeAPIKeyRepo struct {
  71. getByKey func(ctx context.Context, key string) (*service.APIKey, error)
  72. updateLastUsed func(ctx context.Context, id int64, usedAt time.Time) error
  73. }
  74. type fakeGoogleSubscriptionRepo struct {
  75. getByID func(ctx context.Context, id int64) (*service.UserSubscription, error)
  76. getActive func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error)
  77. updateStatus func(ctx context.Context, subscriptionID int64, status string) error
  78. activateWindow func(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error
  79. resetDaily func(ctx context.Context, id int64, start time.Time) error
  80. resetWeekly func(ctx context.Context, id int64, start time.Time) error
  81. resetMonthly func(ctx context.Context, id int64, start time.Time) error
  82. }
  83. func (f fakeAPIKeyRepo) Create(ctx context.Context, key *service.APIKey) error {
  84. return errors.New("not implemented")
  85. }
  86. func (f fakeAPIKeyRepo) GetByID(ctx context.Context, id int64) (*service.APIKey, error) {
  87. return nil, errors.New("not implemented")
  88. }
  89. func (f fakeAPIKeyRepo) GetKeyAndOwnerID(ctx context.Context, id int64) (string, int64, error) {
  90. return "", 0, errors.New("not implemented")
  91. }
  92. func (f fakeAPIKeyRepo) GetByKey(ctx context.Context, key string) (*service.APIKey, error) {
  93. if f.getByKey == nil {
  94. return nil, errors.New("unexpected call")
  95. }
  96. return f.getByKey(ctx, key)
  97. }
  98. func (f fakeAPIKeyRepo) GetByKeyForAuth(ctx context.Context, key string) (*service.APIKey, error) {
  99. return f.GetByKey(ctx, key)
  100. }
  101. func (f fakeAPIKeyRepo) Update(ctx context.Context, key *service.APIKey, _ service.APIKeyUpdateFields) error {
  102. return errors.New("not implemented")
  103. }
  104. func (f fakeAPIKeyRepo) Delete(ctx context.Context, id int64) error {
  105. return errors.New("not implemented")
  106. }
  107. func (f fakeAPIKeyRepo) DeleteWithAudit(ctx context.Context, id int64) error {
  108. return errors.New("not implemented")
  109. }
  110. func (f fakeAPIKeyRepo) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, _ service.APIKeyListFilters) ([]service.APIKey, *pagination.PaginationResult, error) {
  111. return nil, nil, errors.New("not implemented")
  112. }
  113. func (f fakeAPIKeyRepo) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) {
  114. return nil, errors.New("not implemented")
  115. }
  116. func (f fakeAPIKeyRepo) CountByUserID(ctx context.Context, userID int64) (int64, error) {
  117. return 0, errors.New("not implemented")
  118. }
  119. func (f fakeAPIKeyRepo) ExistsByKey(ctx context.Context, key string) (bool, error) {
  120. return false, errors.New("not implemented")
  121. }
  122. func (f fakeAPIKeyRepo) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]service.APIKey, *pagination.PaginationResult, error) {
  123. return nil, nil, errors.New("not implemented")
  124. }
  125. func (f fakeAPIKeyRepo) SearchAPIKeys(ctx context.Context, userID int64, keyword string, limit int) ([]service.APIKey, error) {
  126. return nil, errors.New("not implemented")
  127. }
  128. func (f fakeAPIKeyRepo) ClearGroupIDByGroupID(ctx context.Context, groupID int64) (int64, error) {
  129. return 0, errors.New("not implemented")
  130. }
  131. func (f fakeAPIKeyRepo) CountByGroupID(ctx context.Context, groupID int64) (int64, error) {
  132. return 0, errors.New("not implemented")
  133. }
  134. func (f fakeAPIKeyRepo) ListKeysByUserID(ctx context.Context, userID int64) ([]string, error) {
  135. return nil, errors.New("not implemented")
  136. }
  137. func (f fakeAPIKeyRepo) ListKeysByGroupID(ctx context.Context, groupID int64) ([]string, error) {
  138. return nil, errors.New("not implemented")
  139. }
  140. func (f fakeAPIKeyRepo) IncrementQuotaUsed(ctx context.Context, id int64, amount float64) (float64, error) {
  141. return 0, errors.New("not implemented")
  142. }
  143. func (f fakeAPIKeyRepo) UpdateLastUsed(ctx context.Context, id int64, usedAt time.Time) error {
  144. if f.updateLastUsed != nil {
  145. return f.updateLastUsed(ctx, id, usedAt)
  146. }
  147. return nil
  148. }
  149. func (f fakeAPIKeyRepo) IncrementRateLimitUsage(ctx context.Context, id int64, cost float64) error {
  150. return nil
  151. }
  152. func (f fakeAPIKeyRepo) ResetRateLimitWindows(ctx context.Context, id int64) error {
  153. return nil
  154. }
  155. func (f fakeAPIKeyRepo) GetRateLimitData(ctx context.Context, id int64) (*service.APIKeyRateLimitData, error) {
  156. return &service.APIKeyRateLimitData{}, nil
  157. }
  158. func (f fakeAPIKeyRepo) UpdateGroupIDByUserAndGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (int64, error) {
  159. return 0, errors.New("not implemented")
  160. }
  161. func (f fakeGoogleSubscriptionRepo) Create(ctx context.Context, sub *service.UserSubscription) error {
  162. return errors.New("not implemented")
  163. }
  164. func (f fakeGoogleSubscriptionRepo) GetByID(ctx context.Context, id int64) (*service.UserSubscription, error) {
  165. if f.getByID != nil {
  166. return f.getByID(ctx, id)
  167. }
  168. return nil, errors.New("not implemented")
  169. }
  170. func (f fakeGoogleSubscriptionRepo) GetByIDForUpdate(ctx context.Context, id int64) (*service.UserSubscription, error) {
  171. return f.GetByID(ctx, id)
  172. }
  173. func (f fakeGoogleSubscriptionRepo) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.UserSubscription, error) {
  174. return nil, errors.New("not implemented")
  175. }
  176. func (f fakeGoogleSubscriptionRepo) GetByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) {
  177. return nil, errors.New("not implemented")
  178. }
  179. func (f fakeGoogleSubscriptionRepo) GetActiveByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) {
  180. if f.getActive != nil {
  181. return f.getActive(ctx, userID, groupID)
  182. }
  183. return nil, errors.New("not implemented")
  184. }
  185. func (f fakeGoogleSubscriptionRepo) Update(ctx context.Context, sub *service.UserSubscription) error {
  186. return errors.New("not implemented")
  187. }
  188. func (f fakeGoogleSubscriptionRepo) Delete(ctx context.Context, id int64) error {
  189. return errors.New("not implemented")
  190. }
  191. func (f fakeGoogleSubscriptionRepo) Restore(ctx context.Context, subscriptionID int64, restoredStatus string) (*service.UserSubscription, error) {
  192. return nil, errors.New("not implemented")
  193. }
  194. func (f fakeGoogleSubscriptionRepo) ListByUserID(ctx context.Context, userID int64) ([]service.UserSubscription, error) {
  195. return nil, errors.New("not implemented")
  196. }
  197. func (f fakeGoogleSubscriptionRepo) ListActiveByUserID(ctx context.Context, userID int64) ([]service.UserSubscription, error) {
  198. return nil, errors.New("not implemented")
  199. }
  200. func (f fakeGoogleSubscriptionRepo) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]service.UserSubscription, *pagination.PaginationResult, error) {
  201. return nil, nil, errors.New("not implemented")
  202. }
  203. func (f fakeGoogleSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, platform, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) {
  204. return nil, nil, errors.New("not implemented")
  205. }
  206. func (f fakeGoogleSubscriptionRepo) ExistsByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (bool, error) {
  207. return false, errors.New("not implemented")
  208. }
  209. func (f fakeGoogleSubscriptionRepo) ExistsActiveByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (bool, error) {
  210. return false, errors.New("not implemented")
  211. }
  212. func (f fakeGoogleSubscriptionRepo) ExtendExpiry(ctx context.Context, subscriptionID int64, newExpiresAt time.Time) error {
  213. return errors.New("not implemented")
  214. }
  215. func (f fakeGoogleSubscriptionRepo) UpdateStatus(ctx context.Context, subscriptionID int64, status string) error {
  216. if f.updateStatus != nil {
  217. return f.updateStatus(ctx, subscriptionID, status)
  218. }
  219. return errors.New("not implemented")
  220. }
  221. func (f fakeGoogleSubscriptionRepo) UpdateNotes(ctx context.Context, subscriptionID int64, notes string) error {
  222. return errors.New("not implemented")
  223. }
  224. func (f fakeGoogleSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error {
  225. if f.activateWindow != nil {
  226. return f.activateWindow(ctx, id, dailyStart, periodicStart)
  227. }
  228. return errors.New("not implemented")
  229. }
  230. func (f fakeGoogleSubscriptionRepo) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time, time.Time) error {
  231. return errors.New("not implemented")
  232. }
  233. func (f fakeGoogleSubscriptionRepo) ResetDailyUsage(ctx context.Context, id int64, _ *time.Time, start time.Time) error {
  234. if f.resetDaily != nil {
  235. return f.resetDaily(ctx, id, start)
  236. }
  237. return errors.New("not implemented")
  238. }
  239. func (f fakeGoogleSubscriptionRepo) ResetWeeklyUsage(ctx context.Context, id int64, _ *time.Time, start time.Time) error {
  240. if f.resetWeekly != nil {
  241. return f.resetWeekly(ctx, id, start)
  242. }
  243. return errors.New("not implemented")
  244. }
  245. func (f fakeGoogleSubscriptionRepo) ResetMonthlyUsage(ctx context.Context, id int64, _ *time.Time, start time.Time) error {
  246. if f.resetMonthly != nil {
  247. return f.resetMonthly(ctx, id, start)
  248. }
  249. return errors.New("not implemented")
  250. }
  251. func (f fakeGoogleSubscriptionRepo) IncrementUsage(ctx context.Context, id int64, costUSD float64) error {
  252. return errors.New("not implemented")
  253. }
  254. func (f fakeGoogleSubscriptionRepo) BatchUpdateExpiredStatus(ctx context.Context) (int64, error) {
  255. return 0, errors.New("not implemented")
  256. }
  257. type googleErrorResponse struct {
  258. Error struct {
  259. Code int `json:"code"`
  260. Message string `json:"message"`
  261. Status string `json:"status"`
  262. } `json:"error"`
  263. }
  264. func newTestAPIKeyService(repo service.APIKeyRepository) *service.APIKeyService {
  265. return service.NewAPIKeyService(
  266. repo,
  267. nil, // userRepo (unused in GetByKey)
  268. nil, // groupRepo
  269. nil, // userSubRepo
  270. nil, // userGroupRateRepo
  271. nil, // cache
  272. &config.Config{},
  273. )
  274. }
  275. func TestApiKeyAuthWithSubscriptionGoogle_MissingKey(t *testing.T) {
  276. gin.SetMode(gin.TestMode)
  277. r := gin.New()
  278. apiKeyService := newTestAPIKeyService(fakeAPIKeyRepo{
  279. getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
  280. return nil, errors.New("should not be called")
  281. },
  282. })
  283. r.Use(APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, &config.Config{}))
  284. r.GET("/v1beta/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
  285. req := httptest.NewRequest(http.MethodGet, "/v1beta/test", nil)
  286. rec := httptest.NewRecorder()
  287. r.ServeHTTP(rec, req)
  288. require.Equal(t, http.StatusUnauthorized, rec.Code)
  289. var resp googleErrorResponse
  290. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
  291. require.Equal(t, http.StatusUnauthorized, resp.Error.Code)
  292. require.Equal(t, "API key is required", resp.Error.Message)
  293. require.Equal(t, "UNAUTHENTICATED", resp.Error.Status)
  294. }
  295. func TestApiKeyAuthWithSubscriptionGoogle_QueryApiKeyRejected(t *testing.T) {
  296. gin.SetMode(gin.TestMode)
  297. r := gin.New()
  298. apiKeyService := newTestAPIKeyService(fakeAPIKeyRepo{
  299. getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
  300. return nil, errors.New("should not be called")
  301. },
  302. })
  303. r.Use(APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, &config.Config{}))
  304. r.GET("/v1beta/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
  305. req := httptest.NewRequest(http.MethodGet, "/v1beta/test?api_key=legacy", nil)
  306. rec := httptest.NewRecorder()
  307. r.ServeHTTP(rec, req)
  308. require.Equal(t, http.StatusBadRequest, rec.Code)
  309. var resp googleErrorResponse
  310. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
  311. require.Equal(t, http.StatusBadRequest, resp.Error.Code)
  312. require.Equal(t, "Query parameter api_key is deprecated. Use Authorization header or key instead.", resp.Error.Message)
  313. require.Equal(t, "INVALID_ARGUMENT", resp.Error.Status)
  314. }
  315. func TestApiKeyAuthWithSubscriptionGoogleSetsGroupContext(t *testing.T) {
  316. gin.SetMode(gin.TestMode)
  317. group := &service.Group{
  318. ID: 99,
  319. Name: "g1",
  320. Status: service.StatusActive,
  321. Platform: service.PlatformGemini,
  322. Hydrated: true,
  323. }
  324. user := &service.User{
  325. ID: 7,
  326. Role: service.RoleUser,
  327. Status: service.StatusActive,
  328. Balance: 10,
  329. Concurrency: 3,
  330. }
  331. apiKey := &service.APIKey{
  332. ID: 100,
  333. UserID: user.ID,
  334. Key: "test-key",
  335. Status: service.StatusActive,
  336. User: user,
  337. Group: group,
  338. }
  339. apiKey.GroupID = &group.ID
  340. apiKeyService := service.NewAPIKeyService(
  341. fakeAPIKeyRepo{
  342. getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
  343. if key != apiKey.Key {
  344. return nil, service.ErrAPIKeyNotFound
  345. }
  346. clone := *apiKey
  347. return &clone, nil
  348. },
  349. },
  350. nil,
  351. nil,
  352. nil,
  353. nil,
  354. nil,
  355. &config.Config{RunMode: config.RunModeSimple},
  356. )
  357. cfg := &config.Config{RunMode: config.RunModeSimple}
  358. r := gin.New()
  359. r.Use(APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, cfg))
  360. r.GET("/v1beta/test", func(c *gin.Context) {
  361. groupFromCtx, ok := c.Request.Context().Value(ctxkey.Group).(*service.Group)
  362. if !ok || groupFromCtx == nil || groupFromCtx.ID != group.ID {
  363. c.JSON(http.StatusInternalServerError, gin.H{"ok": false})
  364. return
  365. }
  366. c.JSON(http.StatusOK, gin.H{"ok": true})
  367. })
  368. req := httptest.NewRequest(http.MethodGet, "/v1beta/test", nil)
  369. req.Header.Set("x-api-key", apiKey.Key)
  370. rec := httptest.NewRecorder()
  371. r.ServeHTTP(rec, req)
  372. require.Equal(t, http.StatusOK, rec.Code)
  373. }
  374. func TestApiKeyAuthWithSubscriptionGoogle_QueryKeyAllowedOnV1Beta(t *testing.T) {
  375. gin.SetMode(gin.TestMode)
  376. r := gin.New()
  377. apiKeyService := newTestAPIKeyService(fakeAPIKeyRepo{
  378. getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
  379. return &service.APIKey{
  380. ID: 1,
  381. Key: key,
  382. Status: service.StatusActive,
  383. User: &service.User{
  384. ID: 123,
  385. Status: service.StatusActive,
  386. },
  387. }, nil
  388. },
  389. })
  390. cfg := &config.Config{RunMode: config.RunModeSimple}
  391. r.Use(APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, cfg))
  392. r.GET("/v1beta/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
  393. req := httptest.NewRequest(http.MethodGet, "/v1beta/test?key=valid", nil)
  394. rec := httptest.NewRecorder()
  395. r.ServeHTTP(rec, req)
  396. require.Equal(t, http.StatusOK, rec.Code)
  397. }
  398. func TestApiKeyAuthWithSubscriptionGoogle_InvalidKey(t *testing.T) {
  399. gin.SetMode(gin.TestMode)
  400. r := gin.New()
  401. apiKeyService := newTestAPIKeyService(fakeAPIKeyRepo{
  402. getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
  403. return nil, service.ErrAPIKeyNotFound
  404. },
  405. })
  406. var rejectReason IngressRejectReason
  407. var rejected bool
  408. r.Use(func(c *gin.Context) {
  409. c.Next()
  410. rejectReason, rejected = GetIngressRejectReason(c)
  411. })
  412. r.Use(APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, &config.Config{}))
  413. r.GET("/v1beta/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
  414. req := httptest.NewRequest(http.MethodGet, "/v1beta/test", nil)
  415. req.Header.Set("Authorization", "Bearer invalid")
  416. rec := httptest.NewRecorder()
  417. r.ServeHTTP(rec, req)
  418. require.Equal(t, http.StatusUnauthorized, rec.Code)
  419. var resp googleErrorResponse
  420. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
  421. require.Equal(t, http.StatusUnauthorized, resp.Error.Code)
  422. require.Equal(t, "Invalid API key", resp.Error.Message)
  423. require.Equal(t, "UNAUTHENTICATED", resp.Error.Status)
  424. require.True(t, rejected)
  425. require.Equal(t, IngressRejectInvalidAPIKey, rejectReason)
  426. }
  427. func TestApiKeyAuthWithSubscriptionGoogle_MarksUnavailableGroupBusinessLimited(t *testing.T) {
  428. gin.SetMode(gin.TestMode)
  429. groupID := int64(101)
  430. user := &service.User{
  431. ID: 7,
  432. Role: service.RoleUser,
  433. Status: service.StatusActive,
  434. Balance: 10,
  435. Concurrency: 3,
  436. }
  437. apiKey := &service.APIKey{
  438. ID: 100,
  439. UserID: user.ID,
  440. GroupID: &groupID,
  441. Key: "google-group-deleted",
  442. Status: service.StatusActive,
  443. User: user,
  444. Group: &service.Group{
  445. ID: groupID,
  446. Name: "deleted",
  447. Status: "deleted",
  448. Platform: service.PlatformGemini,
  449. Hydrated: true,
  450. },
  451. }
  452. r := gin.New()
  453. var markedBusinessLimited bool
  454. var businessLimitedReason string
  455. var rejectReason IngressRejectReason
  456. var rejected bool
  457. r.Use(func(c *gin.Context) {
  458. c.Next()
  459. markedBusinessLimited = service.HasOpsClientBusinessLimited(c)
  460. rejectReason, rejected = GetIngressRejectReason(c)
  461. if v, ok := c.Get(service.OpsClientBusinessLimitedReasonKey); ok {
  462. businessLimitedReason, _ = v.(string)
  463. }
  464. })
  465. apiKeyService := newTestAPIKeyService(fakeAPIKeyRepo{
  466. getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
  467. if key != apiKey.Key {
  468. return nil, service.ErrAPIKeyNotFound
  469. }
  470. clone := *apiKey
  471. return &clone, nil
  472. },
  473. })
  474. r.Use(APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, &config.Config{RunMode: config.RunModeSimple}))
  475. r.GET("/v1beta/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
  476. req := httptest.NewRequest(http.MethodGet, "/v1beta/test", nil)
  477. req.Header.Set("x-goog-api-key", apiKey.Key)
  478. rec := httptest.NewRecorder()
  479. r.ServeHTTP(rec, req)
  480. require.Equal(t, http.StatusForbidden, rec.Code)
  481. var resp googleErrorResponse
  482. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
  483. require.Equal(t, "API Key 所属分组已删除", resp.Error.Message)
  484. require.True(t, markedBusinessLimited)
  485. require.Equal(t, service.OpsClientBusinessLimitedReasonAPIKeyGroupUnavailable, businessLimitedReason)
  486. require.True(t, rejected)
  487. require.Equal(t, IngressRejectGroupDeleted, rejectReason)
  488. }
  489. func TestApiKeyAuthWithSubscriptionGoogle_RepoError(t *testing.T) {
  490. gin.SetMode(gin.TestMode)
  491. r := gin.New()
  492. apiKeyService := newTestAPIKeyService(fakeAPIKeyRepo{
  493. getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
  494. return nil, errors.New("db down")
  495. },
  496. })
  497. r.Use(APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, &config.Config{}))
  498. r.GET("/v1beta/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
  499. req := httptest.NewRequest(http.MethodGet, "/v1beta/test", nil)
  500. req.Header.Set("Authorization", "Bearer any")
  501. rec := httptest.NewRecorder()
  502. r.ServeHTTP(rec, req)
  503. require.Equal(t, http.StatusInternalServerError, rec.Code)
  504. var resp googleErrorResponse
  505. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
  506. require.Equal(t, http.StatusInternalServerError, resp.Error.Code)
  507. require.Equal(t, "Failed to validate API key", resp.Error.Message)
  508. require.Equal(t, "INTERNAL", resp.Error.Status)
  509. }
  510. func TestApiKeyAuthWithSubscriptionGoogle_DisabledKey(t *testing.T) {
  511. gin.SetMode(gin.TestMode)
  512. r := gin.New()
  513. apiKeyService := newTestAPIKeyService(fakeAPIKeyRepo{
  514. getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
  515. return &service.APIKey{
  516. ID: 1,
  517. Key: key,
  518. Status: service.StatusDisabled,
  519. User: &service.User{
  520. ID: 123,
  521. Status: service.StatusActive,
  522. },
  523. }, nil
  524. },
  525. })
  526. r.Use(APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, &config.Config{}))
  527. r.GET("/v1beta/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
  528. req := httptest.NewRequest(http.MethodGet, "/v1beta/test", nil)
  529. req.Header.Set("Authorization", "Bearer disabled")
  530. rec := httptest.NewRecorder()
  531. r.ServeHTTP(rec, req)
  532. require.Equal(t, http.StatusUnauthorized, rec.Code)
  533. var resp googleErrorResponse
  534. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
  535. require.Equal(t, http.StatusUnauthorized, resp.Error.Code)
  536. require.Equal(t, "API key is disabled", resp.Error.Message)
  537. require.Equal(t, "UNAUTHENTICATED", resp.Error.Status)
  538. }
  539. func TestApiKeyAuthWithSubscriptionGoogle_InsufficientBalance(t *testing.T) {
  540. gin.SetMode(gin.TestMode)
  541. r := gin.New()
  542. apiKeyService := newTestAPIKeyService(fakeAPIKeyRepo{
  543. getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
  544. return &service.APIKey{
  545. ID: 1,
  546. Key: key,
  547. Status: service.StatusActive,
  548. User: &service.User{
  549. ID: 123,
  550. Status: service.StatusActive,
  551. Balance: 0,
  552. },
  553. }, nil
  554. },
  555. })
  556. r.Use(APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, &config.Config{}))
  557. r.GET("/v1beta/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
  558. req := httptest.NewRequest(http.MethodGet, "/v1beta/test", nil)
  559. req.Header.Set("Authorization", "Bearer ok")
  560. rec := httptest.NewRecorder()
  561. r.ServeHTTP(rec, req)
  562. require.Equal(t, http.StatusForbidden, rec.Code)
  563. var resp googleErrorResponse
  564. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
  565. require.Equal(t, http.StatusForbidden, resp.Error.Code)
  566. require.Equal(t, "Insufficient account balance", resp.Error.Message)
  567. require.Equal(t, "PERMISSION_DENIED", resp.Error.Status)
  568. }
  569. func TestApiKeyAuthWithSubscriptionGoogle_BalanceBelowMinimumReserve(t *testing.T) {
  570. gin.SetMode(gin.TestMode)
  571. // 鉴权层保持历史语义:MinimumBalanceReserve 只用于 billing-cache 预检,
  572. // 0 < balance < reserve 的用户不得在鉴权中间件被硬 403。
  573. r := gin.New()
  574. apiKeyService := newTestAPIKeyService(fakeAPIKeyRepo{
  575. getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
  576. return &service.APIKey{
  577. ID: 1,
  578. Key: key,
  579. Status: service.StatusActive,
  580. User: &service.User{
  581. ID: 123,
  582. Status: service.StatusActive,
  583. Balance: 0.005,
  584. },
  585. }, nil
  586. },
  587. })
  588. cfg := &config.Config{}
  589. cfg.Billing.MinimumBalanceReserve = 0.01
  590. r.Use(APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, cfg))
  591. r.GET("/v1beta/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
  592. req := httptest.NewRequest(http.MethodGet, "/v1beta/test", nil)
  593. req.Header.Set("Authorization", "Bearer ok")
  594. rec := httptest.NewRecorder()
  595. r.ServeHTTP(rec, req)
  596. require.Equal(t, http.StatusOK, rec.Code)
  597. }
  598. func TestApiKeyAuthWithSubscriptionGoogle_RejectsExhaustedBalance(t *testing.T) {
  599. gin.SetMode(gin.TestMode)
  600. r := gin.New()
  601. apiKeyService := newTestAPIKeyService(fakeAPIKeyRepo{
  602. getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
  603. return &service.APIKey{
  604. ID: 1,
  605. Key: key,
  606. Status: service.StatusActive,
  607. User: &service.User{
  608. ID: 123,
  609. Status: service.StatusActive,
  610. Balance: 0,
  611. },
  612. }, nil
  613. },
  614. })
  615. cfg := &config.Config{}
  616. r.Use(APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, cfg))
  617. r.GET("/v1beta/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
  618. req := httptest.NewRequest(http.MethodGet, "/v1beta/test", nil)
  619. req.Header.Set("Authorization", "Bearer ok")
  620. rec := httptest.NewRecorder()
  621. r.ServeHTTP(rec, req)
  622. require.Equal(t, http.StatusForbidden, rec.Code)
  623. var resp googleErrorResponse
  624. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
  625. require.Equal(t, http.StatusForbidden, resp.Error.Code)
  626. require.Equal(t, "Insufficient account balance", resp.Error.Message)
  627. require.Equal(t, "PERMISSION_DENIED", resp.Error.Status)
  628. }
  629. func TestApiKeyAuthWithSubscriptionGoogle_TouchesLastUsedOnSuccess(t *testing.T) {
  630. gin.SetMode(gin.TestMode)
  631. user := &service.User{
  632. ID: 11,
  633. Role: service.RoleUser,
  634. Status: service.StatusActive,
  635. Balance: 10,
  636. Concurrency: 3,
  637. }
  638. apiKey := &service.APIKey{
  639. ID: 201,
  640. UserID: user.ID,
  641. Key: "google-touch-ok",
  642. Status: service.StatusActive,
  643. User: user,
  644. }
  645. var touchedID int64
  646. var touchedAt time.Time
  647. r := gin.New()
  648. apiKeyService := newTestAPIKeyService(fakeAPIKeyRepo{
  649. getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
  650. if key != apiKey.Key {
  651. return nil, service.ErrAPIKeyNotFound
  652. }
  653. clone := *apiKey
  654. return &clone, nil
  655. },
  656. updateLastUsed: func(ctx context.Context, id int64, usedAt time.Time) error {
  657. touchedID = id
  658. touchedAt = usedAt
  659. return nil
  660. },
  661. })
  662. cfg := &config.Config{RunMode: config.RunModeSimple}
  663. r.Use(APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, cfg))
  664. r.GET("/v1beta/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
  665. req := httptest.NewRequest(http.MethodGet, "/v1beta/test", nil)
  666. req.Header.Set("x-goog-api-key", apiKey.Key)
  667. rec := httptest.NewRecorder()
  668. r.ServeHTTP(rec, req)
  669. require.Equal(t, http.StatusOK, rec.Code)
  670. require.Equal(t, apiKey.ID, touchedID)
  671. require.False(t, touchedAt.IsZero())
  672. }
  673. func TestApiKeyAuthWithSubscriptionGoogle_TouchFailureDoesNotBlock(t *testing.T) {
  674. gin.SetMode(gin.TestMode)
  675. user := &service.User{
  676. ID: 12,
  677. Role: service.RoleUser,
  678. Status: service.StatusActive,
  679. Balance: 10,
  680. Concurrency: 3,
  681. }
  682. apiKey := &service.APIKey{
  683. ID: 202,
  684. UserID: user.ID,
  685. Key: "google-touch-fail",
  686. Status: service.StatusActive,
  687. User: user,
  688. }
  689. touchCalls := 0
  690. r := gin.New()
  691. apiKeyService := newTestAPIKeyService(fakeAPIKeyRepo{
  692. getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
  693. if key != apiKey.Key {
  694. return nil, service.ErrAPIKeyNotFound
  695. }
  696. clone := *apiKey
  697. return &clone, nil
  698. },
  699. updateLastUsed: func(ctx context.Context, id int64, usedAt time.Time) error {
  700. touchCalls++
  701. return errors.New("write failed")
  702. },
  703. })
  704. cfg := &config.Config{RunMode: config.RunModeSimple}
  705. r.Use(APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, cfg))
  706. r.GET("/v1beta/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
  707. req := httptest.NewRequest(http.MethodGet, "/v1beta/test", nil)
  708. req.Header.Set("x-goog-api-key", apiKey.Key)
  709. rec := httptest.NewRecorder()
  710. r.ServeHTTP(rec, req)
  711. require.Equal(t, http.StatusOK, rec.Code)
  712. require.Equal(t, 1, touchCalls)
  713. }
  714. func TestApiKeyAuthWithSubscriptionGoogle_TouchesLastUsedInStandardMode(t *testing.T) {
  715. gin.SetMode(gin.TestMode)
  716. user := &service.User{
  717. ID: 13,
  718. Role: service.RoleUser,
  719. Status: service.StatusActive,
  720. Balance: 10,
  721. Concurrency: 3,
  722. }
  723. apiKey := &service.APIKey{
  724. ID: 203,
  725. UserID: user.ID,
  726. Key: "google-touch-standard",
  727. Status: service.StatusActive,
  728. User: user,
  729. }
  730. touchCalls := 0
  731. r := gin.New()
  732. apiKeyService := newTestAPIKeyService(fakeAPIKeyRepo{
  733. getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
  734. if key != apiKey.Key {
  735. return nil, service.ErrAPIKeyNotFound
  736. }
  737. clone := *apiKey
  738. return &clone, nil
  739. },
  740. updateLastUsed: func(ctx context.Context, id int64, usedAt time.Time) error {
  741. touchCalls++
  742. return nil
  743. },
  744. })
  745. cfg := &config.Config{RunMode: config.RunModeStandard}
  746. r.Use(APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, cfg))
  747. r.GET("/v1beta/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
  748. req := httptest.NewRequest(http.MethodGet, "/v1beta/test", nil)
  749. req.Header.Set("Authorization", "Bearer "+apiKey.Key)
  750. rec := httptest.NewRecorder()
  751. r.ServeHTTP(rec, req)
  752. require.Equal(t, http.StatusOK, rec.Code)
  753. require.Equal(t, 1, touchCalls)
  754. }
  755. func TestApiKeyAuthWithSubscriptionGoogle_SubscriptionLimitExceededReturns429(t *testing.T) {
  756. gin.SetMode(gin.TestMode)
  757. limit := 1.0
  758. group := &service.Group{
  759. ID: 77,
  760. Name: "gemini-sub",
  761. Status: service.StatusActive,
  762. Platform: service.PlatformGemini,
  763. Hydrated: true,
  764. SubscriptionType: service.SubscriptionTypeSubscription,
  765. DailyLimitUSD: &limit,
  766. }
  767. user := &service.User{
  768. ID: 999,
  769. Role: service.RoleUser,
  770. Status: service.StatusActive,
  771. Balance: 10,
  772. Concurrency: 3,
  773. }
  774. apiKey := &service.APIKey{
  775. ID: 501,
  776. UserID: user.ID,
  777. Key: "google-sub-limit",
  778. Status: service.StatusActive,
  779. User: user,
  780. Group: group,
  781. }
  782. apiKey.GroupID = &group.ID
  783. apiKeyService := newTestAPIKeyService(fakeAPIKeyRepo{
  784. getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
  785. if key != apiKey.Key {
  786. return nil, service.ErrAPIKeyNotFound
  787. }
  788. clone := *apiKey
  789. return &clone, nil
  790. },
  791. })
  792. now := time.Now()
  793. sub := &service.UserSubscription{
  794. ID: 601,
  795. UserID: user.ID,
  796. GroupID: group.ID,
  797. Status: service.SubscriptionStatusActive,
  798. ExpiresAt: now.Add(24 * time.Hour),
  799. DailyWindowStart: &now,
  800. DailyUsageUSD: 10,
  801. }
  802. subscriptionService := service.NewSubscriptionService(nil, fakeGoogleSubscriptionRepo{
  803. getActive: func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) {
  804. if userID != user.ID || groupID != group.ID {
  805. return nil, service.ErrSubscriptionNotFound
  806. }
  807. clone := *sub
  808. return &clone, nil
  809. },
  810. updateStatus: func(ctx context.Context, subscriptionID int64, status string) error { return nil },
  811. activateWindow: func(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error { return nil },
  812. resetDaily: func(ctx context.Context, id int64, start time.Time) error { return nil },
  813. resetWeekly: func(ctx context.Context, id int64, start time.Time) error { return nil },
  814. resetMonthly: func(ctx context.Context, id int64, start time.Time) error { return nil },
  815. }, nil, nil, &config.Config{RunMode: config.RunModeStandard})
  816. r := gin.New()
  817. r.Use(APIKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService, &config.Config{RunMode: config.RunModeStandard}))
  818. r.GET("/v1beta/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
  819. req := httptest.NewRequest(http.MethodGet, "/v1beta/test", nil)
  820. req.Header.Set("x-goog-api-key", apiKey.Key)
  821. rec := httptest.NewRecorder()
  822. r.ServeHTTP(rec, req)
  823. require.Equal(t, http.StatusTooManyRequests, rec.Code)
  824. var resp googleErrorResponse
  825. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
  826. require.Equal(t, http.StatusTooManyRequests, resp.Error.Code)
  827. require.Equal(t, "RESOURCE_EXHAUSTED", resp.Error.Status)
  828. require.Contains(t, resp.Error.Message, "daily usage limit exceeded")
  829. }