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  }