vertex_service_account_test.go 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. package service
  2. import (
  3. "context"
  4. "crypto/rand"
  5. "crypto/rsa"
  6. "crypto/x509"
  7. "encoding/json"
  8. "encoding/pem"
  9. "net/http"
  10. "net/http/httptest"
  11. "strings"
  12. "testing"
  13. "time"
  14. "github.com/Wei-Shaw/sub2api/internal/pkg/servertiming"
  15. "github.com/stretchr/testify/require"
  16. "github.com/tidwall/gjson"
  17. )
  18. func TestBuildVertexGeminiURL(t *testing.T) {
  19. got, err := buildVertexGeminiURL("my-project", "us-central1", "gemini-3-pro", "streamGenerateContent", true)
  20. require.NoError(t, err)
  21. require.Equal(t, "https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/google/models/gemini-3-pro:streamGenerateContent?alt=sse", got)
  22. }
  23. func TestBuildVertexGeminiURLUsesGlobalEndpointHost(t *testing.T) {
  24. got, err := buildVertexGeminiURL("my-project", "global", "gemini-3-flash-preview", "streamGenerateContent", true)
  25. require.NoError(t, err)
  26. require.Equal(t, "https://aiplatform.googleapis.com/v1/projects/my-project/locations/global/publishers/google/models/gemini-3-flash-preview:streamGenerateContent?alt=sse", got)
  27. }
  28. func TestBuildVertexAnthropicURL(t *testing.T) {
  29. got, err := buildVertexAnthropicURL("my-project", "us-east5", "claude-sonnet-4-5@20250929", false)
  30. require.NoError(t, err)
  31. require.Equal(t, "https://us-east5-aiplatform.googleapis.com/v1/projects/my-project/locations/us-east5/publishers/anthropic/models/claude-sonnet-4-5@20250929:rawPredict", got)
  32. }
  33. func TestBuildVertexAnthropicURLUsesGlobalEndpointHost(t *testing.T) {
  34. got, err := buildVertexAnthropicURL("my-project", "global", "claude-haiku-4-5@20251001", true)
  35. require.NoError(t, err)
  36. require.Equal(t, "https://aiplatform.googleapis.com/v1/projects/my-project/locations/global/publishers/anthropic/models/claude-haiku-4-5@20251001:streamRawPredict", got)
  37. }
  38. func TestNormalizeVertexAnthropicModelID(t *testing.T) {
  39. require.Equal(t, "claude-sonnet-4-5@20250929", normalizeVertexAnthropicModelID("claude-sonnet-4-5-20250929"))
  40. require.Equal(t, "claude-sonnet-4-5@20250929", normalizeVertexAnthropicModelID("claude-sonnet-4-5@20250929"))
  41. require.Equal(t, "claude-sonnet-4-6", normalizeVertexAnthropicModelID("claude-sonnet-4-6"))
  42. }
  43. func TestBuildVertexAnthropicRequestBody(t *testing.T) {
  44. got, err := buildVertexAnthropicRequestBody([]byte(`{"model":"claude-sonnet-4-5","anthropic_version":"2023-06-01","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`))
  45. require.NoError(t, err)
  46. require.Equal(t, "", gjson.GetBytes(got, "model").String())
  47. require.Equal(t, vertexAnthropicVersion, gjson.GetBytes(got, "anthropic_version").String())
  48. require.Equal(t, int64(64), gjson.GetBytes(got, "max_tokens").Int())
  49. require.Equal(t, "hi", gjson.GetBytes(got, "messages.0.content").String())
  50. }
  51. func TestBuildVertexGeminiURLRejectsInvalidLocation(t *testing.T) {
  52. _, err := buildVertexGeminiURL("my-project", "us-central1/path", "gemini-3-pro", "generateContent", false)
  53. require.Error(t, err)
  54. require.Contains(t, err.Error(), "invalid vertex location")
  55. }
  56. func TestParseVertexServiceAccountKey(t *testing.T) {
  57. raw := `{
  58. "type": "service_account",
  59. "project_id": "vertex-proj",
  60. "private_key_id": "kid",
  61. "private_key": "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n",
  62. "client_email": "svc@vertex-proj.iam.gserviceaccount.com"
  63. }`
  64. account := &Account{
  65. Type: AccountTypeServiceAccount,
  66. Platform: PlatformGemini,
  67. Credentials: map[string]any{
  68. "service_account_json": raw,
  69. },
  70. }
  71. key, err := parseVertexServiceAccountKey(account)
  72. require.NoError(t, err)
  73. require.Equal(t, "vertex-proj", key.ProjectID)
  74. require.Equal(t, "svc@vertex-proj.iam.gserviceaccount.com", key.ClientEmail)
  75. require.Equal(t, vertexDefaultTokenURL, key.TokenURI)
  76. require.True(t, strings.Contains(key.PrivateKey, "BEGIN PRIVATE KEY"))
  77. }
  78. func TestVertexServiceAccountProxyURL(t *testing.T) {
  79. proxyID := int64(7)
  80. account := &Account{
  81. ProxyID: &proxyID,
  82. Proxy: &Proxy{
  83. Protocol: "http",
  84. Host: "proxy.example.com",
  85. Port: 8080,
  86. },
  87. }
  88. require.Equal(t, "http://proxy.example.com:8080", vertexServiceAccountProxyURL(account))
  89. require.Empty(t, vertexServiceAccountProxyURL(&Account{Proxy: account.Proxy}))
  90. require.Empty(t, vertexServiceAccountProxyURL(&Account{ProxyID: &proxyID}))
  91. }
  92. func TestVertexServiceAccountHTTPClientRecordsDependency(t *testing.T) {
  93. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  94. w.WriteHeader(http.StatusNoContent)
  95. }))
  96. defer server.Close()
  97. client, err := newVertexServiceAccountHTTPClient("")
  98. require.NoError(t, err)
  99. collector := servertiming.New(time.Now())
  100. ctx := servertiming.WithCollector(context.Background(), collector)
  101. request, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
  102. require.NoError(t, err)
  103. response, err := client.Do(request)
  104. require.NoError(t, err)
  105. require.NoError(t, response.Body.Close())
  106. require.Contains(t, collector.HeaderValue(time.Now(), "bypass"), "dep_http;dur=")
  107. }
  108. func TestExchangeVertexServiceAccountTokenUsesProxy(t *testing.T) {
  109. privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
  110. require.NoError(t, err)
  111. pemBytes := pem.EncodeToMemory(&pem.Block{
  112. Type: "RSA PRIVATE KEY",
  113. Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
  114. })
  115. seenProxyRequest := make(chan string, 1)
  116. proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  117. seenProxyRequest <- r.URL.String()
  118. require.Equal(t, "oauth2.googleapis.com", r.URL.Host)
  119. require.Equal(t, "/token", r.URL.Path)
  120. require.NoError(t, r.ParseForm())
  121. require.Equal(t, "urn:ietf:params:oauth:grant-type:jwt-bearer", r.PostForm.Get("grant_type"))
  122. w.Header().Set("Content-Type", "application/json")
  123. require.NoError(t, json.NewEncoder(w).Encode(vertexTokenResponse{
  124. AccessToken: "proxied-token",
  125. TokenType: "Bearer",
  126. ExpiresIn: 3600,
  127. }))
  128. }))
  129. defer proxy.Close()
  130. key := &vertexServiceAccountKey{
  131. ClientEmail: "svc@example.iam.gserviceaccount.com",
  132. PrivateKey: string(pemBytes),
  133. TokenURI: "http://oauth2.googleapis.com/token",
  134. }
  135. token, ttl, err := exchangeVertexServiceAccountToken(contextWithTestTimeout(t), key, proxy.URL)
  136. require.NoError(t, err)
  137. require.Equal(t, "proxied-token", token)
  138. require.Equal(t, 55*time.Minute, ttl)
  139. select {
  140. case got := <-seenProxyRequest:
  141. require.Equal(t, "http://oauth2.googleapis.com/token", got)
  142. default:
  143. t.Fatal("token request did not reach proxy")
  144. }
  145. }
  146. func contextWithTestTimeout(t *testing.T) context.Context {
  147. t.Helper()
  148. ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  149. t.Cleanup(cancel)
  150. return ctx
  151. }