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  }