Files
sub2api/backend/internal/service/grok_oauth_service_test.go
T
jinfeijie bot ad4bf5c60d feat(grok): 支持 Web SSO 批量导入并转换为 Build OAuth
新增 Grok Web SSO → xAI Device Flow → Grok Build OAuth 导入链路,
支持管理员批量粘贴 SSO key 创建 OAuth 账号。

- 后端:ConvertSSOToBuild、ConvertFromSSO、POST /admin/grok/sso-to-oauth
- 批量:3 worker 并发,失败跳过并汇总 created/failed,worker panic recover
- 无 refresh_token 时写入 expires_at 并强制 auto_pause_on_expired
- 前端:SSO Cookie 导入入口、动态超时、中英文案、部分成功不关弹窗
- 测试:pkg/service/handler/前端超时单测;本地 Docker 真实 SSO e2e 通过
2026-07-14 01:09:07 +08:00

117 lines
3.6 KiB
Go

//go:build unit
package service
import (
"context"
"encoding/base64"
"encoding/json"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/stretchr/testify/require"
)
type grokOAuthClientStub struct {
refreshResponse *xai.TokenResponse
ssoResponse *xai.TokenResponse
exchangeCalls int
}
func (s *grokOAuthClientStub) ExchangeCode(context.Context, string, string, string, string, string) (*xai.TokenResponse, error) {
s.exchangeCalls++
return &xai.TokenResponse{}, nil
}
func (s *grokOAuthClientStub) RefreshToken(context.Context, string, string, string) (*xai.TokenResponse, error) {
return s.refreshResponse, nil
}
func (s *grokOAuthClientStub) ConvertSSOToBuild(context.Context, string, string) (*xai.TokenResponse, error) {
return s.ssoResponse, nil
}
func TestGrokOAuthServiceRefreshTokenPreservesOriginalRefreshTokenWhenNotRotated(t *testing.T) {
svc := NewGrokOAuthService(nil, &grokOAuthClientStub{
refreshResponse: &xai.TokenResponse{
AccessToken: "new-access-token",
TokenType: "Bearer",
ExpiresIn: 3600,
},
})
defer svc.Stop()
info, err := svc.RefreshToken(context.Background(), "original-refresh-token", "", "client-id")
require.NoError(t, err)
require.Equal(t, "new-access-token", info.AccessToken)
require.Equal(t, "original-refresh-token", info.RefreshToken)
require.Equal(t, "client-id", info.ClientID)
}
func TestGrokOAuthServiceExchangeCodeRequiresStateForCallbackURLAndConsumesSession(t *testing.T) {
client := &grokOAuthClientStub{}
svc := NewGrokOAuthService(nil, client)
defer svc.Stop()
auth, err := svc.GenerateAuthURL(context.Background(), nil, "")
require.NoError(t, err)
_, err = svc.ExchangeCode(context.Background(), &GrokExchangeCodeInput{
SessionID: auth.SessionID,
Code: "http://127.0.0.1:56121/callback?code=code-without-state",
})
require.Error(t, err)
require.Contains(t, err.Error(), "GROK_OAUTH_STATE_REQUIRED")
require.Zero(t, client.exchangeCalls)
_, err = svc.ExchangeCode(context.Background(), &GrokExchangeCodeInput{
SessionID: auth.SessionID,
Code: "code-with-state",
State: auth.State,
})
require.Error(t, err)
require.Contains(t, err.Error(), "GROK_OAUTH_SESSION_NOT_FOUND")
require.Zero(t, client.exchangeCalls)
}
func TestGrokOAuthServiceBuildAccountCredentialsDefaultsToSubscriptionProxy(t *testing.T) {
svc := NewGrokOAuthService(nil, &grokOAuthClientStub{})
defer svc.Stop()
credentials := svc.BuildAccountCredentials(&GrokTokenInfo{
AccessToken: "access-token",
ExpiresAt: time.Now().Add(time.Hour).Unix(),
})
require.Equal(t, xai.DefaultCLIBaseURL, credentials["base_url"])
}
func TestGrokOAuthServiceConvertFromSSOExtractsBuildClaims(t *testing.T) {
svc := NewGrokOAuthService(nil, &grokOAuthClientStub{
ssoResponse: &xai.TokenResponse{
AccessToken: makeGrokOAuthJWT(map[string]any{"sub": "user-sub", "team_id": "team-1"}),
RefreshToken: "refresh-token",
IDToken: makeGrokOAuthJWT(map[string]any{"email": "user@example.com"}),
ExpiresIn: 3600,
},
})
defer svc.Stop()
info, err := svc.ConvertFromSSO(context.Background(), "sso-token", nil)
require.NoError(t, err)
require.Equal(t, "user@example.com", info.Email)
require.Equal(t, "user-sub", info.Subject)
require.Equal(t, "team-1", info.TeamID)
credentials := svc.BuildAccountCredentials(info)
require.Equal(t, "user@example.com", credentials["email"])
require.Equal(t, "user-sub", credentials["sub"])
require.Equal(t, "team-1", credentials["team_id"])
}
func makeGrokOAuthJWT(claims map[string]any) string {
payload, _ := json.Marshal(claims)
return "header." + base64.RawURLEncoding.EncodeToString(payload) + ".signature"
}