| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365 |
- //go:build unit
- package service
- import (
- "context"
- "encoding/base64"
- "encoding/json"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
- "github.com/stretchr/testify/require"
- )
- type grokOAuthClientStub struct {
- refreshResponse *xai.TokenResponse
- ssoResponse *xai.TokenResponse
- loginResult *GrokPasswordLoginResult
- loginEmail string
- loginPassword string
- exchangeCalls int
- exchangeRedirectURI string
- }
- func (s *grokOAuthClientStub) ExchangeCode(_ context.Context, _, _, redirectURI, _, _ string) (*xai.TokenResponse, error) {
- s.exchangeCalls++
- s.exchangeRedirectURI = redirectURI
- return &xai.TokenResponse{AccessToken: "access-token"}, nil
- }
- func (s *grokOAuthClientStub) RefreshToken(context.Context, string, string, string) (*xai.TokenResponse, error) {
- return s.refreshResponse, nil
- }
- func (s *grokOAuthClientStub) LoginWithPassword(_ context.Context, email, password, _ string) (*GrokPasswordLoginResult, error) {
- s.loginEmail = email
- s.loginPassword = password
- return s.loginResult, 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 TestGrokOAuthServiceRefreshTokenRejectsEmptyUpstreamResponse(t *testing.T) {
- svc := NewGrokOAuthService(nil, &grokOAuthClientStub{})
- defer svc.Stop()
- require.NotPanics(t, func() {
- info, err := svc.RefreshToken(context.Background(), "refresh-token", "", "client-id")
- require.Nil(t, info)
- require.Error(t, err)
- require.Contains(t, err.Error(), "GROK_OAUTH_INVALID_TOKEN_RESPONSE")
- })
- }
- func TestGrokOAuthServiceExchangeCodeConsumesOnlyAfterValidation(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.NoError(t, err)
- require.Equal(t, 1, client.exchangeCalls)
- _, err = svc.ExchangeCode(context.Background(), &GrokExchangeCodeInput{
- SessionID: auth.SessionID,
- Code: "replayed-code",
- State: auth.State,
- })
- require.Error(t, err)
- require.Contains(t, err.Error(), "GROK_OAUTH_SESSION_NOT_FOUND")
- require.Equal(t, 1, client.exchangeCalls)
- }
- func TestGrokOAuthServiceExchangeCodeRejectsMissingClientWithoutConsumingSession(t *testing.T) {
- svc := NewGrokOAuthService(nil, nil)
- defer svc.Stop()
- auth, err := svc.GenerateAuthURL(context.Background(), nil, "")
- require.NoError(t, err)
- _, err = svc.ExchangeCode(context.Background(), &GrokExchangeCodeInput{
- SessionID: auth.SessionID,
- Code: "code",
- State: auth.State,
- })
- require.Error(t, err)
- require.Contains(t, err.Error(), "GROK_OAUTH_CLIENT_NOT_CONFIGURED")
- _, ok := svc.sessionStore.Get(auth.SessionID)
- require.True(t, ok)
- }
- func TestGrokOAuthServiceExchangeCodeRequiresStateForBareCode(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: "bare-authorization-code",
- })
- require.Error(t, err)
- require.Contains(t, err.Error(), "GROK_OAUTH_STATE_REQUIRED")
- require.Zero(t, client.exchangeCalls)
- _, ok := svc.sessionStore.Get(auth.SessionID)
- require.True(t, ok)
- }
- func TestGrokOAuthServiceExchangeCodeRejectsRedirectURIOverride(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: "authorization-code",
- State: auth.State,
- RedirectURI: "http://127.0.0.1:9999/callback",
- })
- require.Error(t, err)
- require.Contains(t, err.Error(), "GROK_OAUTH_REDIRECT_URI_MISMATCH")
- require.Zero(t, client.exchangeCalls)
- _, err = svc.ExchangeCode(context.Background(), &GrokExchangeCodeInput{
- SessionID: auth.SessionID,
- Code: "authorization-code",
- State: auth.State,
- RedirectURI: xai.DefaultRedirectURI,
- })
- require.NoError(t, err)
- require.Equal(t, xai.DefaultRedirectURI, client.exchangeRedirectURI)
- }
- func TestGrokOAuthServiceExternalFlowsRejectMissingClient(t *testing.T) {
- svc := NewGrokOAuthService(nil, nil)
- defer svc.Stop()
- _, err := svc.RefreshToken(context.Background(), "refresh-token", "", "")
- require.Error(t, err)
- require.Contains(t, err.Error(), "GROK_OAUTH_CLIENT_NOT_CONFIGURED")
- _, err = svc.ValidateSSOToken(context.Background(), "sso-token", nil)
- require.Error(t, err)
- require.Contains(t, err.Error(), "GROK_OAUTH_CLIENT_NOT_CONFIGURED")
- }
- 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", "tier": 5}),
- 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)
- require.Equal(t, "supergrok_heavy", info.SubscriptionTier)
- 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"])
- require.Equal(t, "supergrok_heavy", credentials["subscription_tier"])
- require.NotContains(t, credentials, "sso_token")
- }
- func TestGrokOAuthServiceRefreshAccountTokenOverwritesStaleTierFromNewJWT(t *testing.T) {
- svc := NewGrokOAuthService(nil, &grokOAuthClientStub{
- refreshResponse: &xai.TokenResponse{
- AccessToken: makeGrokOAuthJWT(map[string]any{"sub": "user-sub", "tier": 0}),
- TokenType: "Bearer",
- ExpiresIn: 3600,
- },
- })
- defer svc.Stop()
- account := &Account{
- Platform: PlatformGrok,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "refresh_token": "refresh-token",
- "client_id": "client-id",
- "subscription_tier": "supergrok_heavy",
- },
- }
- info, err := svc.RefreshAccountToken(context.Background(), account)
- require.NoError(t, err)
- require.Equal(t, "free", info.SubscriptionTier)
- credentials := svc.BuildAccountCredentials(info)
- require.Equal(t, "free", credentials["subscription_tier"])
- }
- func TestGrokOAuthServiceRefreshAccountTokenIgnoresIDTokenTierWhenAccessTokenHasNone(t *testing.T) {
- svc := NewGrokOAuthService(nil, &grokOAuthClientStub{
- refreshResponse: &xai.TokenResponse{
- AccessToken: "opaque-access-token",
- IDToken: makeGrokOAuthJWT(map[string]any{"tier": 5}),
- TokenType: "Bearer",
- ExpiresIn: 3600,
- },
- })
- defer svc.Stop()
- account := &Account{
- Platform: PlatformGrok,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "refresh_token": "refresh-token",
- "subscription_tier": "supergrok_lite",
- },
- }
- info, err := svc.RefreshAccountToken(context.Background(), account)
- require.NoError(t, err)
- require.Equal(t, "supergrok_lite", info.SubscriptionTier)
- }
- func TestGrokOAuthServiceRefreshAccountTokenKeepsStoredTierWhenJWTHasNoClaim(t *testing.T) {
- svc := NewGrokOAuthService(nil, &grokOAuthClientStub{
- refreshResponse: &xai.TokenResponse{
- AccessToken: "opaque-access-token",
- TokenType: "Bearer",
- ExpiresIn: 3600,
- },
- })
- defer svc.Stop()
- account := &Account{
- Platform: PlatformGrok,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "refresh_token": "refresh-token",
- "subscription_tier": "supergrok_lite",
- },
- }
- info, err := svc.RefreshAccountToken(context.Background(), account)
- require.NoError(t, err)
- require.Equal(t, "supergrok_lite", info.SubscriptionTier)
- }
- func TestGrokOAuthServiceValidateSSOTokenReturnsOAuthTokensWithoutPersistingSSO(t *testing.T) {
- svc := NewGrokOAuthService(nil, &grokOAuthClientStub{
- ssoResponse: &xai.TokenResponse{
- AccessToken: "access-from-sso",
- RefreshToken: "refresh-from-sso",
- TokenType: "Bearer",
- ExpiresIn: 3600,
- },
- })
- defer svc.Stop()
- info, err := svc.ValidateSSOToken(context.Background(), "sso-token", nil)
- require.NoError(t, err)
- require.Equal(t, "access-from-sso", info.AccessToken)
- require.Equal(t, "refresh-from-sso", info.RefreshToken)
- creds := svc.BuildAccountCredentials(info)
- require.NotContains(t, creds, "sso_token")
- require.NotContains(t, creds, "password")
- }
- func TestGrokOAuthServiceAuthorizePasswordUsesLoginThenSSOAuthorize(t *testing.T) {
- client := &grokOAuthClientStub{
- loginResult: &GrokPasswordLoginResult{
- Email: "user@example.com",
- SSOToken: "password-derived-sso",
- },
- ssoResponse: &xai.TokenResponse{
- AccessToken: "access-from-password",
- RefreshToken: "refresh-from-password",
- ExpiresIn: 3600,
- },
- }
- cfg := &config.Config{}
- cfg.Gateway.Grok.PasswordAuthEnabled = true
- svc := NewGrokOAuthService(nil, client, cfg)
- defer svc.Stop()
- require.True(t, svc.GetCapabilities().PasswordAuthEnabled)
- info, err := svc.AuthorizePassword(context.Background(), " user@example.com ", " super-secret ", nil)
- require.NoError(t, err)
- require.Equal(t, "user@example.com", info.Email)
- require.Equal(t, "access-from-password", info.AccessToken)
- creds := svc.BuildAccountCredentials(info)
- require.NotContains(t, creds, "password")
- require.NotContains(t, creds, "sso_token")
- require.Equal(t, "user@example.com", client.loginEmail)
- require.Equal(t, " super-secret ", client.loginPassword)
- }
- func TestGrokOAuthServiceAuthorizePasswordDisabledByDefault(t *testing.T) {
- client := &grokOAuthClientStub{}
- svc := NewGrokOAuthService(nil, client)
- defer svc.Stop()
- require.False(t, svc.GetCapabilities().PasswordAuthEnabled)
- _, err := svc.AuthorizePassword(context.Background(), "user@example.com", "secret", nil)
- require.Error(t, err)
- require.Contains(t, err.Error(), "GROK_OAUTH_PASSWORD_AUTH_DISABLED")
- require.Empty(t, client.loginEmail)
- }
- func makeGrokOAuthJWT(claims map[string]any) string {
- payload, _ := json.Marshal(claims)
- return "header." + base64.RawURLEncoding.EncodeToString(payload) + ".signature"
- }
|