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 }