github.com/anthropics/anthropic-sdk-go@v1.70.1/aws/aws_test.go (about) 1 package aws 2 3 import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "io" 8 "net/http" 9 "net/http/httptest" 10 "reflect" 11 "strings" 12 "testing" 13 14 awssdk "github.com/aws/aws-sdk-go-v2/aws" 15 v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" 16 "github.com/aws/aws-sdk-go-v2/credentials" 17 18 "github.com/anthropics/anthropic-sdk-go" 19 "github.com/anthropics/anthropic-sdk-go/internal/awsauth" 20 ) 21 22 func makeStaticConfig(region string) awssdk.Config { 23 return awssdk.Config{ 24 Region: region, 25 Credentials: credentials.StaticCredentialsProvider{ 26 Value: awssdk.Credentials{ 27 AccessKeyID: "test-access-key", 28 SecretAccessKey: "test-secret-key", 29 }, 30 }, 31 } 32 } 33 34 type capturedRequest struct { 35 Headers http.Header 36 URL string 37 } 38 39 func messagesHandler(captured *capturedRequest) http.HandlerFunc { 40 return func(w http.ResponseWriter, r *http.Request) { 41 captured.Headers = r.Header.Clone() 42 captured.URL = r.URL.String() 43 w.Header().Set("Content-Type", "application/json") 44 json.NewEncoder(w).Encode(map[string]any{ 45 "id": "msg_test", 46 "type": "message", 47 "role": "assistant", 48 "content": []map[string]any{{"type": "text", "text": "hi"}}, 49 "model": "claude-sonnet-4-6-20250514", 50 "stop_reason": "end_turn", 51 "stop_sequence": nil, 52 "usage": map[string]any{"input_tokens": 1, "output_tokens": 1}, 53 }) 54 } 55 } 56 57 func newTestClient(t *testing.T, cfg ClientConfig) (*Client, *capturedRequest) { 58 t.Helper() 59 var captured capturedRequest 60 server := httptest.NewServer(messagesHandler(&captured)) 61 t.Cleanup(server.Close) 62 63 cfg.BaseURL = server.URL 64 client, err := NewClient(context.Background(), cfg) 65 if err != nil { 66 t.Fatalf("NewClient failed: %v", err) 67 } 68 return client, &captured 69 } 70 71 func sendTestRequest(t *testing.T, client *Client) { 72 t.Helper() 73 _, err := client.Messages.New(context.Background(), anthropic.MessageNewParams{ 74 Model: "claude-sonnet-4-6-20250514", 75 MaxTokens: 1, 76 Messages: []anthropic.MessageParam{ 77 anthropic.NewUserMessage(anthropic.NewTextBlock("hi")), 78 }, 79 }) 80 if err != nil { 81 t.Fatalf("request failed: %v", err) 82 } 83 } 84 85 // --- Service sync test --- 86 87 func TestClientServicesMatchAnthropicClient(t *testing.T) { 88 awsType := reflect.TypeOf(Client{}) 89 anthropicType := reflect.TypeOf(anthropic.Client{}) 90 91 for i := 0; i < anthropicType.NumField(); i++ { 92 field := anthropicType.Field(i) 93 awsField, ok := awsType.FieldByName(field.Name) 94 if !ok { 95 t.Errorf("aws.Client is missing field %q (type %s) from anthropic.Client", field.Name, field.Type) 96 continue 97 } 98 if awsField.Type != field.Type { 99 t.Errorf("aws.Client.%s has type %s, expected %s", field.Name, awsField.Type, field.Type) 100 } 101 } 102 } 103 104 // --- Validation tests --- 105 106 func TestNewClientRequiresWorkspaceID(t *testing.T) { 107 t.Setenv("ANTHROPIC_AWS_WORKSPACE_ID", "") 108 t.Setenv("ANTHROPIC_AWS_API_KEY", "") 109 110 _, err := NewClient(context.Background(), ClientConfig{ 111 AWSRegion: "us-east-1", 112 AWSAccessKey: "key", 113 AWSSecretAccessKey: "secret", 114 }) 115 if err == nil { 116 t.Fatal("expected error when WorkspaceID is missing") 117 } 118 } 119 120 func TestNewClientRequiresBaseURLOrRegion(t *testing.T) { 121 t.Setenv("AWS_REGION", "") 122 t.Setenv("ANTHROPIC_AWS_BASE_URL", "") 123 t.Setenv("ANTHROPIC_AWS_WORKSPACE_ID", "default") 124 125 _, err := NewClient(context.Background(), ClientConfig{ 126 APIKey: "my-key", 127 }) 128 if err == nil { 129 t.Fatal("expected error when neither base URL nor region is available") 130 } 131 } 132 133 func TestNewClientRegionRequiredForSigV4(t *testing.T) { 134 t.Setenv("AWS_REGION", "") 135 t.Setenv("ANTHROPIC_AWS_WORKSPACE_ID", "default") 136 t.Setenv("ANTHROPIC_AWS_API_KEY", "") 137 138 _, err := NewClient(context.Background(), ClientConfig{ 139 AWSAccessKey: "key", 140 AWSSecretAccessKey: "secret", 141 }) 142 if err == nil { 143 t.Fatal("expected error when region is missing and SigV4 is used") 144 } 145 } 146 147 // --- API key mode tests --- 148 149 func TestAPIKeyModeHeaders(t *testing.T) { 150 t.Setenv("ANTHROPIC_AWS_WORKSPACE_ID", "") 151 t.Setenv("ANTHROPIC_AWS_API_KEY", "") 152 153 client, captured := newTestClient(t, ClientConfig{ 154 APIKey: "my-api-key", 155 AWSRegion: "us-east-1", 156 WorkspaceID: "ws-123", 157 }) 158 sendTestRequest(t, client) 159 160 if got := captured.Headers.Get("X-Api-Key"); got != "my-api-key" { 161 t.Errorf("expected x-api-key %q, got %q", "my-api-key", got) 162 } 163 if got := captured.Headers.Get("Anthropic-Workspace-Id"); got != "ws-123" { 164 t.Errorf("expected anthropic-workspace-id %q, got %q", "ws-123", got) 165 } 166 if captured.Headers.Get("Authorization") != "" { 167 t.Error("expected no Authorization header in API key mode") 168 } 169 } 170 171 func TestAPIKeyFromEnvHeaders(t *testing.T) { 172 t.Setenv("ANTHROPIC_AWS_API_KEY", "env-api-key") 173 t.Setenv("AWS_REGION", "us-east-1") 174 t.Setenv("ANTHROPIC_AWS_WORKSPACE_ID", "") 175 176 client, captured := newTestClient(t, ClientConfig{ 177 WorkspaceID: "ws-456", 178 }) 179 sendTestRequest(t, client) 180 181 if got := captured.Headers.Get("X-Api-Key"); got != "env-api-key" { 182 t.Errorf("expected x-api-key %q, got %q", "env-api-key", got) 183 } 184 } 185 186 func TestExplicitAPIKeyOverridesEnv(t *testing.T) { 187 t.Setenv("ANTHROPIC_AWS_API_KEY", "env-api-key") 188 t.Setenv("AWS_REGION", "us-east-1") 189 190 client, captured := newTestClient(t, ClientConfig{ 191 APIKey: "explicit-key", 192 WorkspaceID: "ws-789", 193 }) 194 sendTestRequest(t, client) 195 196 if got := captured.Headers.Get("X-Api-Key"); got != "explicit-key" { 197 t.Errorf("expected x-api-key %q, got %q", "explicit-key", got) 198 } 199 } 200 201 // --- SigV4 mode tests --- 202 203 func TestSigV4ModeHeaders(t *testing.T) { 204 t.Setenv("ANTHROPIC_AWS_WORKSPACE_ID", "") 205 t.Setenv("ANTHROPIC_AWS_API_KEY", "") 206 t.Setenv("ANTHROPIC_API_KEY", "") 207 208 client, captured := newTestClient(t, ClientConfig{ 209 AWSRegion: "us-east-1", 210 AWSAccessKey: "test-access-key", 211 AWSSecretAccessKey: "test-secret-key", 212 WorkspaceID: "ws-sigv4", 213 }) 214 sendTestRequest(t, client) 215 216 auth := captured.Headers.Get("Authorization") 217 if auth == "" { 218 t.Fatal("expected Authorization header in SigV4 mode") 219 } 220 if !strings.HasPrefix(auth, "AWS4-HMAC-SHA256") { 221 t.Errorf("expected AWS4 signature, got: %s", auth) 222 } 223 if !strings.Contains(auth, defaultServiceName) { 224 t.Errorf("expected service name %q in Authorization, got: %s", defaultServiceName, auth) 225 } 226 if captured.Headers.Get("X-Amz-Date") == "" { 227 t.Error("expected X-Amz-Date header in SigV4 mode") 228 } 229 if got := captured.Headers.Get("Anthropic-Workspace-Id"); got != "ws-sigv4" { 230 t.Errorf("expected anthropic-workspace-id %q, got %q", "ws-sigv4", got) 231 } 232 } 233 234 func TestSigV4WithSessionToken(t *testing.T) { 235 t.Setenv("ANTHROPIC_AWS_WORKSPACE_ID", "") 236 t.Setenv("ANTHROPIC_AWS_API_KEY", "") 237 t.Setenv("ANTHROPIC_API_KEY", "") 238 239 client, captured := newTestClient(t, ClientConfig{ 240 AWSRegion: "us-east-1", 241 AWSAccessKey: "test-access-key", 242 AWSSecretAccessKey: "test-secret-key", 243 AWSSessionToken: "test-session-token", 244 WorkspaceID: "ws-session", 245 }) 246 sendTestRequest(t, client) 247 248 if captured.Headers.Get("X-Amz-Security-Token") != "test-session-token" { 249 t.Errorf("expected X-Amz-Security-Token %q, got %q", "test-session-token", captured.Headers.Get("X-Amz-Security-Token")) 250 } 251 } 252 253 // --- Workspace ID tests --- 254 255 func TestWorkspaceIDFromEnv(t *testing.T) { 256 t.Setenv("ANTHROPIC_AWS_WORKSPACE_ID", "env-workspace") 257 t.Setenv("ANTHROPIC_AWS_API_KEY", "test-key") 258 t.Setenv("AWS_REGION", "us-east-1") 259 260 client, captured := newTestClient(t, ClientConfig{}) 261 sendTestRequest(t, client) 262 263 if got := captured.Headers.Get("Anthropic-Workspace-Id"); got != "env-workspace" { 264 t.Errorf("expected anthropic-workspace-id %q, got %q", "env-workspace", got) 265 } 266 } 267 268 func TestPerRequestWorkspaceIDOverridesClientConfig(t *testing.T) { 269 t.Setenv("ANTHROPIC_AWS_WORKSPACE_ID", "") 270 t.Setenv("ANTHROPIC_AWS_API_KEY", "") 271 272 client, captured := newTestClient(t, ClientConfig{ 273 APIKey: "my-api-key", 274 AWSRegion: "us-east-1", 275 WorkspaceID: "ws-client", 276 }) 277 _, err := client.Messages.New(context.Background(), anthropic.MessageNewParams{ 278 Model: "claude-sonnet-4-6-20250514", 279 MaxTokens: 1, 280 Messages: []anthropic.MessageParam{anthropic.NewUserMessage(anthropic.NewTextBlock("hi"))}, 281 WorkspaceID: anthropic.String("ws-request"), 282 }) 283 if err != nil { 284 t.Fatalf("request failed: %v", err) 285 } 286 287 if got := captured.Headers.Values("Anthropic-Workspace-Id"); len(got) != 1 || got[0] != "ws-request" { 288 t.Errorf("expected single anthropic-workspace-id %q, got %q", "ws-request", got) 289 } 290 } 291 292 // --- Base URL tests --- 293 294 func TestBaseURLDerivedFromRegion(t *testing.T) { 295 t.Setenv("ANTHROPIC_AWS_BASE_URL", "") 296 t.Setenv("ANTHROPIC_AWS_WORKSPACE_ID", "") 297 t.Setenv("ANTHROPIC_AWS_API_KEY", "") 298 299 resolved, err := awsauth.ResolveConfig(toInternalConfig(ClientConfig{ 300 AWSRegion: "us-west-2", 301 WorkspaceID: "default", 302 APIKey: "my-key", 303 }), awsResolveParams()) 304 if err != nil { 305 t.Fatalf("unexpected error: %v", err) 306 } 307 expected := "https://aws-external-anthropic.us-west-2.api.aws" 308 if resolved.BaseURL != expected { 309 t.Errorf("expected base URL %q, got %q", expected, resolved.BaseURL) 310 } 311 } 312 313 func TestBaseURLFromEnv(t *testing.T) { 314 t.Setenv("ANTHROPIC_AWS_BASE_URL", "https://custom.gateway.example.com") 315 t.Setenv("ANTHROPIC_AWS_API_KEY", "my-key") 316 t.Setenv("ANTHROPIC_AWS_WORKSPACE_ID", "") 317 318 resolved, err := awsauth.ResolveConfig(toInternalConfig(ClientConfig{ 319 WorkspaceID: "default", 320 }), awsResolveParams()) 321 if err != nil { 322 t.Fatalf("unexpected error: %v", err) 323 } 324 if resolved.BaseURL != "https://custom.gateway.example.com" { 325 t.Errorf("expected base URL from env, got %q", resolved.BaseURL) 326 } 327 } 328 329 func TestBaseURLExplicitOverridesRegion(t *testing.T) { 330 t.Setenv("ANTHROPIC_AWS_BASE_URL", "") 331 t.Setenv("ANTHROPIC_AWS_WORKSPACE_ID", "") 332 t.Setenv("ANTHROPIC_AWS_API_KEY", "") 333 334 resolved, err := awsauth.ResolveConfig(toInternalConfig(ClientConfig{ 335 BaseURL: "https://explicit.example.com", 336 AWSRegion: "us-east-1", 337 WorkspaceID: "default", 338 APIKey: "my-key", 339 }), awsResolveParams()) 340 if err != nil { 341 t.Fatalf("unexpected error: %v", err) 342 } 343 if resolved.BaseURL != "https://explicit.example.com" { 344 t.Errorf("expected explicit base URL, got %q", resolved.BaseURL) 345 } 346 } 347 348 // --- skipAuth tests --- 349 350 func TestSkipAuthNoWorkspaceRequired(t *testing.T) { 351 t.Setenv("ANTHROPIC_AWS_WORKSPACE_ID", "") 352 t.Setenv("ANTHROPIC_AWS_API_KEY", "") 353 354 _, err := NewClient(context.Background(), ClientConfig{ 355 BaseURL: "https://proxy.example.com", 356 SkipAuth: true, 357 }) 358 if err != nil { 359 t.Fatalf("expected no error with skipAuth, got: %v", err) 360 } 361 } 362 363 func TestSkipAuthNoAuthHeaders(t *testing.T) { 364 t.Setenv("ANTHROPIC_AWS_WORKSPACE_ID", "") 365 t.Setenv("ANTHROPIC_AWS_API_KEY", "") 366 t.Setenv("ANTHROPIC_API_KEY", "") 367 368 client, captured := newTestClient(t, ClientConfig{ 369 SkipAuth: true, 370 }) 371 sendTestRequest(t, client) 372 373 if captured.Headers.Get("Authorization") != "" { 374 t.Error("expected no Authorization header with skipAuth") 375 } 376 if captured.Headers.Get("X-Amz-Date") != "" { 377 t.Error("expected no X-Amz-Date header with skipAuth") 378 } 379 if captured.Headers.Get("Anthropic-Workspace-Id") != "" { 380 t.Error("expected no anthropic-workspace-id header with skipAuth") 381 } 382 } 383 384 func TestSkipAuthIgnoresProvidedCreds(t *testing.T) { 385 t.Setenv("ANTHROPIC_AWS_WORKSPACE_ID", "") 386 t.Setenv("ANTHROPIC_AWS_API_KEY", "") 387 t.Setenv("ANTHROPIC_API_KEY", "") 388 389 client, captured := newTestClient(t, ClientConfig{ 390 AWSAccessKey: "key", 391 AWSSecretAccessKey: "secret", 392 AWSRegion: "us-east-1", 393 WorkspaceID: "ws-123", 394 SkipAuth: true, 395 }) 396 sendTestRequest(t, client) 397 398 if captured.Headers.Get("Authorization") != "" { 399 t.Error("expected no Authorization header with skipAuth even when credentials are provided") 400 } 401 if captured.Headers.Get("Anthropic-Workspace-Id") != "" { 402 t.Error("expected no anthropic-workspace-id header with skipAuth even when workspace ID is provided") 403 } 404 } 405 406 // --- ANTHROPIC_API_KEY isolation test --- 407 408 func TestDoesNotLeakAnthropicAPIKey(t *testing.T) { 409 t.Setenv("ANTHROPIC_API_KEY", "should-not-appear") 410 t.Setenv("ANTHROPIC_AWS_WORKSPACE_ID", "") 411 t.Setenv("ANTHROPIC_AWS_API_KEY", "") 412 413 client, captured := newTestClient(t, ClientConfig{ 414 AWSRegion: "us-east-1", 415 AWSAccessKey: "test-access-key", 416 AWSSecretAccessKey: "test-secret-key", 417 WorkspaceID: "ws-test", 418 }) 419 sendTestRequest(t, client) 420 421 if got := captured.Headers.Get("X-Api-Key"); got != "" { 422 t.Errorf("expected no x-api-key header when using SigV4, got %q", got) 423 } 424 } 425 426 // --- Middleware unit tests --- 427 428 func TestAWSMiddlewareSigV4Signing(t *testing.T) { 429 cfg := makeStaticConfig("us-east-1") 430 signer := v4.NewSigner() 431 middleware := awsauth.SigV4Middleware(signer, cfg, defaultServiceName) 432 433 body := []byte(`{"messages":[{"role":"user","content":"hello"}]}`) 434 req, err := http.NewRequest("POST", "https://aws-external-anthropic.us-east-1.api.aws/v1/messages", bytes.NewReader(body)) 435 if err != nil { 436 t.Fatalf("failed to create request: %v", err) 437 } 438 req.Header.Set("Content-Type", "application/json") 439 440 _, err = middleware(req, func(r *http.Request) (*http.Response, error) { 441 if r.Header.Get("Authorization") == "" { 442 t.Error("expected Authorization header to be set by SigV4 signing") 443 } 444 if r.Header.Get("X-Amz-Date") == "" { 445 t.Error("expected X-Amz-Date header to be set by SigV4 signing") 446 } 447 bodyBytes, err := io.ReadAll(r.Body) 448 if err != nil { 449 t.Fatalf("failed to read request body: %v", err) 450 } 451 if !bytes.Equal(bodyBytes, body) { 452 t.Errorf("expected body %q, got %q", body, bodyBytes) 453 } 454 return &http.Response{StatusCode: 200, Body: http.NoBody}, nil 455 }) 456 if err != nil { 457 t.Fatalf("middleware failed: %v", err) 458 } 459 } 460 461 func TestAWSMiddlewareEmptyBody(t *testing.T) { 462 cfg := makeStaticConfig("us-east-1") 463 signer := v4.NewSigner() 464 middleware := awsauth.SigV4Middleware(signer, cfg, defaultServiceName) 465 466 req, err := http.NewRequest("GET", "https://aws-external-anthropic.us-east-1.api.aws/v1/models", nil) 467 if err != nil { 468 t.Fatalf("failed to create request: %v", err) 469 } 470 471 _, err = middleware(req, func(r *http.Request) (*http.Response, error) { 472 if r.Header.Get("Authorization") == "" { 473 t.Error("expected Authorization header from SigV4 signing even with empty body") 474 } 475 return &http.Response{StatusCode: 200, Body: http.NoBody}, nil 476 }) 477 if err != nil { 478 t.Fatalf("middleware failed: %v", err) 479 } 480 } 481 482 func TestAWSMiddlewareServiceName(t *testing.T) { 483 cfg := makeStaticConfig("eu-west-1") 484 signer := v4.NewSigner() 485 middleware := awsauth.SigV4Middleware(signer, cfg, defaultServiceName) 486 487 req, err := http.NewRequest("POST", "https://aws-external-anthropic.eu-west-1.api.aws/v1/messages", bytes.NewReader([]byte(`{}`))) 488 if err != nil { 489 t.Fatalf("failed to create request: %v", err) 490 } 491 492 var gotAuth string 493 _, err = middleware(req, func(r *http.Request) (*http.Response, error) { 494 gotAuth = r.Header.Get("Authorization") 495 return &http.Response{StatusCode: 200, Body: http.NoBody}, nil 496 }) 497 if err != nil { 498 t.Fatalf("middleware failed: %v", err) 499 } 500 501 if gotAuth == "" { 502 t.Fatal("expected non-empty Authorization header") 503 } 504 expectedServiceFragment := "/" + defaultServiceName + "/" 505 if !bytes.Contains([]byte(gotAuth), []byte(expectedServiceFragment)) { 506 t.Errorf("expected Authorization to contain service %q, got: %s", defaultServiceName, gotAuth) 507 } 508 } 509 510 func TestAWSMiddlewareQueryParamsIncludedInSignature(t *testing.T) { 511 cfg := makeStaticConfig("us-east-1") 512 signer := v4.NewSigner() 513 middleware := awsauth.SigV4Middleware(signer, cfg, defaultServiceName) 514 515 body := []byte(`{}`) 516 urlWithQuery := "https://aws-external-anthropic.us-east-1.api.aws/v1/messages?beta=true&stream=false" 517 req, err := http.NewRequest("POST", urlWithQuery, bytes.NewReader(body)) 518 if err != nil { 519 t.Fatalf("failed to create request: %v", err) 520 } 521 522 var signedAuth string 523 _, err = middleware(req, func(r *http.Request) (*http.Response, error) { 524 signedAuth = r.Header.Get("Authorization") 525 if r.URL.RawQuery == "" { 526 t.Error("expected query params to be preserved after signing") 527 } 528 if r.URL.Query().Get("beta") != "true" || r.URL.Query().Get("stream") != "false" { 529 t.Errorf("query params were modified: got %s", r.URL.RawQuery) 530 } 531 return &http.Response{StatusCode: 200, Body: http.NoBody}, nil 532 }) 533 if err != nil { 534 t.Fatalf("middleware failed: %v", err) 535 } 536 537 middlewareNoQuery := awsauth.SigV4Middleware(signer, cfg, defaultServiceName) 538 reqNoQuery, err := http.NewRequest("POST", "https://aws-external-anthropic.us-east-1.api.aws/v1/messages", bytes.NewReader(body)) 539 if err != nil { 540 t.Fatalf("failed to create request: %v", err) 541 } 542 543 var signedAuthNoQuery string 544 _, err = middlewareNoQuery(reqNoQuery, func(r *http.Request) (*http.Response, error) { 545 signedAuthNoQuery = r.Header.Get("Authorization") 546 return &http.Response{StatusCode: 200, Body: http.NoBody}, nil 547 }) 548 if err != nil { 549 t.Fatalf("middleware failed: %v", err) 550 } 551 552 if signedAuth == signedAuthNoQuery { 553 t.Error("expected different signatures for requests with and without query params") 554 } 555 } 556 557 func TestAWSMiddlewareBodyReplayed(t *testing.T) { 558 cfg := makeStaticConfig("us-east-1") 559 signer := v4.NewSigner() 560 middleware := awsauth.SigV4Middleware(signer, cfg, defaultServiceName) 561 562 originalBody := []byte(`{"test":"body content"}`) 563 req, err := http.NewRequest("POST", "https://aws-external-anthropic.us-east-1.api.aws/v1/messages", bytes.NewReader(originalBody)) 564 if err != nil { 565 t.Fatalf("failed to create request: %v", err) 566 } 567 568 _, err = middleware(req, func(r *http.Request) (*http.Response, error) { 569 if r.GetBody == nil { 570 t.Error("expected GetBody to be set for retry support") 571 } 572 rc, err := r.GetBody() 573 if err != nil { 574 t.Fatalf("GetBody failed: %v", err) 575 } 576 replayed, err := io.ReadAll(rc) 577 if err != nil { 578 t.Fatalf("failed to read replayed body: %v", err) 579 } 580 if !bytes.Equal(replayed, originalBody) { 581 t.Errorf("replayed body %q does not match original %q", replayed, originalBody) 582 } 583 return &http.Response{StatusCode: 200, Body: http.NoBody}, nil 584 }) 585 if err != nil { 586 t.Fatalf("middleware failed: %v", err) 587 } 588 }