Files
sub2api/backend/internal/handler/admin/grok_oauth_handler_test.go
T
shaw 221581400b feat(grok): 支持账号级自定义上游地址与请求头覆写
将账号级请求头覆写从 anthropic/openai 的 api_key 账号扩展到 Grok 的
api_key 与 oauth 账号,并放开 Grok OAuth 账号的自定义上游地址(仅作用于
转发端点,授权与 token 刷新链路不变)。

后端:
- IsHeaderOverrideEligible 扩展到 Grok(api_key+oauth),禁止名单新增
  x-grok-conv-id(逐请求会话路由头)。
- 所有 Grok 上游请求路径接线 ApplyHeaderOverrides(Responses/Chat 桥/
  媒体/配额探测/billing 探测/连通性测试),统一置于内置默认头之后。
- GetGrokBaseURL/GetGrokMediaBaseURL 放开 OAuth 自定义地址:官方地址
  视同未定制回落官方网关,仅显式第三方 host 改发转发流量。
- 非官方 host 允许任意 path 前缀,官方 host 仍强制 /v1。
- OAuth base_url 校验按 host 判定官方/自定义,自定义 host 恒受运营方
  URL 策略约束,不受 XAI_ALLOW_UNSAFE_URL_OVERRIDES 调试开关放宽。
- 给 GrokQuotaService 注入 config,使配额/billing 探测与转发共用同一
  URL 策略。

前端:
- Edit 模态为 Grok OAuth 账号新增「自定义上游地址」开关。
- 请求头表单新增 JSON 快速导入与按 JSON 一键复制(复用 useClipboard,
  兼容非安全上下文 HTTP 页面)。
- 门控改为平台×类型判定,Bulk 批量 base_url 增加格式校验,zh/en 文案同步。
2026-07-15 20:30:23 +08:00

301 lines
11 KiB
Go

