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  }