github.com/anthropics/anthropic-sdk-go@v1.70.1/googlecloud/googlecloud_test.go (about) 1 package googlecloud 2 3 import ( 4 "context" 5 "encoding/json" 6 "net/http" 7 "net/http/httptest" 8 "os" 9 "path/filepath" 10 "reflect" 11 "strings" 12 "testing" 13 14 "golang.org/x/oauth2" 15 16 "github.com/anthropics/anthropic-sdk-go" 17 ) 18 19 func clearEnv(t *testing.T) { 20 t.Helper() 21 t.Setenv(envProject, "") 22 t.Setenv(envLocation, "") 23 t.Setenv(envWorkspaceID, "") 24 t.Setenv(envBaseURL, "") 25 t.Setenv("GOOGLE_CLOUD_PROJECT", "") 26 } 27 28 type capturedRequest struct { 29 Headers http.Header 30 URL string 31 } 32 33 func messagesHandler(captured *capturedRequest) http.HandlerFunc { 34 return func(w http.ResponseWriter, r *http.Request) { 35 captured.Headers = r.Header.Clone() 36 captured.URL = r.URL.String() 37 w.Header().Set("Content-Type", "application/json") 38 json.NewEncoder(w).Encode(map[string]any{ 39 "id": "msg_test", 40 "type": "message", 41 "role": "assistant", 42 "content": []map[string]any{{"type": "text", "text": "hi"}}, 43 "model": "claude-sonnet-4-5", 44 "stop_reason": "end_turn", 45 "stop_sequence": nil, 46 "usage": map[string]any{"input_tokens": 1, "output_tokens": 1}, 47 }) 48 } 49 } 50 51 // newTestClient stands up an httptest server and a client pointed at it, using a 52 // static token source so no real Google call is made. 53 func newTestClient(t *testing.T, cfg ClientConfig) (*Client, *capturedRequest) { 54 t.Helper() 55 var captured capturedRequest 56 server := httptest.NewServer(messagesHandler(&captured)) 57 t.Cleanup(server.Close) 58 59 cfg.BaseURL = server.URL 60 if cfg.TokenSource == nil && !cfg.SkipAuth { 61 cfg.TokenSource = oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "test-token"}) 62 } 63 client, err := NewClient(context.Background(), cfg) 64 if err != nil { 65 t.Fatalf("NewClient failed: %v", err) 66 } 67 return client, &captured 68 } 69 70 func sendTestRequest(t *testing.T, client *Client) { 71 t.Helper() 72 _, err := client.Messages.New(context.Background(), anthropic.MessageNewParams{ 73 Model: "claude-sonnet-4-5", 74 MaxTokens: 1, 75 Messages: []anthropic.MessageParam{ 76 anthropic.NewUserMessage(anthropic.NewTextBlock("hi")), 77 }, 78 }) 79 if err != nil { 80 t.Fatalf("request failed: %v", err) 81 } 82 } 83 84 // --- Service surface parity --- 85 86 // excludedServices are anthropic.Client fields intentionally NOT mirrored here. 87 // Completions is the deprecated text-completions API. 88 var excludedServices = map[string]bool{"Completions": true} 89 90 func TestClientServicesMatchAnthropicClient(t *testing.T) { 91 gcType := reflect.TypeFor[Client]() 92 anthropicType := reflect.TypeFor[anthropic.Client]() 93 94 // Every non-excluded base service must be present here with the same type — 95 // catches the base client growing a resource this mirror would silently miss. 96 for i := 0; i < anthropicType.NumField(); i++ { 97 field := anthropicType.Field(i) 98 if excludedServices[field.Name] { 99 if _, ok := gcType.FieldByName(field.Name); ok { 100 t.Errorf("Client exposes %q, which is intentionally excluded", field.Name) 101 } 102 continue 103 } 104 gcField, ok := gcType.FieldByName(field.Name) 105 if !ok { 106 t.Errorf("Client is missing field %q (type %s) from anthropic.Client", field.Name, field.Type) 107 continue 108 } 109 if gcField.Type != field.Type { 110 t.Errorf("Client.%s has type %s, expected %s", field.Name, gcField.Type, field.Type) 111 } 112 } 113 114 // Conversely, every field here must exist on the base client (no stray fields). 115 for i := 0; i < gcType.NumField(); i++ { 116 field := gcType.Field(i) 117 if _, ok := anthropicType.FieldByName(field.Name); !ok { 118 t.Errorf("Client has field %q not present on anthropic.Client", field.Name) 119 } 120 } 121 } 122 123 // --- Auth header wiring --- 124 125 func TestBearerModeHeaders(t *testing.T) { 126 clearEnv(t) 127 t.Setenv("ANTHROPIC_API_KEY", "") 128 129 client, captured := newTestClient(t, ClientConfig{ 130 WorkspaceID: "wrkspc_abc", 131 TokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "tok-123"}), 132 }) 133 sendTestRequest(t, client) 134 135 if got := captured.Headers.Get("Authorization"); got != "Bearer tok-123" { 136 t.Errorf("Authorization = %q, want %q", got, "Bearer tok-123") 137 } 138 // The workspace id travels in the URL path only — the gateway mints the 139 // header itself and rejects client-sent canonical-cased names. 140 if got := captured.Headers.Get("Anthropic-Workspace-Id"); got != "" { 141 t.Errorf("expected no anthropic-workspace-id header, got %q", got) 142 } 143 if got := captured.Headers.Get("X-Api-Key"); got != "" { 144 t.Errorf("expected no x-api-key, got %q", got) 145 } 146 } 147 148 func TestWorkspaceIDFromEnv(t *testing.T) { 149 clearEnv(t) 150 t.Setenv(envWorkspaceID, "env-workspace") 151 152 client, captured := newTestClient(t, ClientConfig{}) 153 sendTestRequest(t, client) 154 155 // An env-resolved workspace id feeds URL derivation only — never a header. 156 if got := captured.Headers.Get("Anthropic-Workspace-Id"); got != "" { 157 t.Errorf("expected no anthropic-workspace-id header, got %q", got) 158 } 159 } 160 161 // --- Validation --- 162 163 func TestNewClientRequiresWorkspaceID(t *testing.T) { 164 clearEnv(t) 165 _, err := NewClient(context.Background(), ClientConfig{ 166 BaseURL: "https://proxy.example.com", 167 TokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "tok"}), 168 }) 169 if err == nil { 170 t.Fatal("expected error when WorkspaceID is missing") 171 } 172 } 173 174 func TestExplicitBaseURLWithoutLocation(t *testing.T) { 175 clearEnv(t) 176 _, err := NewClient(context.Background(), ClientConfig{ 177 BaseURL: "https://proxy.example.com", 178 WorkspaceID: "wrkspc_abc", 179 TokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "tok"}), 180 }) 181 if err != nil { 182 t.Fatalf("expected no error with explicit BaseURL and no Location, got: %v", err) 183 } 184 } 185 186 // --- SkipAuth --- 187 188 func TestSkipAuthNoWorkspaceRequired(t *testing.T) { 189 clearEnv(t) 190 _, err := NewClient(context.Background(), ClientConfig{ 191 BaseURL: "https://proxy.example.com", 192 SkipAuth: true, 193 }) 194 if err != nil { 195 t.Fatalf("expected no error with SkipAuth, got: %v", err) 196 } 197 } 198 199 func TestSkipAuthNoAuthHeaders(t *testing.T) { 200 clearEnv(t) 201 t.Setenv("ANTHROPIC_API_KEY", "") 202 203 client, captured := newTestClient(t, ClientConfig{SkipAuth: true}) 204 sendTestRequest(t, client) 205 206 if captured.Headers.Get("Authorization") != "" { 207 t.Error("expected no Authorization header with SkipAuth") 208 } 209 if captured.Headers.Get("Anthropic-Workspace-Id") != "" { 210 t.Error("expected no anthropic-workspace-id header with SkipAuth") 211 } 212 } 213 214 func TestSkipAuthRejectsProvidedCreds(t *testing.T) { 215 clearEnv(t) 216 _, err := NewClient(context.Background(), ClientConfig{ 217 BaseURL: "https://proxy.example.com", 218 TokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "tok"}), 219 SkipAuth: true, 220 }) 221 if err == nil { 222 t.Fatal("expected error when SkipAuth and TokenSource are both set") 223 } 224 if !strings.Contains(err.Error(), "mutually exclusive") { 225 t.Errorf("error = %q, want mention of mutual exclusivity", err) 226 } 227 } 228 229 // --- base-SDK env isolation --- 230 // 231 // The googlecloud client constructs services directly (no DefaultClientOptions), 232 // so the base SDK's env-var credential chain — ANTHROPIC_API_KEY, 233 // ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, ANTHROPIC_PROFILE/CONFIG_DIR, 234 // ANTHROPIC_CUSTOM_HEADERS — must contribute nothing to the request. 235 236 func TestDoesNotLeakAnthropicAPIKey(t *testing.T) { 237 clearEnv(t) 238 t.Setenv("ANTHROPIC_API_KEY", "should-not-appear") 239 240 client, captured := newTestClient(t, ClientConfig{ 241 WorkspaceID: "wrkspc_abc", 242 TokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "tok"}), 243 }) 244 sendTestRequest(t, client) 245 246 if got := captured.Headers.Get("X-Api-Key"); got != "" { 247 t.Errorf("expected no x-api-key header, got %q", got) 248 } 249 } 250 251 func TestDoesNotLeakAnthropicAuthToken(t *testing.T) { 252 clearEnv(t) 253 t.Setenv("ANTHROPIC_AUTH_TOKEN", "env-auth-token") 254 255 client, captured := newTestClient(t, ClientConfig{ 256 WorkspaceID: "wrkspc_abc", 257 TokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "ts-token"}), 258 }) 259 sendTestRequest(t, client) 260 261 if got := captured.Headers.Values("Authorization"); len(got) != 1 || got[0] != "Bearer ts-token" { 262 t.Errorf("Authorization = %q, want exactly [%q] (TokenSource value, not ANTHROPIC_AUTH_TOKEN)", got, "Bearer ts-token") 263 } 264 if got := captured.Headers.Get("X-Api-Key"); got != "" { 265 t.Errorf("expected no x-api-key header, got %q", got) 266 } 267 } 268 269 func TestDoesNotLeakAnthropicBaseURL(t *testing.T) { 270 clearEnv(t) 271 t.Setenv("ANTHROPIC_BASE_URL", "https://should-not-be-used.example.com") 272 273 client, captured := newTestClient(t, ClientConfig{WorkspaceID: "wrkspc_abc"}) 274 sendTestRequest(t, client) 275 276 // The request reached the gateway test server (captured is populated), not 277 // the env URL — newTestClient's BaseURL is the only URL the client sees. 278 if captured.URL == "" { 279 t.Fatal("request never reached the gateway test server") 280 } 281 if got := captured.Headers.Get("Authorization"); got != "Bearer test-token" { 282 t.Errorf("Authorization = %q, want gateway bearer", got) 283 } 284 } 285 286 func TestDoesNotLeakAnthropicProfile(t *testing.T) { 287 clearEnv(t) 288 289 // A real-shaped profile under ANTHROPIC_CONFIG_DIR/configs/<profile>.json with a 290 // base_url and a credentials file — neither must surface on the wire. 291 dir := t.TempDir() 292 if err := os.MkdirAll(filepath.Join(dir, "configs"), 0o755); err != nil { 293 t.Fatal(err) 294 } 295 if err := os.MkdirAll(filepath.Join(dir, "credentials"), 0o700); err != nil { 296 t.Fatal(err) 297 } 298 profile := `{"version":"1.0","base_url":"https://profile.example.com","authentication":{"type":"user_oauth","client_id":"cli_x"}}` 299 if err := os.WriteFile(filepath.Join(dir, "configs", "leaky.json"), []byte(profile), 0o644); err != nil { 300 t.Fatal(err) 301 } 302 creds := `{"access_token":"profile-access-token","token_type":"Bearer"}` 303 if err := os.WriteFile(filepath.Join(dir, "credentials", "leaky.json"), []byte(creds), 0o600); err != nil { 304 t.Fatal(err) 305 } 306 t.Setenv("ANTHROPIC_CONFIG_DIR", dir) 307 t.Setenv("ANTHROPIC_PROFILE", "leaky") 308 309 client, captured := newTestClient(t, ClientConfig{ 310 WorkspaceID: "wrkspc_abc", 311 TokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "ts-token"}), 312 }) 313 sendTestRequest(t, client) 314 315 if captured.URL == "" { 316 t.Fatal("request never reached the gateway test server") 317 } 318 if got := captured.Headers.Values("Authorization"); len(got) != 1 || got[0] != "Bearer ts-token" { 319 t.Errorf("Authorization = %q, want exactly [%q] (TokenSource value, not profile token)", got, "Bearer ts-token") 320 } 321 if got := captured.Headers.Get("X-Api-Key"); got != "" { 322 t.Errorf("expected no x-api-key header, got %q", got) 323 } 324 } 325 326 func TestDoesNotLeakAnthropicCustomHeaders(t *testing.T) { 327 clearEnv(t) 328 // One benign header and one Authorization override; neither is honored 329 // because the googlecloud client never walks DefaultClientOptions. The 330 // bearer middleware's only-if-absent rule is therefore not exercised here — 331 // the env header simply never reaches the request. 332 t.Setenv("ANTHROPIC_CUSTOM_HEADERS", "X-Benign: from-env\nAuthorization: Bearer env-token") 333 334 client, captured := newTestClient(t, ClientConfig{ 335 WorkspaceID: "wrkspc_abc", 336 TokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "ts-token"}), 337 }) 338 sendTestRequest(t, client) 339 340 if got := captured.Headers.Get("X-Benign"); got != "" { 341 t.Errorf("X-Benign = %q, want absent (ANTHROPIC_CUSTOM_HEADERS not honored)", got) 342 } 343 if got := captured.Headers.Values("Authorization"); len(got) != 1 || got[0] != "Bearer ts-token" { 344 t.Errorf("Authorization = %q, want exactly [%q]", got, "Bearer ts-token") 345 } 346 } 347 348 // --- ADC consultation --- 349 350 func TestExplicitTokenSourceSkipsADC(t *testing.T) { 351 clearEnv(t) 352 // google.FindDefaultCredentials reads GOOGLE_APPLICATION_CREDENTIALS first; a 353 // path that exists but is not valid JSON makes it fail. With an explicit 354 // TokenSource the client must never reach that call — the request succeeds. 355 bad := filepath.Join(t.TempDir(), "broken.json") 356 if err := os.WriteFile(bad, []byte("not-json"), 0o600); err != nil { 357 t.Fatal(err) 358 } 359 t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", bad) 360 361 client, captured := newTestClient(t, ClientConfig{ 362 WorkspaceID: "wrkspc_abc", 363 TokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "ts-token"}), 364 }) 365 sendTestRequest(t, client) 366 367 if got := captured.Headers.Get("Authorization"); got != "Bearer ts-token" { 368 t.Errorf("Authorization = %q, want %q", got, "Bearer ts-token") 369 } 370 }