github.com/anthropics/anthropic-sdk-go@v1.70.1/config/writers_test.go (about) 1 package config_test 2 3 import ( 4 "encoding/json" 5 "os" 6 "path/filepath" 7 "reflect" 8 "runtime" 9 "strings" 10 "testing" 11 "time" 12 13 "github.com/anthropics/anthropic-sdk-go/config" 14 ) 15 16 func TestDefaultDir_HonorsEnv(t *testing.T) { 17 dir := t.TempDir() 18 t.Setenv("ANTHROPIC_CONFIG_DIR", dir) 19 if got := config.DefaultDir(); got != dir { 20 t.Errorf("DefaultDir() = %q, want %q", got, dir) 21 } 22 } 23 24 func TestPathHelpers(t *testing.T) { 25 dir := "/etc/anthropic" 26 want := map[string]string{ 27 "profile": filepath.Join(dir, "configs", "work.json"), 28 "credentials": filepath.Join(dir, "credentials", "work.json"), 29 "active": filepath.Join(dir, "active_config"), 30 } 31 if got := config.ProfilePath(dir, "work"); got != want["profile"] { 32 t.Errorf("ProfilePath = %q, want %q", got, want["profile"]) 33 } 34 if got := config.ProfileCredentialsPath(dir, "work"); got != want["credentials"] { 35 t.Errorf("ProfileCredentialsPath = %q, want %q", got, want["credentials"]) 36 } 37 if got := config.ActiveConfigPath(dir); got != want["active"] { 38 t.Errorf("ActiveConfigPath = %q, want %q", got, want["active"]) 39 } 40 } 41 42 func TestDirHelpers_MatchPerProfileParents(t *testing.T) { 43 dir := "/etc/anthropic" 44 // ProfilesDir / CredentialsDir must be exactly the parent of the 45 // per-profile paths so callers that previously derived the directory via 46 // filepath.Dir(ProfilePath(dir, "_")) get the same result. 47 if got, want := config.ProfilesDir(dir), filepath.Dir(config.ProfilePath(dir, "x")); got != want { 48 t.Errorf("ProfilesDir = %q, want %q", got, want) 49 } 50 if got, want := config.CredentialsDir(dir), filepath.Dir(config.ProfileCredentialsPath(dir, "x")); got != want { 51 t.Errorf("CredentialsDir = %q, want %q", got, want) 52 } 53 } 54 55 func TestLoadProfile(t *testing.T) { 56 dir := t.TempDir() 57 // Point env resolution at a different profile so we prove LoadProfile 58 // bypasses ANTHROPIC_PROFILE / active_config. 59 t.Setenv("ANTHROPIC_PROFILE", "not-the-one-we-load") 60 61 cfg := &config.Config{ 62 BaseURL: "https://api.example.com", 63 OrganizationID: "org_abc", 64 AuthenticationInfo: config.NewUserOAuthAuthentication("client-xyz"), 65 } 66 if err := config.SaveProfile(dir, "work", cfg); err != nil { 67 t.Fatal(err) 68 } 69 70 loaded, err := config.LoadProfile(dir, "work") 71 if err != nil { 72 t.Fatal(err) 73 } 74 if loaded.BaseURL != "https://api.example.com" { 75 t.Errorf("BaseURL = %q", loaded.BaseURL) 76 } 77 if loaded.OrganizationID != "org_abc" { 78 t.Errorf("OrganizationID = %q", loaded.OrganizationID) 79 } 80 if loaded.AuthenticationInfo == nil || loaded.AuthenticationInfo.UserOAuth == nil || 81 loaded.AuthenticationInfo.UserOAuth.ClientID != "client-xyz" { 82 t.Errorf("AuthenticationInfo round-trip: %+v", loaded.AuthenticationInfo) 83 } 84 wantCreds := config.ProfileCredentialsPath(dir, "work") 85 if loaded.AuthenticationInfo.CredentialsPath != wantCreds { 86 t.Errorf("CredentialsPath = %q, want defaulted %q", 87 loaded.AuthenticationInfo.CredentialsPath, wantCreds) 88 } 89 90 if _, err := config.LoadProfile(dir, "missing"); err == nil { 91 t.Error("expected error for missing profile") 92 } 93 } 94 95 func TestListProfiles(t *testing.T) { 96 t.Run("nonexistent dir", func(t *testing.T) { 97 got, err := config.ListProfiles(filepath.Join(t.TempDir(), "never-created")) 98 if err != nil { 99 t.Fatalf("err = %v, want nil", err) 100 } 101 if len(got) != 0 { 102 t.Errorf("got %v, want empty", got) 103 } 104 }) 105 106 t.Run("empty configs dir", func(t *testing.T) { 107 dir := t.TempDir() 108 os.MkdirAll(filepath.Join(dir, "configs"), 0755) 109 got, err := config.ListProfiles(dir) 110 if err != nil { 111 t.Fatalf("err = %v, want nil", err) 112 } 113 if len(got) != 0 { 114 t.Errorf("got %v, want empty", got) 115 } 116 }) 117 118 t.Run("populated and sorted", func(t *testing.T) { 119 dir := t.TempDir() 120 cfg := &config.Config{AuthenticationInfo: config.NewUserOAuthAuthentication("c")} 121 // Save out of order to prove the result is sorted. 122 for _, name := range []string{"work", "default", "staging"} { 123 if err := config.SaveProfile(dir, name, cfg); err != nil { 124 t.Fatal(err) 125 } 126 } 127 // Noise that must be ignored: a .tmp leftover and a stray subdir. 128 os.WriteFile(filepath.Join(dir, "configs", "scratch.json.tmp"), []byte("{}"), 0644) 129 os.Mkdir(filepath.Join(dir, "configs", "subdir"), 0755) 130 131 got, err := config.ListProfiles(dir) 132 if err != nil { 133 t.Fatal(err) 134 } 135 want := []string{"default", "staging", "work"} 136 if !reflect.DeepEqual(got, want) { 137 t.Errorf("got %v, want %v", got, want) 138 } 139 }) 140 } 141 142 func TestSaveProfile_WritesFileWithMode0644(t *testing.T) { 143 dir := t.TempDir() 144 cfg := &config.Config{ 145 BaseURL: "https://api.example.com", 146 OrganizationID: "org_123", 147 AuthenticationInfo: config.NewUserOAuthAuthentication("client-xyz"), 148 } 149 if err := config.SaveProfile(dir, "work", cfg); err != nil { 150 t.Fatal(err) 151 } 152 153 target := config.ProfilePath(dir, "work") 154 info, err := os.Stat(target) 155 if err != nil { 156 t.Fatal(err) 157 } 158 if runtime.GOOS != "windows" { 159 if mode := info.Mode().Perm(); mode != 0644 { 160 t.Errorf("config file mode = %o, want 0644 (configs are non-secret)", mode) 161 } 162 } 163 // Parent directory should also be public (0755). 164 if runtime.GOOS != "windows" { 165 parentInfo, err := os.Stat(filepath.Dir(target)) 166 if err != nil { 167 t.Fatal(err) 168 } 169 if mode := parentInfo.Mode().Perm(); mode != 0755 { 170 t.Errorf("configs/ dir mode = %o, want 0755", mode) 171 } 172 } 173 174 data, err := os.ReadFile(target) 175 if err != nil { 176 t.Fatal(err) 177 } 178 var decoded config.Config 179 if err := json.Unmarshal(data, &decoded); err != nil { 180 t.Fatalf("re-decode: %v", err) 181 } 182 if decoded.Version != config.ConfigFileVersion { 183 t.Errorf("Version = %q, want %q (SaveProfile must stamp it on write)", 184 decoded.Version, config.ConfigFileVersion) 185 } 186 if decoded.BaseURL != "https://api.example.com" { 187 t.Errorf("BaseURL round-trip: %q", decoded.BaseURL) 188 } 189 if decoded.AuthenticationInfo == nil || decoded.AuthenticationInfo.UserOAuth == nil || 190 decoded.AuthenticationInfo.UserOAuth.ClientID != "client-xyz" { 191 t.Errorf("UserOAuth round-trip: %+v", decoded.AuthenticationInfo) 192 } 193 if _, err := os.Stat(target + ".tmp"); !os.IsNotExist(err) { 194 t.Errorf("expected no .tmp leftover, stat err = %v", err) 195 } 196 } 197 198 func TestSaveProfile_LoadConfigRoundTrip(t *testing.T) { 199 dir := t.TempDir() 200 t.Setenv("ANTHROPIC_CONFIG_DIR", dir) 201 t.Setenv("ANTHROPIC_PROFILE", "rt") 202 203 original := &config.Config{ 204 BaseURL: "https://rt.example.com", 205 OrganizationID: "org_rt", 206 WorkspaceID: "wrkspc_rt", 207 AuthenticationInfo: config.NewOIDCFederationAuthentication(config.OIDCFederation{ 208 FederationRuleID: "fdrl_rt", 209 ServiceAccountID: "svac_rt", 210 IdentityToken: &config.IdentityTokenConfig{ 211 Source: config.IdentityTokenSourceFile, 212 Path: "/tmp/token", 213 }, 214 Scope: "user:inference", 215 }), 216 } 217 218 if err := config.SaveProfile(dir, "rt", original); err != nil { 219 t.Fatal(err) 220 } 221 loaded, err := config.LoadConfig() 222 if err != nil { 223 t.Fatal(err) 224 } 225 if loaded.BaseURL != original.BaseURL || 226 loaded.OrganizationID != original.OrganizationID || 227 loaded.WorkspaceID != original.WorkspaceID { 228 t.Errorf("top-level fields diverged: got %+v", loaded) 229 } 230 if loaded.AuthenticationInfo == nil || loaded.AuthenticationInfo.OIDCFederation == nil { 231 t.Fatal("OIDCFederation missing after load") 232 } 233 if loaded.AuthenticationInfo.OIDCFederation.FederationRuleID != "fdrl_rt" { 234 t.Errorf("FederationRuleID: %q", loaded.AuthenticationInfo.OIDCFederation.FederationRuleID) 235 } 236 if loaded.AuthenticationInfo.OIDCFederation.Scope != "user:inference" { 237 t.Errorf("Scope round-trip: %q", loaded.AuthenticationInfo.OIDCFederation.Scope) 238 } 239 } 240 241 // TestSaveProfile_LoadSaveKeepsCredentialsPathBlank verifies that a 242 // load → save cycle doesn't rewrite a blank credentials_path into an 243 // absolute path pinned to the current $HOME. LoadConfig defaults the 244 // field at read time for the SDK's own consumers; SaveProfile must 245 // strip that default back out before writing so the on-disk file stays 246 // relocatable. 247 func TestSaveProfile_LoadSaveKeepsCredentialsPathBlank(t *testing.T) { 248 dir := t.TempDir() 249 t.Setenv("ANTHROPIC_CONFIG_DIR", dir) 250 t.Setenv("ANTHROPIC_PROFILE", "portable") 251 252 // Initial save: caller never sets CredentialsPath. 253 original := &config.Config{ 254 OrganizationID: "org_portable", 255 AuthenticationInfo: config.NewUserOAuthAuthentication("client-xyz"), 256 } 257 if err := config.SaveProfile(dir, "portable", original); err != nil { 258 t.Fatal(err) 259 } 260 261 // Load populates the field with the default absolute path. 262 loaded, err := config.LoadConfig() 263 if err != nil { 264 t.Fatal(err) 265 } 266 if loaded.AuthenticationInfo.CredentialsPath == "" { 267 t.Fatal("LoadConfig should populate the default CredentialsPath for runtime use") 268 } 269 270 // Save again — the default path must not be persisted back. 271 if err := config.SaveProfile(dir, "portable", loaded); err != nil { 272 t.Fatal(err) 273 } 274 275 data, err := os.ReadFile(config.ProfilePath(dir, "portable")) 276 if err != nil { 277 t.Fatal(err) 278 } 279 var raw map[string]any 280 if err := json.Unmarshal(data, &raw); err != nil { 281 t.Fatal(err) 282 } 283 auth, _ := raw["authentication"].(map[string]any) 284 if _, present := auth["credentials_path"]; present { 285 t.Errorf("credentials_path should be omitted on write, got: %s", string(data)) 286 } 287 } 288 289 // TestSaveProfile_ExplicitCredentialsPathPreserved asserts that a caller 290 // who deliberately sets a non-default CredentialsPath still sees it 291 // round-trip through the save path. 292 func TestSaveProfile_ExplicitCredentialsPathPreserved(t *testing.T) { 293 dir := t.TempDir() 294 cfg := &config.Config{ 295 AuthenticationInfo: &config.AuthenticationInfo{ 296 Type: config.AuthenticationTypeUserOAuth, 297 CredentialsPath: "/custom/explicit/path.json", 298 UserOAuth: &config.UserOAuth{ClientID: "c"}, 299 }, 300 } 301 if err := config.SaveProfile(dir, "explicit", cfg); err != nil { 302 t.Fatal(err) 303 } 304 data, err := os.ReadFile(config.ProfilePath(dir, "explicit")) 305 if err != nil { 306 t.Fatal(err) 307 } 308 var raw map[string]any 309 if err := json.Unmarshal(data, &raw); err != nil { 310 t.Fatal(err) 311 } 312 auth, _ := raw["authentication"].(map[string]any) 313 if auth["credentials_path"] != "/custom/explicit/path.json" { 314 t.Errorf("explicit credentials_path lost: %v", auth["credentials_path"]) 315 } 316 // And the caller's original struct must not be mutated by the save. 317 if cfg.AuthenticationInfo.CredentialsPath != "/custom/explicit/path.json" { 318 t.Errorf("SaveProfile mutated caller's cfg: %q", cfg.AuthenticationInfo.CredentialsPath) 319 } 320 } 321 322 func TestSaveProfile_Validation(t *testing.T) { 323 dir := t.TempDir() 324 good := &config.Config{AuthenticationInfo: config.NewUserOAuthAuthentication("c")} 325 326 cases := []struct { 327 name string 328 dir string 329 profile string 330 cfg *config.Config 331 }{ 332 {"empty dir", "", "work", good}, 333 {"traversal profile", dir, "../evil", good}, 334 {"nil cfg", dir, "work", nil}, 335 {"missing auth", dir, "work", &config.Config{}}, 336 } 337 for _, tc := range cases { 338 t.Run(tc.name, func(t *testing.T) { 339 if err := config.SaveProfile(tc.dir, tc.profile, tc.cfg); err == nil { 340 t.Error("expected error") 341 } 342 }) 343 } 344 } 345 346 func TestWriteCredentials_WritesFileWithMode0600(t *testing.T) { 347 dir := t.TempDir() 348 path := config.ProfileCredentialsPath(dir, "myprof") 349 350 expiry := time.Now().Add(time.Hour).Truncate(time.Second) 351 creds := config.Credentials{ 352 AccessToken: "access-123", 353 RefreshToken: "refresh-456", 354 ExpiresAt: &expiry, 355 } 356 if err := config.WriteCredentials(path, creds); err != nil { 357 t.Fatal(err) 358 } 359 360 info, err := os.Stat(path) 361 if err != nil { 362 t.Fatal(err) 363 } 364 if runtime.GOOS != "windows" { 365 if mode := info.Mode().Perm(); mode != 0600 { 366 t.Errorf("credentials file mode = %o, want 0600 (credentials are secret)", mode) 367 } 368 parentInfo, err := os.Stat(filepath.Dir(path)) 369 if err != nil { 370 t.Fatal(err) 371 } 372 if mode := parentInfo.Mode().Perm(); mode != 0700 { 373 t.Errorf("credentials/ dir mode = %o, want 0700", mode) 374 } 375 } 376 377 data, err := os.ReadFile(path) 378 if err != nil { 379 t.Fatal(err) 380 } 381 var raw map[string]any 382 if err := json.Unmarshal(data, &raw); err != nil { 383 t.Fatal(err) 384 } 385 if raw["version"] != config.CredentialsFileVersion || 386 raw["type"] != "oauth_token" || 387 raw["access_token"] != "access-123" || 388 raw["refresh_token"] != "refresh-456" { 389 t.Errorf("unexpected wire contents: %v", raw) 390 } 391 if raw["expires_at"] == nil { 392 t.Error("expires_at missing") 393 } 394 } 395 396 func TestWriteCredentials_EmptyPath(t *testing.T) { 397 if err := config.WriteCredentials("", config.Credentials{AccessToken: "x"}); err == nil { 398 t.Error("expected error for empty path") 399 } 400 } 401 402 func TestCredentials_RoundTrip(t *testing.T) { 403 now := time.Unix(1_700_000_000, 0) 404 cases := []config.Credentials{ 405 {AccessToken: "a", RefreshToken: "r", ExpiresAt: &now}, 406 {AccessToken: "a"}, 407 {AccessToken: "a", RefreshToken: "r"}, 408 { 409 AccessToken: "a", 410 RefreshToken: "r", 411 ExpiresAt: &now, 412 Scope: "user:inference user:profile", 413 OrganizationUUID: "11111111-2222-3333-4444-555555555555", 414 OrganizationName: "Acme Inc", 415 AccountEmail: "user@example.com", 416 }, 417 } 418 for _, in := range cases { 419 b, err := json.Marshal(in) 420 if err != nil { 421 t.Fatal(err) 422 } 423 var out config.Credentials 424 if err := json.Unmarshal(b, &out); err != nil { 425 t.Fatal(err) 426 } 427 if out.AccessToken != in.AccessToken || out.RefreshToken != in.RefreshToken { 428 t.Errorf("token round-trip failed: in=%+v out=%+v", in, out) 429 } 430 if out.Scope != in.Scope || out.OrganizationUUID != in.OrganizationUUID || 431 out.OrganizationName != in.OrganizationName || out.AccountEmail != in.AccountEmail { 432 t.Errorf("token-time field round-trip failed: in=%+v out=%+v", in, out) 433 } 434 if (in.ExpiresAt == nil) != (out.ExpiresAt == nil) { 435 t.Errorf("expiry presence mismatch: in=%+v out=%+v", in.ExpiresAt, out.ExpiresAt) 436 } 437 if in.ExpiresAt != nil && !out.ExpiresAt.Equal(*in.ExpiresAt) { 438 t.Errorf("expiry value mismatch: in=%v out=%v", in.ExpiresAt, out.ExpiresAt) 439 } 440 } 441 } 442 443 func TestWriteCredentials_RoundTripsTokenTimeFields(t *testing.T) { 444 dir := t.TempDir() 445 path := config.ProfileCredentialsPath(dir, "p") 446 expiry := time.Unix(1_700_000_000, 0) 447 in := config.Credentials{ 448 AccessToken: "tok", 449 RefreshToken: "ref", 450 ExpiresAt: &expiry, 451 Scope: "user:inference", 452 OrganizationUUID: "org-uuid", 453 OrganizationName: "Org Name", 454 AccountEmail: "user@example.com", 455 } 456 if err := config.WriteCredentials(path, in); err != nil { 457 t.Fatal(err) 458 } 459 data, err := os.ReadFile(path) 460 if err != nil { 461 t.Fatal(err) 462 } 463 var out config.Credentials 464 if err := json.Unmarshal(data, &out); err != nil { 465 t.Fatal(err) 466 } 467 // Compare via reflect.DeepEqual after normalizing ExpiresAt zone (the 468 // wire form is unix seconds, so location is dropped). 469 if out.ExpiresAt == nil || !out.ExpiresAt.Equal(*in.ExpiresAt) { 470 t.Fatalf("ExpiresAt round-trip: in=%v out=%v", in.ExpiresAt, out.ExpiresAt) 471 } 472 in.ExpiresAt, out.ExpiresAt = nil, nil 473 if !reflect.DeepEqual(in, out) { 474 t.Errorf("WriteCredentials round-trip mismatch:\n in=%+v\nout=%+v", in, out) 475 } 476 } 477 478 // TestLoadProfile_MissingVersionDecodesAsZero pins the read-side compatibility 479 // contract: a config file written by an older SDK (before the version field 480 // existed) loads cleanly with cfg.Version == "". Future SDKs that bump 481 // ConfigFileVersion can rely on this to distinguish "old file" from "current" 482 // without an error path. 483 func TestLoadProfile_MissingVersionDecodesAsZero(t *testing.T) { 484 dir := t.TempDir() 485 path := config.ProfilePath(dir, "old") 486 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { 487 t.Fatal(err) 488 } 489 // Hand-rolled config JSON without the version key, mimicking a file from 490 // an older SDK release. 491 body := `{"authentication":{"type":"user_oauth","client_id":"cid"}}` 492 if err := os.WriteFile(path, []byte(body), 0o644); err != nil { 493 t.Fatal(err) 494 } 495 t.Setenv("ANTHROPIC_CONFIG_DIR", dir) 496 t.Setenv("ANTHROPIC_PROFILE", "old") 497 cfg, err := config.LoadConfig() 498 if err != nil { 499 t.Fatalf("LoadConfig on no-version file: %v", err) 500 } 501 if cfg.Version != "" { 502 t.Errorf("cfg.Version = %q, want empty (missing-key default)", cfg.Version) 503 } 504 } 505 506 // TestCredentials_UnmarshalMissingVersion pins the equivalent contract for 507 // the credentials file: a no-version file unmarshals without error. The 508 // public Credentials struct does not surface Version (it lives only on the 509 // wire shape), so we just assert the unmarshal succeeds and other fields 510 // populate. 511 func TestCredentials_UnmarshalMissingVersion(t *testing.T) { 512 body := `{"type":"oauth_token","access_token":"tok","refresh_token":"ref"}` 513 var creds config.Credentials 514 if err := json.Unmarshal([]byte(body), &creds); err != nil { 515 t.Fatalf("Unmarshal no-version credentials: %v", err) 516 } 517 if creds.AccessToken != "tok" || creds.RefreshToken != "ref" { 518 t.Errorf("fields not populated: %+v", creds) 519 } 520 } 521 522 func TestSetActiveProfile_WritesPointer(t *testing.T) { 523 dir := t.TempDir() 524 if err := config.SetActiveProfile(dir, "work"); err != nil { 525 t.Fatal(err) 526 } 527 data, err := os.ReadFile(config.ActiveConfigPath(dir)) 528 if err != nil { 529 t.Fatal(err) 530 } 531 if got := string(data); got != "work\n" { 532 t.Errorf("active_config contents = %q", got) 533 } 534 } 535 536 func TestSetActiveProfile_SwitchesLoadedProfile(t *testing.T) { 537 dir := t.TempDir() 538 t.Setenv("ANTHROPIC_CONFIG_DIR", dir) 539 t.Setenv("ANTHROPIC_PROFILE", "") 540 os.Unsetenv("ANTHROPIC_PROFILE") 541 542 for _, name := range []string{"alpha", "beta"} { 543 cfg := &config.Config{ 544 AuthenticationInfo: config.NewUserOAuthAuthentication("cid-" + name), 545 } 546 if err := config.SaveProfile(dir, name, cfg); err != nil { 547 t.Fatal(err) 548 } 549 } 550 551 if err := config.SetActiveProfile(dir, "beta"); err != nil { 552 t.Fatal(err) 553 } 554 loaded, err := config.LoadConfig() 555 if err != nil { 556 t.Fatal(err) 557 } 558 if loaded.AuthenticationInfo.UserOAuth.ClientID != "cid-beta" { 559 t.Errorf("active profile not honoured: %+v", loaded.AuthenticationInfo) 560 } 561 } 562 563 func TestSetActiveProfile_Validation(t *testing.T) { 564 dir := t.TempDir() 565 if err := config.SetActiveProfile("", "work"); err == nil { 566 t.Error("expected error for empty dir") 567 } 568 if err := config.SetActiveProfile(dir, "../evil"); err == nil { 569 t.Error("expected error for traversal profile") 570 } 571 } 572 573 func TestDeleteProfile_RemovesBothFilesAndClearsPointer(t *testing.T) { 574 dir := t.TempDir() 575 os.MkdirAll(filepath.Join(dir, "configs"), 0755) 576 os.MkdirAll(filepath.Join(dir, "credentials"), 0755) 577 os.WriteFile(config.ProfilePath(dir, "work"), []byte("{}"), 0600) 578 os.WriteFile(config.ProfileCredentialsPath(dir, "work"), []byte("{}"), 0600) 579 os.WriteFile(config.ActiveConfigPath(dir), []byte("work\n"), 0644) 580 581 if err := config.DeleteProfile(dir, "work"); err != nil { 582 t.Fatal(err) 583 } 584 for _, p := range []string{ 585 config.ProfilePath(dir, "work"), 586 config.ProfileCredentialsPath(dir, "work"), 587 config.ActiveConfigPath(dir), 588 } { 589 if _, err := os.Stat(p); !os.IsNotExist(err) { 590 t.Errorf("%s still exists (err %v)", p, err) 591 } 592 } 593 } 594 595 func TestDeleteProfile_LeavesOtherPointer(t *testing.T) { 596 dir := t.TempDir() 597 os.MkdirAll(filepath.Join(dir, "configs"), 0755) 598 os.WriteFile(config.ProfilePath(dir, "work"), []byte("{}"), 0600) 599 os.WriteFile(config.ActiveConfigPath(dir), []byte("other\n"), 0644) 600 601 if err := config.DeleteProfile(dir, "work"); err != nil { 602 t.Fatal(err) 603 } 604 data, err := os.ReadFile(config.ActiveConfigPath(dir)) 605 if err != nil { 606 t.Fatal(err) 607 } 608 if string(data) != "other\n" { 609 t.Errorf("active_config was clobbered: %q", data) 610 } 611 } 612 613 func TestDeleteProfile_Idempotent(t *testing.T) { 614 dir := t.TempDir() 615 if err := config.DeleteProfile(dir, "nonexistent"); err != nil { 616 t.Errorf("first delete returned %v", err) 617 } 618 if err := config.DeleteProfile(dir, "nonexistent"); err != nil { 619 t.Errorf("second delete returned %v", err) 620 } 621 } 622 623 // TestWriteCredentials_UniqueTmpFilename verifies that two concurrent 624 // writes to the same target use different sibling tmp filenames so they 625 // don't trample each other. The previous fixed "<path>.tmp" was a race 626 // — a second writer would overwrite the first writer's tmp before its 627 // rename completed. 628 func TestWriteCredentials_UniqueTmpFilename(t *testing.T) { 629 dir := t.TempDir() 630 target := filepath.Join(dir, "creds.json") 631 exp := time.Now().Add(time.Hour) 632 if err := config.WriteCredentials(target, config.Credentials{AccessToken: "a", ExpiresAt: &exp}); err != nil { 633 t.Fatal(err) 634 } 635 // No fixed-name .tmp leftover from the previous behavior. 636 if _, err := os.Stat(target + ".tmp"); !os.IsNotExist(err) { 637 t.Errorf("expected no fixed-name .tmp leftover, stat err = %v", err) 638 } 639 // No leftover .tmp at all (success path cleans them up). 640 entries, err := os.ReadDir(dir) 641 if err != nil { 642 t.Fatal(err) 643 } 644 for _, e := range entries { 645 if filepath.Ext(e.Name()) == ".tmp" { 646 t.Errorf("found .tmp leftover after successful write: %s", e.Name()) 647 } 648 } 649 } 650 651 // TestWriteCredentials_RejectsSymlinkTarget verifies that an attacker who 652 // pre-plants a symlink at the credentials path cannot redirect the write. 653 // The read-side check (checkCredentialsFileSafety) is insufficient on its 654 // own — the write path is the injection vector for an attacker who can 655 // place a symlink before first-write. 656 func TestWriteCredentials_RejectsSymlinkTarget(t *testing.T) { 657 dir := t.TempDir() 658 victim := filepath.Join(dir, "victim.json") 659 if err := os.WriteFile(victim, []byte("{}"), 0600); err != nil { 660 t.Fatal(err) 661 } 662 target := filepath.Join(dir, "creds.json") 663 if err := os.Symlink(victim, target); err != nil { 664 t.Skipf("symlink unsupported on this platform: %v", err) 665 } 666 exp := time.Now().Add(time.Hour) 667 err := config.WriteCredentials(target, config.Credentials{AccessToken: "tok", ExpiresAt: &exp}) 668 if err == nil { 669 t.Fatal("expected error when target is a symlink") 670 } 671 if !strings.Contains(err.Error(), "symlink") { 672 t.Errorf("expected symlink error, got: %v", err) 673 } 674 // Victim must be untouched. 675 got, err := os.ReadFile(victim) 676 if err != nil { 677 t.Fatal(err) 678 } 679 if string(got) != "{}" { 680 t.Errorf("victim file was modified through symlink: %q", got) 681 } 682 // No .tmp debris left behind on the failure path. 683 entries, err := os.ReadDir(dir) 684 if err != nil { 685 t.Fatal(err) 686 } 687 for _, e := range entries { 688 if filepath.Ext(e.Name()) == ".tmp" { 689 t.Errorf("found .tmp debris after symlink rejection: %s", e.Name()) 690 } 691 } 692 }