github.com/anthropics/anthropic-sdk-go@v1.70.1/config/config_test.go (about)

     1  package config_test
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/json"
     6  	"log"
     7  	"os"
     8  	"path/filepath"
     9  	"runtime"
    10  	"strings"
    11  	"testing"
    12  
    13  	"github.com/anthropics/anthropic-sdk-go/config"
    14  )
    15  
    16  // setupConfigDir creates a temp config dir with configs/<profile>.json written
    17  // from cfgData. Returns the config dir path.
    18  func setupConfigDir(t *testing.T, profile string, cfgData map[string]any) string {
    19  	t.Helper()
    20  	dir := t.TempDir()
    21  	configsDir := filepath.Join(dir, "configs")
    22  	os.MkdirAll(configsDir, 0755)
    23  	b, _ := json.MarshalIndent(cfgData, "", "  ")
    24  	os.WriteFile(filepath.Join(configsDir, profile+".json"), b, 0644)
    25  	return dir
    26  }
    27  
    28  // unsetEnv removes an environment variable for the duration of a test. It
    29  // uses t.Setenv first to register the original value for automatic
    30  // restoration at test end, then calls os.Unsetenv to actually remove it.
    31  func unsetEnv(t *testing.T, key string) {
    32  	t.Helper()
    33  	t.Setenv(key, "")
    34  	os.Unsetenv(key)
    35  }
    36  
    37  // oidcFederationProfile is a minimal valid oidc_federation config body.
    38  func oidcFederationProfile() map[string]any {
    39  	return map[string]any{
    40  		"authentication": map[string]any{
    41  			"type":               "oidc_federation",
    42  			"federation_rule_id": "fdrl_test",
    43  			"identity_token": map[string]any{
    44  				"source": "file",
    45  				"path":   "/tmp/token",
    46  			},
    47  		},
    48  	}
    49  }
    50  
    51  // userOAuthProfile is a minimal valid user_oauth config body.
    52  func userOAuthProfile() map[string]any {
    53  	return map[string]any{
    54  		"authentication": map[string]any{
    55  			"type": "user_oauth",
    56  		},
    57  	}
    58  }
    59  
    60  func TestLoadConfig_ProfileFromEnv(t *testing.T) {
    61  	body := oidcFederationProfile()
    62  	body["base_url"] = "https://work.example.com"
    63  	dir := setupConfigDir(t, "work", body)
    64  	t.Setenv("ANTHROPIC_CONFIG_DIR", dir)
    65  	t.Setenv("ANTHROPIC_PROFILE", "work")
    66  
    67  	cfg, err := config.LoadConfig()
    68  	if err != nil {
    69  		t.Fatal(err)
    70  	}
    71  	if cfg.AuthenticationInfo.Type != config.AuthenticationTypeOIDCFederation {
    72  		t.Fatalf("got type %q, want %q", cfg.AuthenticationInfo.Type, config.AuthenticationTypeOIDCFederation)
    73  	}
    74  	if cfg.BaseURL != "https://work.example.com" {
    75  		t.Fatalf("got base_url %q, want %q", cfg.BaseURL, "https://work.example.com")
    76  	}
    77  }
    78  
    79  func TestLoadConfig_ProfileFromActiveConfigFile(t *testing.T) {
    80  	dir := setupConfigDir(t, "staging", userOAuthProfile())
    81  	os.WriteFile(filepath.Join(dir, "active_config"), []byte("staging"), 0644)
    82  	t.Setenv("ANTHROPIC_CONFIG_DIR", dir)
    83  	unsetEnv(t, "ANTHROPIC_PROFILE")
    84  
    85  	cfg, err := config.LoadConfig()
    86  	if err != nil {
    87  		t.Fatal(err)
    88  	}
    89  	if cfg.AuthenticationInfo.Type != config.AuthenticationTypeUserOAuth {
    90  		t.Fatalf("got type %q, want %q", cfg.AuthenticationInfo.Type, config.AuthenticationTypeUserOAuth)
    91  	}
    92  }
    93  
    94  func TestLoadConfig_DefaultProfile(t *testing.T) {
    95  	dir := setupConfigDir(t, "default", userOAuthProfile())
    96  	t.Setenv("ANTHROPIC_CONFIG_DIR", dir)
    97  	unsetEnv(t, "ANTHROPIC_PROFILE")
    98  
    99  	cfg, err := config.LoadConfig()
   100  	if err != nil {
   101  		t.Fatal(err)
   102  	}
   103  	if cfg.AuthenticationInfo.Type != config.AuthenticationTypeUserOAuth {
   104  		t.Fatalf("got type %q, want %q", cfg.AuthenticationInfo.Type, config.AuthenticationTypeUserOAuth)
   105  	}
   106  }
   107  
   108  func TestLoadConfig_EnvProfileTakesPrecedenceOverActiveConfig(t *testing.T) {
   109  	envBody := oidcFederationProfile()
   110  	envBody["base_url"] = "https://from-env.example.com"
   111  	dir := setupConfigDir(t, "from-env", envBody)
   112  
   113  	fileBody := oidcFederationProfile()
   114  	fileBody["base_url"] = "https://from-file.example.com"
   115  	setupB, _ := json.MarshalIndent(fileBody, "", "  ")
   116  	os.MkdirAll(filepath.Join(dir, "configs"), 0755)
   117  	os.WriteFile(filepath.Join(dir, "configs", "from-file.json"), setupB, 0644)
   118  	os.WriteFile(filepath.Join(dir, "active_config"), []byte("from-file"), 0644)
   119  
   120  	t.Setenv("ANTHROPIC_CONFIG_DIR", dir)
   121  	t.Setenv("ANTHROPIC_PROFILE", "from-env")
   122  
   123  	cfg, err := config.LoadConfig()
   124  	if err != nil {
   125  		t.Fatal(err)
   126  	}
   127  	if cfg.BaseURL != "https://from-env.example.com" {
   128  		t.Fatalf("got base_url %q, want %q", cfg.BaseURL, "https://from-env.example.com")
   129  	}
   130  }
   131  
   132  func TestLoadConfig_OIDCFederationAllFields(t *testing.T) {
   133  	dir := setupConfigDir(t, "default", map[string]any{
   134  		"base_url":        "https://api.example.com",
   135  		"organization_id": "org-123",
   136  		"workspace_id":    "wrkspc_456",
   137  		"authentication": map[string]any{
   138  			"type":               "oidc_federation",
   139  			"credentials_path":   "/custom/creds.json",
   140  			"federation_rule_id": "fdrl_789",
   141  			"service_account_id": "svac_abc",
   142  			"identity_token": map[string]any{
   143  				"source": "file",
   144  				"path":   "/tmp/token",
   145  			},
   146  		},
   147  	})
   148  	t.Setenv("ANTHROPIC_CONFIG_DIR", dir)
   149  	t.Setenv("ANTHROPIC_PROFILE", "default")
   150  
   151  	cfg, err := config.LoadConfig()
   152  	if err != nil {
   153  		t.Fatal(err)
   154  	}
   155  	if cfg.AuthenticationInfo.Type != config.AuthenticationTypeOIDCFederation {
   156  		t.Fatalf("got type %q, want %q", cfg.AuthenticationInfo.Type, config.AuthenticationTypeOIDCFederation)
   157  	}
   158  	if cfg.BaseURL != "https://api.example.com" {
   159  		t.Fatalf("got base_url %q", cfg.BaseURL)
   160  	}
   161  	if cfg.OrganizationID != "org-123" {
   162  		t.Fatalf("got organization_id %q", cfg.OrganizationID)
   163  	}
   164  	if cfg.WorkspaceID != "wrkspc_456" {
   165  		t.Fatalf("got workspace_id %q", cfg.WorkspaceID)
   166  	}
   167  	if cfg.AuthenticationInfo.CredentialsPath != "/custom/creds.json" {
   168  		t.Fatalf("got credentials_path %q", cfg.AuthenticationInfo.CredentialsPath)
   169  	}
   170  	oidc := cfg.AuthenticationInfo.OIDCFederation
   171  	if oidc == nil {
   172  		t.Fatal("expected oidc_federation sub-object")
   173  	}
   174  	if oidc.FederationRuleID != "fdrl_789" {
   175  		t.Fatalf("got federation_rule_id %q", oidc.FederationRuleID)
   176  	}
   177  	if oidc.ServiceAccountID != "svac_abc" {
   178  		t.Fatalf("got service_account_id %q", oidc.ServiceAccountID)
   179  	}
   180  	if oidc.IdentityToken == nil || oidc.IdentityToken.Source != config.IdentityTokenSourceFile {
   181  		t.Fatalf("got identity_token %+v", oidc.IdentityToken)
   182  	}
   183  	if oidc.IdentityToken.Path != "/tmp/token" {
   184  		t.Fatalf("got identity_token.path %q", oidc.IdentityToken.Path)
   185  	}
   186  }
   187  
   188  func TestLoadConfig_UserOAuthWithClientID(t *testing.T) {
   189  	body := userOAuthProfile()
   190  	body["authentication"].(map[string]any)["client_id"] = "client-xyz"
   191  	dir := setupConfigDir(t, "default", body)
   192  	t.Setenv("ANTHROPIC_CONFIG_DIR", dir)
   193  	t.Setenv("ANTHROPIC_PROFILE", "default")
   194  
   195  	cfg, err := config.LoadConfig()
   196  	if err != nil {
   197  		t.Fatal(err)
   198  	}
   199  	if cfg.AuthenticationInfo.UserOAuth == nil {
   200  		t.Fatal("expected user_oauth sub-object")
   201  	}
   202  	if cfg.AuthenticationInfo.UserOAuth.ClientID != "client-xyz" {
   203  		t.Fatalf("got client_id %q", cfg.AuthenticationInfo.UserOAuth.ClientID)
   204  	}
   205  }
   206  
   207  func TestLoadConfig_DefaultCredentialsPath(t *testing.T) {
   208  	dir := setupConfigDir(t, "myprofile", userOAuthProfile())
   209  	t.Setenv("ANTHROPIC_CONFIG_DIR", dir)
   210  	t.Setenv("ANTHROPIC_PROFILE", "myprofile")
   211  
   212  	cfg, err := config.LoadConfig()
   213  	if err != nil {
   214  		t.Fatal(err)
   215  	}
   216  	want := filepath.Join(dir, "credentials", "myprofile.json")
   217  	if cfg.AuthenticationInfo.CredentialsPath != want {
   218  		t.Fatalf("got credentials_path %q, want %q", cfg.AuthenticationInfo.CredentialsPath, want)
   219  	}
   220  }
   221  
   222  func TestLoadConfig_ExplicitCredentialsPathNotOverridden(t *testing.T) {
   223  	body := userOAuthProfile()
   224  	body["authentication"].(map[string]any)["credentials_path"] = "/explicit/path.json"
   225  	dir := setupConfigDir(t, "default", body)
   226  	t.Setenv("ANTHROPIC_CONFIG_DIR", dir)
   227  	t.Setenv("ANTHROPIC_PROFILE", "default")
   228  
   229  	cfg, err := config.LoadConfig()
   230  	if err != nil {
   231  		t.Fatal(err)
   232  	}
   233  	if cfg.AuthenticationInfo.CredentialsPath != "/explicit/path.json" {
   234  		t.Fatalf("got credentials_path %q, want %q", cfg.AuthenticationInfo.CredentialsPath, "/explicit/path.json")
   235  	}
   236  }
   237  
   238  func TestLoadConfig_MissingConfigFile(t *testing.T) {
   239  	t.Setenv("ANTHROPIC_CONFIG_DIR", "/nonexistent/dir")
   240  	t.Setenv("ANTHROPIC_PROFILE", "default")
   241  
   242  	_, err := config.LoadConfig()
   243  	if err == nil {
   244  		t.Fatal("expected error for missing config file")
   245  	}
   246  }
   247  
   248  func TestLoadConfig_EmptyActiveConfigFallsBackToDefault(t *testing.T) {
   249  	dir := setupConfigDir(t, "default", userOAuthProfile())
   250  	os.WriteFile(filepath.Join(dir, "active_config"), []byte("  \n  "), 0644)
   251  	t.Setenv("ANTHROPIC_CONFIG_DIR", dir)
   252  	unsetEnv(t, "ANTHROPIC_PROFILE")
   253  
   254  	cfg, err := config.LoadConfig()
   255  	if err != nil {
   256  		t.Fatal(err)
   257  	}
   258  	if cfg.AuthenticationInfo.Type != config.AuthenticationTypeUserOAuth {
   259  		t.Fatalf("got type %q", cfg.AuthenticationInfo.Type)
   260  	}
   261  }
   262  
   263  func TestLoadConfig_MissingAuthenticationInfo(t *testing.T) {
   264  	dir := setupConfigDir(t, "default", map[string]any{
   265  		"base_url": "https://api.example.com",
   266  	})
   267  	t.Setenv("ANTHROPIC_CONFIG_DIR", dir)
   268  	t.Setenv("ANTHROPIC_PROFILE", "default")
   269  
   270  	_, err := config.LoadConfig()
   271  	if err == nil {
   272  		t.Fatal("expected error for missing authentication")
   273  	}
   274  }
   275  
   276  // TestLoadConfig_CrossVariantFieldTolerated asserts that a field belonging
   277  // to the other variant (e.g. client_id on an oidc_federation body) is
   278  // silently ignored per the credentials-file-format spec's tolerance rule.
   279  // The declared variant still decodes cleanly.
   280  func TestLoadConfig_CrossVariantFieldTolerated(t *testing.T) {
   281  	dir := setupConfigDir(t, "default", map[string]any{
   282  		"authentication": map[string]any{
   283  			"type":               "oidc_federation",
   284  			"federation_rule_id": "fdrl_test",
   285  			"client_id":          "ignored-cross-variant",
   286  		},
   287  	})
   288  	t.Setenv("ANTHROPIC_CONFIG_DIR", dir)
   289  	t.Setenv("ANTHROPIC_PROFILE", "default")
   290  
   291  	cfg, err := config.LoadConfig()
   292  	if err != nil {
   293  		t.Fatal(err)
   294  	}
   295  	if cfg.AuthenticationInfo.Type != config.AuthenticationTypeOIDCFederation {
   296  		t.Errorf("type: %q", cfg.AuthenticationInfo.Type)
   297  	}
   298  	if cfg.AuthenticationInfo.OIDCFederation.FederationRuleID != "fdrl_test" {
   299  		t.Errorf("FederationRuleID: %q", cfg.AuthenticationInfo.OIDCFederation.FederationRuleID)
   300  	}
   301  	if cfg.AuthenticationInfo.UserOAuth != nil {
   302  		t.Errorf("UserOAuth should be nil, got %+v", cfg.AuthenticationInfo.UserOAuth)
   303  	}
   304  }
   305  
   306  // TestLoadConfig_BothVariantFieldsTolerated asserts that a payload carrying
   307  // fields from both variants at once is accepted: the declared variant's
   308  // fields populate its sub-struct and the other variant's fields are
   309  // ignored.
   310  func TestLoadConfig_BothVariantFieldsTolerated(t *testing.T) {
   311  	dir := setupConfigDir(t, "default", map[string]any{
   312  		"authentication": map[string]any{
   313  			"type":               "user_oauth",
   314  			"federation_rule_id": "ignored-fdrl",
   315  			"client_id":          "client-xyz",
   316  		},
   317  	})
   318  	t.Setenv("ANTHROPIC_CONFIG_DIR", dir)
   319  	t.Setenv("ANTHROPIC_PROFILE", "default")
   320  
   321  	cfg, err := config.LoadConfig()
   322  	if err != nil {
   323  		t.Fatal(err)
   324  	}
   325  	if cfg.AuthenticationInfo.UserOAuth == nil || cfg.AuthenticationInfo.UserOAuth.ClientID != "client-xyz" {
   326  		t.Errorf("UserOAuth.ClientID: %+v", cfg.AuthenticationInfo.UserOAuth)
   327  	}
   328  	if cfg.AuthenticationInfo.OIDCFederation != nil {
   329  		t.Errorf("OIDCFederation should be nil, got %+v", cfg.AuthenticationInfo.OIDCFederation)
   330  	}
   331  }
   332  
   333  func TestLoadConfig_UnknownAuthenticationType(t *testing.T) {
   334  	dir := setupConfigDir(t, "default", map[string]any{
   335  		"authentication": map[string]any{
   336  			"type": "totally_made_up",
   337  		},
   338  	})
   339  	t.Setenv("ANTHROPIC_CONFIG_DIR", dir)
   340  	t.Setenv("ANTHROPIC_PROFILE", "default")
   341  
   342  	_, err := config.LoadConfig()
   343  	if err == nil {
   344  		t.Fatal("expected error for unknown authentication type")
   345  	}
   346  }
   347  
   348  func TestLoadConfig_RejectsProfileWithPathTraversal(t *testing.T) {
   349  	dir := setupConfigDir(t, "default", userOAuthProfile())
   350  	// Write a config file at the location the traversal path would resolve
   351  	// to, so the only thing stopping the load is the validator.
   352  	os.MkdirAll(filepath.Join(dir, "configs"), 0755)
   353  	evilBody := userOAuthProfile()
   354  	evilB, _ := json.Marshal(evilBody)
   355  	os.WriteFile(filepath.Join(dir, "configs", "evil.json"), evilB, 0644)
   356  
   357  	for _, bad := range []string{
   358  		"../etc/passwd",
   359  		"..",
   360  		"../../evil",
   361  		`nested\name`,
   362  		"with/slash",
   363  		"",
   364  	} {
   365  		t.Run(bad, func(t *testing.T) {
   366  			t.Setenv("ANTHROPIC_CONFIG_DIR", dir)
   367  			t.Setenv("ANTHROPIC_PROFILE", bad)
   368  			if _, err := config.LoadConfig(); err == nil {
   369  				t.Fatalf("expected error for profile %q", bad)
   370  			}
   371  		})
   372  	}
   373  }
   374  
   375  // TestLoadConfig_ToleratesUnknownTopLevelFields covers the spec's tolerance
   376  // rule on the top-level Config object.
   377  func TestLoadConfig_ToleratesUnknownTopLevelFields(t *testing.T) {
   378  	body := oidcFederationProfile()
   379  	body["future_field"] = "ignored-by-this-sdk-version"
   380  	body["_comment"] = "human-readable annotation"
   381  	dir := setupConfigDir(t, "default", body)
   382  	t.Setenv("ANTHROPIC_CONFIG_DIR", dir)
   383  	t.Setenv("ANTHROPIC_PROFILE", "default")
   384  	cfg, err := config.LoadConfig()
   385  	if err != nil {
   386  		t.Fatal(err)
   387  	}
   388  	if cfg.AuthenticationInfo.Type != config.AuthenticationTypeOIDCFederation {
   389  		t.Errorf("type: %q", cfg.AuthenticationInfo.Type)
   390  	}
   391  }
   392  
   393  // TestLoadConfig_ToleratesUnknownVariantFields covers the tolerance rule
   394  // inside AuthenticationInfo.UnmarshalJSON. A typo'd field name (e.g.
   395  // "federation_rule" instead of "federation_rule_id") is silently ignored
   396  // — the typo surfaces as a missing-required-field error at credential
   397  // resolution time, not a parse error.
   398  func TestLoadConfig_ToleratesUnknownVariantFields(t *testing.T) {
   399  	dir := setupConfigDir(t, "default", map[string]any{
   400  		"authentication": map[string]any{
   401  			"type":               "oidc_federation",
   402  			"federation_rule_id": "fdrl_ok",
   403  			"federation_rule":    "typo-ignored",
   404  			"_comment":           "per-spec tolerance",
   405  		},
   406  	})
   407  	t.Setenv("ANTHROPIC_CONFIG_DIR", dir)
   408  	t.Setenv("ANTHROPIC_PROFILE", "default")
   409  	cfg, err := config.LoadConfig()
   410  	if err != nil {
   411  		t.Fatal(err)
   412  	}
   413  	if cfg.AuthenticationInfo.OIDCFederation.FederationRuleID != "fdrl_ok" {
   414  		t.Errorf("FederationRuleID: %q", cfg.AuthenticationInfo.OIDCFederation.FederationRuleID)
   415  	}
   416  }
   417  
   418  // TestLoadConfig_UnknownVariantFieldLogsWarning verifies that the tolerance
   419  // rule also logs a warn-once message naming the unknown field, so a user
   420  // whose typo silently vanished can at least see that it happened.
   421  func TestLoadConfig_UnknownVariantFieldLogsWarning(t *testing.T) {
   422  	dir := setupConfigDir(t, "default", map[string]any{
   423  		"authentication": map[string]any{
   424  			"type":               "oidc_federation",
   425  			"federation_rule_id": "fdrl_ok",
   426  			"federaton_rule_id":  "typo-ignored",
   427  		},
   428  	})
   429  	t.Setenv("ANTHROPIC_CONFIG_DIR", dir)
   430  	t.Setenv("ANTHROPIC_PROFILE", "default")
   431  	config.ResetConfigWarnOnceForTest()
   432  
   433  	var buf bytes.Buffer
   434  	log.SetOutput(&buf)
   435  	t.Cleanup(func() { log.SetOutput(os.Stderr) })
   436  
   437  	cfg, err := config.LoadConfig()
   438  	if err != nil {
   439  		t.Fatal(err)
   440  	}
   441  	out := buf.String()
   442  	if !strings.Contains(out, "federaton_rule_id") {
   443  		t.Errorf("expected warning mentioning unknown field, got: %q", out)
   444  	}
   445  	// Regression check: the correctly-spelled field must still load through
   446  	// even when an unknown field is alongside it.
   447  	if cfg.AuthenticationInfo == nil || cfg.AuthenticationInfo.OIDCFederation == nil ||
   448  		cfg.AuthenticationInfo.OIDCFederation.FederationRuleID != "fdrl_ok" {
   449  		t.Errorf("correct field dropped: got %+v", cfg.AuthenticationInfo)
   450  	}
   451  }
   452  
   453  // TestAuthenticationInfo_RoundTrip marshals a populated AuthenticationInfo
   454  // for each variant and asserts that unmarshalling the result reproduces the
   455  // original.
   456  func TestAuthenticationInfo_RoundTrip(t *testing.T) {
   457  	cases := []struct {
   458  		name  string
   459  		value config.AuthenticationInfo
   460  	}{
   461  		{
   462  			name: "oidc_federation_all_fields",
   463  			value: config.AuthenticationInfo{
   464  				Type:            config.AuthenticationTypeOIDCFederation,
   465  				CredentialsPath: "/tmp/creds.json",
   466  				OIDCFederation: &config.OIDCFederation{
   467  					FederationRuleID: "fdrl_abc",
   468  					ServiceAccountID: "svac_def",
   469  					IdentityToken: &config.IdentityTokenConfig{
   470  						Source: config.IdentityTokenSourceFile,
   471  						Path:   "/tmp/token",
   472  					},
   473  					Scope: "user:inference",
   474  				},
   475  			},
   476  		},
   477  		{
   478  			name: "oidc_federation_minimal",
   479  			value: config.AuthenticationInfo{
   480  				Type: config.AuthenticationTypeOIDCFederation,
   481  				OIDCFederation: &config.OIDCFederation{
   482  					FederationRuleID: "fdrl_min",
   483  				},
   484  			},
   485  		},
   486  		{
   487  			name: "user_oauth_with_client_id_and_scope",
   488  			value: config.AuthenticationInfo{
   489  				Type:            config.AuthenticationTypeUserOAuth,
   490  				CredentialsPath: "/tmp/creds.json",
   491  				UserOAuth: &config.UserOAuth{
   492  					ClientID: "client-xyz",
   493  					Scope:    "user:profile user:inference",
   494  				},
   495  			},
   496  		},
   497  		{
   498  			name: "user_oauth_with_console_url",
   499  			value: config.AuthenticationInfo{
   500  				Type: config.AuthenticationTypeUserOAuth,
   501  				UserOAuth: &config.UserOAuth{
   502  					ClientID:   "client-xyz",
   503  					ConsoleURL: "https://staging.example/console",
   504  				},
   505  			},
   506  		},
   507  		{
   508  			name: "user_oauth_static",
   509  			value: config.AuthenticationInfo{
   510  				Type:      config.AuthenticationTypeUserOAuth,
   511  				UserOAuth: &config.UserOAuth{},
   512  			},
   513  		},
   514  	}
   515  	for _, tc := range cases {
   516  		t.Run(tc.name, func(t *testing.T) {
   517  			b, err := json.Marshal(tc.value)
   518  			if err != nil {
   519  				t.Fatalf("marshal: %v", err)
   520  			}
   521  			var got config.AuthenticationInfo
   522  			if err := json.Unmarshal(b, &got); err != nil {
   523  				t.Fatalf("unmarshal %s: %v", string(b), err)
   524  			}
   525  			// Compare field by field so we catch pointer-equality traps.
   526  			if got.Type != tc.value.Type {
   527  				t.Errorf("Type: got %q, want %q", got.Type, tc.value.Type)
   528  			}
   529  			if got.CredentialsPath != tc.value.CredentialsPath {
   530  				t.Errorf("CredentialsPath: got %q, want %q", got.CredentialsPath, tc.value.CredentialsPath)
   531  			}
   532  			switch tc.value.Type {
   533  			case config.AuthenticationTypeOIDCFederation:
   534  				if got.OIDCFederation == nil {
   535  					t.Fatal("OIDCFederation nil after round-trip")
   536  				}
   537  				if got.OIDCFederation.FederationRuleID != tc.value.OIDCFederation.FederationRuleID {
   538  					t.Errorf("FederationRuleID: got %q, want %q",
   539  						got.OIDCFederation.FederationRuleID, tc.value.OIDCFederation.FederationRuleID)
   540  				}
   541  				if got.OIDCFederation.ServiceAccountID != tc.value.OIDCFederation.ServiceAccountID {
   542  					t.Errorf("ServiceAccountID: got %q, want %q",
   543  						got.OIDCFederation.ServiceAccountID, tc.value.OIDCFederation.ServiceAccountID)
   544  				}
   545  				if got.OIDCFederation.Scope != tc.value.OIDCFederation.Scope {
   546  					t.Errorf("Scope: got %q, want %q",
   547  						got.OIDCFederation.Scope, tc.value.OIDCFederation.Scope)
   548  				}
   549  				wantIT := tc.value.OIDCFederation.IdentityToken
   550  				gotIT := got.OIDCFederation.IdentityToken
   551  				if (wantIT == nil) != (gotIT == nil) {
   552  					t.Fatalf("IdentityToken presence: got %v, want %v", gotIT, wantIT)
   553  				}
   554  				if wantIT != nil && (gotIT.Source != wantIT.Source || gotIT.Path != wantIT.Path) {
   555  					t.Errorf("IdentityToken: got %+v, want %+v", gotIT, wantIT)
   556  				}
   557  				if got.UserOAuth != nil {
   558  					t.Errorf("UserOAuth should be nil, got %+v", got.UserOAuth)
   559  				}
   560  			case config.AuthenticationTypeUserOAuth:
   561  				if got.UserOAuth == nil {
   562  					t.Fatal("UserOAuth nil after round-trip")
   563  				}
   564  				if got.UserOAuth.ClientID != tc.value.UserOAuth.ClientID {
   565  					t.Errorf("ClientID: got %q, want %q",
   566  						got.UserOAuth.ClientID, tc.value.UserOAuth.ClientID)
   567  				}
   568  				if got.UserOAuth.Scope != tc.value.UserOAuth.Scope {
   569  					t.Errorf("Scope: got %q, want %q",
   570  						got.UserOAuth.Scope, tc.value.UserOAuth.Scope)
   571  				}
   572  				if got.OIDCFederation != nil {
   573  					t.Errorf("OIDCFederation should be nil, got %+v", got.OIDCFederation)
   574  				}
   575  			}
   576  		})
   577  	}
   578  }
   579  
   580  // TestAuthenticationInfo_FlatWireShape pins the exact JSON emitted by
   581  // MarshalJSON so a future refactor can't silently re-introduce nesting.
   582  func TestAuthenticationInfo_FlatWireShape(t *testing.T) {
   583  	value := config.AuthenticationInfo{
   584  		Type:            config.AuthenticationTypeOIDCFederation,
   585  		CredentialsPath: "/tmp/creds.json",
   586  		OIDCFederation: &config.OIDCFederation{
   587  			FederationRuleID: "fdrl_abc",
   588  			Scope:            "user:inference",
   589  		},
   590  	}
   591  	b, err := json.Marshal(value)
   592  	if err != nil {
   593  		t.Fatal(err)
   594  	}
   595  	var asMap map[string]any
   596  	if err := json.Unmarshal(b, &asMap); err != nil {
   597  		t.Fatal(err)
   598  	}
   599  	// Variant-specific field must sit at the top level, not nested.
   600  	if _, nested := asMap["oidc_federation"]; nested {
   601  		t.Errorf("output nests oidc_federation key: %s", string(b))
   602  	}
   603  	if asMap["federation_rule_id"] != "fdrl_abc" {
   604  		t.Errorf("expected flat federation_rule_id, got: %s", string(b))
   605  	}
   606  	if asMap["type"] != "oidc_federation" {
   607  		t.Errorf("missing or wrong type: %s", string(b))
   608  	}
   609  	if asMap["credentials_path"] != "/tmp/creds.json" {
   610  		t.Errorf("missing credentials_path: %s", string(b))
   611  	}
   612  	if asMap["scope"] != "user:inference" {
   613  		t.Errorf("expected flat scope at auth level, got: %s", string(b))
   614  	}
   615  }
   616  
   617  // TestLoadConfig_EnvDoesNotOverrideProfileFields locks in the spec's
   618  // fill-missing-only semantics: if a profile declares a field, an env var of
   619  // the same name must NOT silently replace it. This keeps an explicit
   620  // profile authoritative on a machine that also exports WIF env vars.
   621  func TestLoadConfig_EnvDoesNotOverrideProfileFields(t *testing.T) {
   622  	body := oidcFederationProfile()
   623  	body["base_url"] = "https://file.example.com"
   624  	body["organization_id"] = "org_from_file"
   625  	body["workspace_id"] = "wrkspc_from_file"
   626  	auth := body["authentication"].(map[string]any)
   627  	auth["federation_rule_id"] = "fdrl_from_file"
   628  	auth["service_account_id"] = "svac_from_file"
   629  	auth["scope"] = "scope_from_file"
   630  	auth["identity_token"] = map[string]any{
   631  		"source": "file",
   632  		"path":   "/tmp/file-token",
   633  	}
   634  
   635  	dir := setupConfigDir(t, "default", body)
   636  	t.Setenv("ANTHROPIC_CONFIG_DIR", dir)
   637  	unsetEnv(t, "ANTHROPIC_PROFILE")
   638  	t.Setenv("ANTHROPIC_BASE_URL", "https://env.example.com")
   639  	t.Setenv("ANTHROPIC_ORGANIZATION_ID", "org_from_env")
   640  	t.Setenv("ANTHROPIC_WORKSPACE_ID", "wrkspc_from_env")
   641  	t.Setenv("ANTHROPIC_FEDERATION_RULE_ID", "fdrl_from_env")
   642  	t.Setenv("ANTHROPIC_SERVICE_ACCOUNT_ID", "svac_from_env")
   643  	t.Setenv("ANTHROPIC_SCOPE", "scope_from_env")
   644  	t.Setenv("ANTHROPIC_IDENTITY_TOKEN_FILE", "/tmp/env-token")
   645  
   646  	cfg, err := config.LoadConfig()
   647  	if err != nil {
   648  		t.Fatal(err)
   649  	}
   650  	if cfg.BaseURL != "https://file.example.com" {
   651  		t.Errorf("base_url: got %q, want file value (env must not override)", cfg.BaseURL)
   652  	}
   653  	if cfg.OrganizationID != "org_from_file" {
   654  		t.Errorf("organization_id: got %q, want file value", cfg.OrganizationID)
   655  	}
   656  	if cfg.WorkspaceID != "wrkspc_from_file" {
   657  		t.Errorf("workspace_id: got %q, want file value", cfg.WorkspaceID)
   658  	}
   659  	oidc := cfg.AuthenticationInfo.OIDCFederation
   660  	if oidc.FederationRuleID != "fdrl_from_file" {
   661  		t.Errorf("federation_rule_id: got %q, want file value", oidc.FederationRuleID)
   662  	}
   663  	if oidc.ServiceAccountID != "svac_from_file" {
   664  		t.Errorf("service_account_id: got %q, want file value", oidc.ServiceAccountID)
   665  	}
   666  	if oidc.Scope != "scope_from_file" {
   667  		t.Errorf("scope: got %q, want file value", oidc.Scope)
   668  	}
   669  	if oidc.IdentityToken == nil || oidc.IdentityToken.Path != "/tmp/file-token" {
   670  		t.Errorf("identity_token: got %+v, want file value", oidc.IdentityToken)
   671  	}
   672  }
   673  
   674  // TestLoadConfig_EnvFillsMissingFields covers the other half of the
   675  // fill-missing rule: env vars populate fields the profile OMITTED. A
   676  // profile can declare federation_rule_id and organization_id once on disk
   677  // and let each pod inject its own identity_token via env.
   678  func TestLoadConfig_EnvFillsMissingFields(t *testing.T) {
   679  	// Profile omits every field the env can fill.
   680  	body := map[string]any{
   681  		"authentication": map[string]any{
   682  			"type": "oidc_federation",
   683  		},
   684  	}
   685  	dir := setupConfigDir(t, "default", body)
   686  	t.Setenv("ANTHROPIC_CONFIG_DIR", dir)
   687  	unsetEnv(t, "ANTHROPIC_PROFILE")
   688  	t.Setenv("ANTHROPIC_ORGANIZATION_ID", "org_from_env")
   689  	t.Setenv("ANTHROPIC_WORKSPACE_ID", "wrkspc_from_env")
   690  	t.Setenv("ANTHROPIC_FEDERATION_RULE_ID", "fdrl_from_env")
   691  	t.Setenv("ANTHROPIC_SERVICE_ACCOUNT_ID", "svac_from_env")
   692  	t.Setenv("ANTHROPIC_SCOPE", "scope_from_env")
   693  	t.Setenv("ANTHROPIC_IDENTITY_TOKEN_FILE", "/tmp/env-token")
   694  
   695  	cfg, err := config.LoadConfig()
   696  	if err != nil {
   697  		t.Fatal(err)
   698  	}
   699  	if cfg.OrganizationID != "org_from_env" {
   700  		t.Errorf("organization_id: got %q, want env fill-in", cfg.OrganizationID)
   701  	}
   702  	if cfg.WorkspaceID != "wrkspc_from_env" {
   703  		t.Errorf("workspace_id: got %q, want env fill-in", cfg.WorkspaceID)
   704  	}
   705  	oidc := cfg.AuthenticationInfo.OIDCFederation
   706  	if oidc.FederationRuleID != "fdrl_from_env" {
   707  		t.Errorf("federation_rule_id: got %q, want env fill-in", oidc.FederationRuleID)
   708  	}
   709  	if oidc.ServiceAccountID != "svac_from_env" {
   710  		t.Errorf("service_account_id: got %q, want env fill-in", oidc.ServiceAccountID)
   711  	}
   712  	if oidc.Scope != "scope_from_env" {
   713  		t.Errorf("scope: got %q, want env fill-in", oidc.Scope)
   714  	}
   715  	if oidc.IdentityToken == nil || oidc.IdentityToken.Path != "/tmp/env-token" {
   716  		t.Errorf("identity_token: got %+v, want env fill-in", oidc.IdentityToken)
   717  	}
   718  }
   719  
   720  func TestLoadConfig_EnvFillsMissingUserOAuthScope(t *testing.T) {
   721  	// Profile omits scope entirely.
   722  	body := userOAuthProfile()
   723  	dir := setupConfigDir(t, "default", body)
   724  	t.Setenv("ANTHROPIC_CONFIG_DIR", dir)
   725  	unsetEnv(t, "ANTHROPIC_PROFILE")
   726  	t.Setenv("ANTHROPIC_SCOPE", "scope_from_env")
   727  
   728  	cfg, err := config.LoadConfig()
   729  	if err != nil {
   730  		t.Fatal(err)
   731  	}
   732  	if cfg.AuthenticationInfo.UserOAuth == nil {
   733  		t.Fatal("expected user_oauth variant")
   734  	}
   735  	if got := cfg.AuthenticationInfo.UserOAuth.Scope; got != "scope_from_env" {
   736  		t.Errorf("user_oauth scope: got %q, want env fill-in", got)
   737  	}
   738  }
   739  
   740  func TestLoadConfig_EnvDoesNotOverrideUserOAuthScope(t *testing.T) {
   741  	body := userOAuthProfile()
   742  	auth := body["authentication"].(map[string]any)
   743  	auth["scope"] = "scope_from_file"
   744  
   745  	dir := setupConfigDir(t, "default", body)
   746  	t.Setenv("ANTHROPIC_CONFIG_DIR", dir)
   747  	unsetEnv(t, "ANTHROPIC_PROFILE")
   748  	t.Setenv("ANTHROPIC_SCOPE", "scope_from_env")
   749  
   750  	cfg, err := config.LoadConfig()
   751  	if err != nil {
   752  		t.Fatal(err)
   753  	}
   754  	if cfg.AuthenticationInfo.UserOAuth == nil {
   755  		t.Fatal("expected user_oauth variant")
   756  	}
   757  	if got := cfg.AuthenticationInfo.UserOAuth.Scope; got != "scope_from_file" {
   758  		t.Errorf("user_oauth scope: got %q, want file value (env must not override)", got)
   759  	}
   760  }
   761  
   762  func TestLoadConfig_EnvOverrideIgnoresEmptyValue(t *testing.T) {
   763  	body := oidcFederationProfile()
   764  	body["base_url"] = "https://file.example.com"
   765  	dir := setupConfigDir(t, "default", body)
   766  	t.Setenv("ANTHROPIC_CONFIG_DIR", dir)
   767  	unsetEnv(t, "ANTHROPIC_PROFILE")
   768  	t.Setenv("ANTHROPIC_BASE_URL", "")
   769  
   770  	cfg, err := config.LoadConfig()
   771  	if err != nil {
   772  		t.Fatal(err)
   773  	}
   774  	if cfg.BaseURL != "https://file.example.com" {
   775  		t.Errorf("empty env var should not clear file field, got %q", cfg.BaseURL)
   776  	}
   777  }
   778  
   779  func TestLoadConfig_XDGConfigHome(t *testing.T) {
   780  	if runtime.GOOS == "windows" {
   781  		t.Skip("XDG not applicable on Windows")
   782  	}
   783  	xdg := t.TempDir()
   784  	configsDir := filepath.Join(xdg, "anthropic", "configs")
   785  	os.MkdirAll(configsDir, 0755)
   786  	body, _ := json.MarshalIndent(oidcFederationProfile(), "", "  ")
   787  	os.WriteFile(filepath.Join(configsDir, "default.json"), body, 0644)
   788  
   789  	unsetEnv(t, "ANTHROPIC_CONFIG_DIR")
   790  	unsetEnv(t, "ANTHROPIC_PROFILE")
   791  	t.Setenv("XDG_CONFIG_HOME", xdg)
   792  	t.Setenv("HOME", t.TempDir()) // ensure fallback path is not used
   793  
   794  	cfg, err := config.LoadConfig()
   795  	if err != nil {
   796  		t.Fatalf("LoadConfig with XDG_CONFIG_HOME set: %v", err)
   797  	}
   798  	if cfg.AuthenticationInfo.Type != config.AuthenticationTypeOIDCFederation {
   799  		t.Errorf("expected to load profile from XDG_CONFIG_HOME, got type %q", cfg.AuthenticationInfo.Type)
   800  	}
   801  }