//go:build unit
package admin
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/Wei-Shaw/sub2api/internal/service"
)
type grokQuotaHandlerAccountRepo struct {
service.AccountRepository
account *service.Account
updates map[int64]map[string]any
}
func (r *grokQuotaHandlerAccountRepo) GetByID(_ context.Context, id int64) (*service.Account, error) {
if r.account != nil && r.account.ID == id {
return r.account, nil
}
return nil, service.ErrAccountNotFound
}
func (r *grokQuotaHandlerAccountRepo) UpdateExtra(_ context.Context, id int64, updates map[string]any) error {
if r.updates == nil {
r.updates = make(map[int64]map[string]any)
}
r.updates[id] = updates
return nil
}
type grokQuotaHandlerUpstream struct {
mu sync.Mutex
requests []*http.Request
bodies [][]byte
}
type grokOAuthReconcilerStub struct {
input service.GrokOAuthReconcileInput
calls int
result *service.GrokOAuthReconcileResult
err error
}
func (s *grokOAuthReconcilerStub) ReconcileGrokOAuth(_ context.Context, input service.GrokOAuthReconcileInput) (*service.GrokOAuthReconcileResult, error) {
s.calls++
s.input = input
return s.result, s.err
}
func (u *grokQuotaHandlerUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
var body []byte
if req.Body != nil {
body, _ = io.ReadAll(req.Body)
}
u.mu.Lock()
u.requests = append(u.requests, req)
u.bodies = append(u.bodies, body)
u.mu.Unlock()
if req.URL.Path == "/v1/responses" {
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{
"X-Ratelimit-Limit-Requests": []string{"10"},
"X-Ratelimit-Remaining-Requests": []string{"8"},
},
Body: io.NopCloser(strings.NewReader(`{"id":"resp_probe"}`)),
}, nil
}
payload := `{"config":{"billingPeriodStart":"2026-07-01T00:00:00Z","billingPeriodEnd":"2026-08-01T00:00:00Z"}}`
if req.URL.RawQuery == "format=credits" {
payload = `{"config":{"currentPeriod":{"type":"WEEKLY","start":"2026-07-09T03:25:00Z","end":"2026-07-16T03:25:00Z"}}}`
}
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(payload))}, nil
}
func (u *grokQuotaHandlerUpstream) DoWithTLS(
req *http.Request,
proxyURL string,
accountID int64,
accountConcurrency int,
_ *tlsfingerprint.Profile,
) (*http.Response, error) {
return u.Do(req, proxyURL, accountID, accountConcurrency)
}
func TestGrokOAuthHandlerQueryQuotaProbesUpstream(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &grokQuotaHandlerAccountRepo{account: &service.Account{
ID: 42,
Platform: service.PlatformGrok,
Type: service.AccountTypeOAuth,
Status: service.StatusActive,
Schedulable: true,
Concurrency: 1,
Credentials: map[string]any{
"access_token": "access-token",
"refresh_token": "refresh-token",
"expires_at": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339),
},
}}
upstream := &grokQuotaHandlerUpstream{}
quotaService := service.NewGrokQuotaService(repo, nil, service.NewGrokTokenProvider(repo, nil), upstream, nil)
handler := NewGrokOAuthHandler(nil, nil, quotaService, nil)
router := gin.New()
router.GET("/api/v1/admin/grok/accounts/:id/quota", handler.QueryQuota)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/grok/accounts/42/quota", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Contains(t, rec.Body.String(), `"source":"hybrid_probe"`)
require.Contains(t, rec.Body.String(), `"billing":`)
require.Contains(t, rec.Body.String(), `"snapshot":`)
require.Contains(t, rec.Body.String(), `"headers_observed":true`)
require.NotContains(t, rec.Body.String(), "access-token")
upstream.mu.Lock()
requests := append([]*http.Request(nil), upstream.requests...)
bodies := append([][]byte(nil), upstream.bodies...)
upstream.mu.Unlock()
require.Len(t, requests, 3)
for i, upstreamReq := range requests {
require.Equal(t, "Bearer access-token", upstreamReq.Header.Get("Authorization"))
if upstreamReq.URL.String() == xai.DefaultCLIBaseURL+"/responses" {
require.Contains(t, string(bodies[i]), `"model":"grok-4.5"`)
require.Contains(t, string(bodies[i]), `"store":false`)
}
}
require.NotNil(t, repo.updates[42])
}
func TestGrokOAuthHandlerResetQuotaReturnsUnsupported(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &grokQuotaHandlerAccountRepo{account: &service.Account{
ID: 43,
Platform: service.PlatformGrok,
Type: service.AccountTypeOAuth,
}}
quotaService := service.NewGrokQuotaService(repo, nil, nil, nil, nil)
handler := NewGrokOAuthHandler(nil, nil, quotaService, nil)
router := gin.New()
router.POST("/api/v1/admin/grok/accounts/:id/reset-quota", handler.ResetQuota)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/grok/accounts/43/reset-quota", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusNotImplemented, rec.Code)
require.Contains(t, rec.Body.String(), `"reason":"GROK_QUOTA_RESET_UNSUPPORTED"`)
require.NotContains(t, rec.Body.String(), "access-token")
}
func TestGrokOAuthHandlerRuntimeSanityDoesNotExposeSecrets(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv(xai.EnvBaseURL, "http://127.0.0.1:8080/v1?access_token=secret")
t.Setenv(xai.EnvClientID, "client-secret-like-value")
handler := NewGrokOAuthHandler(nil, nil, nil, nil)
router := gin.New()
router.GET("/api/v1/admin/grok/runtime-sanity", handler.RuntimeSanity)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/grok/runtime-sanity", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Contains(t, rec.Body.String(), `"public_gateway_scope":"responses_only"`)
require.Contains(t, rec.Body.String(), `"valid":false`)
require.NotContains(t, rec.Body.String(), "access_token")
require.NotContains(t, rec.Body.String(), "secret")
require.NotContains(t, rec.Body.String(), "client-secret-like-value")
}
func TestGrokSSOImportExpiryUsesTokenExpiryWithoutRefreshToken(t *testing.T) {
tokenExpiry := time.Now().Add(6 * time.Hour).Unix()
expiresAt, autoPause := grokSSOImportExpiry(nil, nil, &service.GrokTokenInfo{
ExpiresAt: tokenExpiry,
})
require.NotNil(t, expiresAt)
require.Equal(t, tokenExpiry, *expiresAt)
require.NotNil(t, autoPause)
require.True(t, *autoPause)
}
func TestGrokSSOImportExpiryUsesEarlierRequestedExpiryWithoutRefreshToken(t *testing.T) {
requestedExpiry := time.Now().Add(2 * time.Hour).Unix()
tokenExpiry := time.Now().Add(6 * time.Hour).Unix()
requestedAutoPause := false
expiresAt, autoPause := grokSSOImportExpiry(&requestedExpiry, &requestedAutoPause, &service.GrokTokenInfo{
ExpiresAt: tokenExpiry,
})
require.NotNil(t, expiresAt)
require.Equal(t, requestedExpiry, *expiresAt)
require.NotNil(t, autoPause)
require.True(t, *autoPause)
}
func TestGrokSSOImportExpiryPreservesRequestSettingsWithRefreshToken(t *testing.T) {
requestedExpiry := time.Now().Add(2 * time.Hour).Unix()
requestedAutoPause := false
expiresAt, autoPause := grokSSOImportExpiry(&requestedExpiry, &requestedAutoPause, &service.GrokTokenInfo{
RefreshToken: "refresh-token",
ExpiresAt: time.Now().Add(6 * time.Hour).Unix(),
})
require.Same(t, &requestedExpiry, expiresAt)
require.Same(t, &requestedAutoPause, autoPause)
}
func TestGrokSSOImportWorkerRecoversPanic(t *testing.T) {
h := &GrokOAuthHandler{}
result := h.safeCreateAccountFromSSOToken(context.Background(), GrokSSOToOAuthRequest{}, "token", 2, 3)
// Without a service, createAccountFromSSOToken would panic on nil service access.
// Recovery must convert that into a failed item and keep the worker alive.
require.False(t, result.created)
require.Equal(t, 2, result.item.Index)
require.Contains(t, result.item.Error, "internal worker panic")
}
func TestGrokOAuthHandlerReconcileDefaultsToDryRun(t *testing.T) {
gin.SetMode(gin.TestMode)
reconciler := &grokOAuthReconcilerStub{result: &service.GrokOAuthReconcileResult{
DryRun: true,
Scanned: 2,
Actionable: 1,
WouldBlock: 1,
Items: []service.GrokOAuthReconcileItem{{AccountID: 42, Reason: service.GrokOAuthReconcileReasonMissingRefreshToken, Action: service.GrokOAuthReconcileActionBlock, Outcome: service.GrokOAuthReconcileOutcomePlanned}},
NextAfterID: 0,
}}
handler := NewGrokOAuthHandler(nil, nil, nil, reconciler)
router := gin.New()
router.POST("/api/v1/admin/grok/oauth/reconcile", handler.ReconcileOAuthAccounts)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/grok/oauth/reconcile", strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, 1, reconciler.calls)
require.True(t, reconciler.input.DryRun)
require.False(t, reconciler.input.Apply)
require.Contains(t, rec.Body.String(), `"reason":"missing_refresh_token"`)
require.NotContains(t, rec.Body.String(), `"refresh_token":`)
require.NotContains(t, rec.Body.String(), `"access_token":`)
}
func TestGrokOAuthHandlerReconcileRequiresExplicitApply(t *testing.T) {
gin.SetMode(gin.TestMode)
reconciler := &grokOAuthReconcilerStub{}
handler := NewGrokOAuthHandler(nil, nil, nil, reconciler)
router := gin.New()
router.POST("/api/v1/admin/grok/oauth/reconcile", handler.ReconcileOAuthAccounts)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/grok/oauth/reconcile", strings.NewReader(`{"dry_run":false}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code)
require.Zero(t, reconciler.calls)
require.NotContains(t, rec.Body.String(), "credentials")
}
func TestGrokOAuthHandlerReconcileExplicitApply(t *testing.T) {
gin.SetMode(gin.TestMode)
reconciler := &grokOAuthReconcilerStub{result: &service.GrokOAuthReconcileResult{DryRun: false, Refreshed: 1}}
handler := NewGrokOAuthHandler(nil, nil, nil, reconciler)
router := gin.New()
router.POST("/api/v1/admin/grok/oauth/reconcile", handler.ReconcileOAuthAccounts)
rec := httptest.NewRecorder()
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}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, 1, reconciler.calls)
require.True(t, reconciler.input.Apply)
require.False(t, reconciler.input.DryRun)
require.Equal(t, int64(10), reconciler.input.AfterID)
require.Equal(t, 25, reconciler.input.Limit)
require.Equal(t, time.Hour, reconciler.input.RefreshWindow)
}