github.com/anthropics/anthropic-sdk-go@v1.70.1/googlecloud/auth_test.go (about) 1 package googlecloud 2 3 import ( 4 "context" 5 "net/http" 6 "os" 7 "path/filepath" 8 "strings" 9 "testing" 10 11 "golang.org/x/oauth2" 12 ) 13 14 // --- resolveConfig --- 15 16 func TestResolveDerivesBaseURLFromLocationAndProject(t *testing.T) { 17 clearEnv(t) 18 rc, err := resolveConfig(ClientConfig{ 19 Project: "my-project", 20 Location: "us-central1", 21 WorkspaceID: "wrkspc_123", 22 }) 23 if err != nil { 24 t.Fatalf("unexpected error: %v", err) 25 } 26 want := "https://claude.googleapis.com/v1alpha/projects/my-project/locations/us-central1/workspaces/wrkspc_123/invoke" 27 if rc.BaseURL != want { 28 t.Errorf("derived base URL = %q, want %q", rc.BaseURL, want) 29 } 30 } 31 32 func TestResolveLocationDefaultsToGlobal(t *testing.T) { 33 clearEnv(t) 34 rc, err := resolveConfig(ClientConfig{ 35 Project: "my-project", 36 WorkspaceID: "wrkspc_123", 37 }) 38 if err != nil { 39 t.Fatalf("unexpected error: %v", err) 40 } 41 if rc.Location != "global" { 42 t.Errorf("location = %q, want default %q", rc.Location, "global") 43 } 44 want := "https://claude.googleapis.com/v1alpha/projects/my-project/locations/global/workspaces/wrkspc_123/invoke" 45 if rc.BaseURL != want { 46 t.Errorf("derived base URL = %q, want %q", rc.BaseURL, want) 47 } 48 } 49 50 func TestResolveExplicitBaseURLNeedsNoLocation(t *testing.T) { 51 clearEnv(t) 52 rc, err := resolveConfig(ClientConfig{ 53 BaseURL: "https://proxy.example.com", 54 WorkspaceID: "wrkspc_123", 55 }) 56 if err != nil { 57 t.Fatalf("unexpected error: %v", err) 58 } 59 if rc.BaseURL != "https://proxy.example.com" { 60 t.Errorf("base URL = %q, want explicit value", rc.BaseURL) 61 } 62 } 63 64 func TestResolveBaseURLFromEnvOverridesDerivation(t *testing.T) { 65 clearEnv(t) 66 t.Setenv(envBaseURL, "https://env.example.com") 67 rc, err := resolveConfig(ClientConfig{ 68 Project: "my-project", 69 Location: "us-central1", 70 WorkspaceID: "wrkspc_123", 71 }) 72 if err != nil { 73 t.Fatalf("unexpected error: %v", err) 74 } 75 if rc.BaseURL != "https://env.example.com" { 76 t.Errorf("base URL = %q, want env value", rc.BaseURL) 77 } 78 } 79 80 func TestResolveLocationFromEnv(t *testing.T) { 81 clearEnv(t) 82 t.Setenv(envLocation, "europe-west4") 83 rc, err := resolveConfig(ClientConfig{ 84 Project: "my-project", 85 WorkspaceID: "wrkspc_123", 86 }) 87 if err != nil { 88 t.Fatalf("unexpected error: %v", err) 89 } 90 if rc.Location != "europe-west4" { 91 t.Errorf("location = %q, want env value", rc.Location) 92 } 93 want := "https://claude.googleapis.com/v1alpha/projects/my-project/locations/europe-west4/workspaces/wrkspc_123/invoke" 94 if rc.BaseURL != want { 95 t.Errorf("derived base URL = %q, want %q", rc.BaseURL, want) 96 } 97 } 98 99 func TestResolveProjectFromGoogleCloudProjectEnv(t *testing.T) { 100 clearEnv(t) 101 t.Setenv("GOOGLE_CLOUD_PROJECT", "gcp-env-project") 102 rc, err := resolveConfig(ClientConfig{ 103 Location: "us-central1", 104 WorkspaceID: "wrkspc_123", 105 }) 106 if err != nil { 107 t.Fatalf("unexpected error: %v", err) 108 } 109 if rc.Project != "gcp-env-project" { 110 t.Errorf("project = %q, want GOOGLE_CLOUD_PROJECT value", rc.Project) 111 } 112 } 113 114 func TestResolveAnthropicProjectEnvBeatsGoogleCloudProject(t *testing.T) { 115 clearEnv(t) 116 t.Setenv(envProject, "anthropic-env-project") 117 t.Setenv("GOOGLE_CLOUD_PROJECT", "gcp-env-project") 118 rc, err := resolveConfig(ClientConfig{ 119 Location: "us-central1", 120 WorkspaceID: "wrkspc_123", 121 }) 122 if err != nil { 123 t.Fatalf("unexpected error: %v", err) 124 } 125 if rc.Project != "anthropic-env-project" { 126 t.Errorf("project = %q, want %s value (precedence over GOOGLE_CLOUD_PROJECT)", rc.Project, envProject) 127 } 128 } 129 130 func TestResolveExplicitConfigOverridesEnv(t *testing.T) { 131 t.Setenv(envProject, "env-project") 132 t.Setenv(envLocation, "env-location") 133 t.Setenv(envWorkspaceID, "env-workspace") 134 t.Setenv(envBaseURL, "") 135 t.Setenv("GOOGLE_CLOUD_PROJECT", "gcp-env-project") 136 rc, err := resolveConfig(ClientConfig{ 137 Project: "explicit-project", 138 Location: "us-central1", 139 WorkspaceID: "explicit-workspace", 140 }) 141 if err != nil { 142 t.Fatalf("unexpected error: %v", err) 143 } 144 if rc.Project != "explicit-project" { 145 t.Errorf("project = %q, want explicit", rc.Project) 146 } 147 if rc.Location != "us-central1" { 148 t.Errorf("location = %q, want explicit", rc.Location) 149 } 150 if rc.WorkspaceID != "explicit-workspace" { 151 t.Errorf("workspace = %q, want explicit", rc.WorkspaceID) 152 } 153 } 154 155 func TestResolveWorkspaceFromEnv(t *testing.T) { 156 clearEnv(t) 157 t.Setenv(envWorkspaceID, "env-workspace") 158 rc, err := resolveConfig(ClientConfig{ 159 BaseURL: "https://proxy.example.com", 160 }) 161 if err != nil { 162 t.Fatalf("unexpected error: %v", err) 163 } 164 if rc.WorkspaceID != "env-workspace" { 165 t.Errorf("workspace = %q, want env value", rc.WorkspaceID) 166 } 167 } 168 169 func TestResolveRequiresWorkspace(t *testing.T) { 170 clearEnv(t) 171 _, err := resolveConfig(ClientConfig{ 172 BaseURL: "https://proxy.example.com", 173 }) 174 if err == nil { 175 t.Fatal("expected error when WorkspaceID is missing") 176 } 177 } 178 179 func TestResolveSkipAuthWithExplicitBaseURLNeedsNoWorkspace(t *testing.T) { 180 clearEnv(t) 181 _, err := resolveConfig(ClientConfig{ 182 BaseURL: "https://proxy.example.com", 183 SkipAuth: true, 184 }) 185 if err != nil { 186 t.Fatalf("unexpected error with SkipAuth: %v", err) 187 } 188 } 189 190 func TestResolveSkipAuthDerivationStillRequiresWorkspace(t *testing.T) { 191 clearEnv(t) 192 // The workspace ID is part of the derived base URL, so SkipAuth alone no 193 // longer waives it — only an explicit BaseURL does. 194 _, err := resolveConfig(ClientConfig{ 195 Project: "my-project", 196 SkipAuth: true, 197 }) 198 if err == nil { 199 t.Fatal("expected error when SkipAuth derivation has no WorkspaceID") 200 } 201 for _, want := range []string{"WorkspaceID", envWorkspaceID} { 202 if !strings.Contains(err.Error(), want) { 203 t.Errorf("error = %q, want mention of %q", err, want) 204 } 205 } 206 } 207 208 func TestResolveSkipAuthDerivesURLWithWorkspace(t *testing.T) { 209 clearEnv(t) 210 rc, err := resolveConfig(ClientConfig{ 211 Project: "my-project", 212 WorkspaceID: "wrkspc_123", 213 SkipAuth: true, 214 }) 215 if err != nil { 216 t.Fatalf("unexpected error: %v", err) 217 } 218 want := "https://claude.googleapis.com/v1alpha/projects/my-project/locations/global/workspaces/wrkspc_123/invoke" 219 if rc.BaseURL != want { 220 t.Errorf("derived base URL = %q, want %q", rc.BaseURL, want) 221 } 222 } 223 224 func TestResolveSkipAuthAndTokenSourceMutuallyExclusive(t *testing.T) { 225 clearEnv(t) 226 _, err := resolveConfig(ClientConfig{ 227 BaseURL: "https://proxy.example.com", 228 SkipAuth: true, 229 TokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "tok"}), 230 }) 231 if err == nil { 232 t.Fatal("expected error when SkipAuth and TokenSource are both set") 233 } 234 } 235 236 func TestResolveEmptyEnvTreatedAsUnset(t *testing.T) { 237 // An exported-but-empty env var is the unset case, not "use empty string". 238 t.Setenv(envProject, "") 239 t.Setenv(envLocation, "") 240 t.Setenv(envWorkspaceID, "") 241 t.Setenv(envBaseURL, "") 242 t.Setenv("GOOGLE_CLOUD_PROJECT", "") 243 244 t.Run("workspace still required", func(t *testing.T) { 245 _, err := resolveConfig(ClientConfig{BaseURL: "https://proxy.example.com"}) 246 if err == nil { 247 t.Fatal("expected workspace-required error when env workspace is empty") 248 } 249 }) 250 t.Run("project not satisfied by empty env", func(t *testing.T) { 251 rc, err := resolveConfig(ClientConfig{WorkspaceID: "wrkspc_123", BaseURL: "https://proxy.example.com"}) 252 if err != nil { 253 t.Fatalf("unexpected error: %v", err) 254 } 255 if rc.Project != "" { 256 t.Errorf("Project = %q, want empty (env was empty)", rc.Project) 257 } 258 }) 259 t.Run("base URL not satisfied by empty env", func(t *testing.T) { 260 rc, err := resolveConfig(ClientConfig{Project: "p", Location: "us-central1", WorkspaceID: "wrkspc_123"}) 261 if err != nil { 262 t.Fatalf("unexpected error: %v", err) 263 } 264 if rc.BaseURL == "" { 265 t.Error("BaseURL = empty, want derived (empty env must not short-circuit derivation)") 266 } 267 }) 268 } 269 270 // --- ADC fallback --- 271 272 // adcFixture is a minimal service-account credentials file. The private key is 273 // never parsed: google.FindDefaultCredentials only reads the JSON and stores 274 // the bytes on the jwt.Config; key parsing happens at Token() time, which this 275 // test never reaches. Asserting only construction-time behavior keeps the test 276 // hermetic — exercising Token() would call the Google token endpoint. 277 const adcFixture = `{ 278 "type": "service_account", 279 "project_id": "adc-project", 280 "private_key_id": "k", 281 "private_key": "-----BEGIN PRIVATE KEY-----\nZmFrZQ==\n-----END PRIVATE KEY-----\n", 282 "client_email": "test@adc-project.iam.gserviceaccount.com", 283 "client_id": "1", 284 "token_uri": "https://oauth2.googleapis.com/token" 285 }` 286 287 func TestADCFallbackBackfillsProject(t *testing.T) { 288 clearEnv(t) 289 path := filepath.Join(t.TempDir(), "adc.json") 290 if err := os.WriteFile(path, []byte(adcFixture), 0o600); err != nil { 291 t.Fatal(err) 292 } 293 t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", path) 294 295 // No Project, no BaseURL, no TokenSource: createClientOptions must fall back 296 // to ADC, read project_id from the fixture, and derive the base URL from it. 297 // A failure to consult ADC would surface as the "no project found" error. 298 _, err := createClientOptions(context.Background(), ClientConfig{ 299 Location: "us-central1", 300 WorkspaceID: "wrkspc_123", 301 }) 302 if err != nil { 303 t.Fatalf("createClientOptions with ADC fixture failed: %v", err) 304 } 305 } 306 307 func TestADCFallbackInvalidCredentialsSurfacesError(t *testing.T) { 308 clearEnv(t) 309 path := filepath.Join(t.TempDir(), "adc.json") 310 if err := os.WriteFile(path, []byte("not-json"), 0o600); err != nil { 311 t.Fatal(err) 312 } 313 t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", path) 314 315 _, err := createClientOptions(context.Background(), ClientConfig{ 316 Location: "us-central1", 317 WorkspaceID: "wrkspc_123", 318 }) 319 if err == nil { 320 t.Fatal("expected ADC load error when GOOGLE_APPLICATION_CREDENTIALS is invalid") 321 } 322 } 323 324 // --- bearerMiddleware --- 325 326 func TestBearerMiddlewareSetsAuthHeader(t *testing.T) { 327 ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "test-token"}) 328 mw := bearerMiddleware(ts) 329 330 req, _ := http.NewRequest("POST", "https://example.com/v1/messages", nil) 331 var got string 332 _, err := mw(req, func(r *http.Request) (*http.Response, error) { 333 got = r.Header.Get("Authorization") 334 return &http.Response{StatusCode: 200, Body: http.NoBody}, nil 335 }) 336 if err != nil { 337 t.Fatalf("middleware error: %v", err) 338 } 339 if got != "Bearer test-token" { 340 t.Errorf("Authorization = %q, want %q", got, "Bearer test-token") 341 } 342 } 343 344 func TestBearerMiddlewareDoesNotOverrideExistingAuth(t *testing.T) { 345 ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "test-token"}) 346 mw := bearerMiddleware(ts) 347 348 req, _ := http.NewRequest("POST", "https://example.com/v1/messages", nil) 349 req.Header.Set("Authorization", "Bearer preexisting") 350 var got string 351 _, err := mw(req, func(r *http.Request) (*http.Response, error) { 352 got = r.Header.Get("Authorization") 353 return &http.Response{StatusCode: 200, Body: http.NoBody}, nil 354 }) 355 if err != nil { 356 t.Fatalf("middleware error: %v", err) 357 } 358 if got != "Bearer preexisting" { 359 t.Errorf("Authorization = %q, want preexisting value untouched", got) 360 } 361 } 362 363 func TestBearerMiddlewareDoesNotBufferBody(t *testing.T) { 364 ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "test-token"}) 365 mw := bearerMiddleware(ts) 366 367 // A request with a nil GetBody must reach next untouched: the bearer middleware 368 // has no reason to read or reset the body (unlike SigV4). 369 req, _ := http.NewRequest("POST", "https://example.com/v1/messages", http.NoBody) 370 req.GetBody = nil 371 _, err := mw(req, func(r *http.Request) (*http.Response, error) { 372 if r.GetBody != nil { 373 t.Error("bearer middleware should not set GetBody") 374 } 375 return &http.Response{StatusCode: 200, Body: http.NoBody}, nil 376 }) 377 if err != nil { 378 t.Fatalf("middleware error: %v", err) 379 } 380 }