Skip to content

Commit e084320

Browse files
authored
Merge branch 'main' into feat/source-build-formula
2 parents f74a206 + 717e52e commit e084320

5 files changed

Lines changed: 208 additions & 40 deletions

File tree

cmd/auth/auth_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ package auth
33
import (
44
"encoding/base64"
55
"encoding/json"
6+
"path/filepath"
67
"testing"
8+
9+
"github.com/Bandwidth/cli/internal/config"
710
)
811

912
func TestCmdStructure(t *testing.T) {
@@ -153,3 +156,52 @@ func TestParseJWTClaimsInvalidPayload(t *testing.T) {
153156
t.Fatal("expected error for invalid base64 payload")
154157
}
155158
}
159+
160+
// TestRunSwitch_PersistsTargetIntoActiveProfile guards against the bug where
161+
// switch only updated the legacy top-level cfg.AccountID, leaving the active
162+
// profile's AccountID stale — so subsequent commands continued targeting the
163+
// pre-switch account.
164+
func TestRunSwitch_PersistsTargetIntoActiveProfile(t *testing.T) {
165+
home := t.TempDir()
166+
t.Setenv("HOME", home)
167+
// On macOS, UserHomeDir checks HOME first, but ensure XDG_CONFIG_HOME isn't
168+
// pointing somewhere else for this test.
169+
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
170+
171+
cfgPath, err := config.DefaultPath()
172+
if err != nil {
173+
t.Fatal(err)
174+
}
175+
176+
cfg := &config.Config{Format: "json"}
177+
cfg.SetProfile("default", &config.Profile{
178+
ClientID: "id1",
179+
AccountID: "ACCT_A",
180+
Accounts: []string{"ACCT_A", "ACCT_B"},
181+
})
182+
if err := config.Save(cfgPath, cfg); err != nil {
183+
t.Fatal(err)
184+
}
185+
186+
if err := runSwitch(nil, []string{"ACCT_B"}); err != nil {
187+
t.Fatalf("runSwitch returned error: %v", err)
188+
}
189+
190+
loaded, err := config.Load(cfgPath)
191+
if err != nil {
192+
t.Fatal(err)
193+
}
194+
195+
p := loaded.Profiles["default"]
196+
if p == nil {
197+
t.Fatal("default profile missing after switch")
198+
}
199+
if p.AccountID != "ACCT_B" {
200+
t.Errorf("profile AccountID after switch = %q, want %q", p.AccountID, "ACCT_B")
201+
}
202+
// Active-profile lookup must agree (this is what subsequent commands consult).
203+
active := loaded.ActiveProfileConfig()
204+
if active.AccountID != "ACCT_B" {
205+
t.Errorf("ActiveProfileConfig().AccountID after switch = %q, want %q", active.AccountID, "ACCT_B")
206+
}
207+
}

cmd/auth/switch.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,20 @@ func runSwitch(cmd *cobra.Command, args []string) error {
105105
return nil
106106
}
107107

108+
// Persist into both the active profile (the source of truth consulted by
109+
// subsequent commands) and the legacy top-level field. Updating only one
110+
// leaves them disagreeing, which silently routes commands to the old
111+
// account.
108112
cfg.AccountID = target
113+
if len(cfg.Profiles) > 0 {
114+
name := cfg.ActiveProfile
115+
if name == "" {
116+
name = "default"
117+
}
118+
if p, ok := cfg.Profiles[name]; ok {
119+
p.AccountID = target
120+
}
121+
}
109122
if err := config.Save(configPath, cfg); err != nil {
110123
return fmt.Errorf("saving config: %w", err)
111124
}

cmd/recording/download.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@ func runDownload(cmd *cobra.Command, args []string) error {
4242
return fmt.Errorf("downloading recording: %w", err)
4343
}
4444

45-
if err := os.WriteFile(downloadOutput, data, 0644); err != nil {
45+
// Recordings can contain customer call audio (PII, financial discussions,
46+
// voicemail). Write owner-only so other local users on shared hosts can't
47+
// read the file.
48+
if err := os.WriteFile(downloadOutput, data, 0600); err != nil {
4649
return fmt.Errorf("writing recording to file: %w", err)
4750
}
4851

internal/config/config.go

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,30 @@ type Config struct {
4242
Environment string `json:"environment,omitempty"`
4343
}
4444

45-
// ActiveProfileConfig returns the active profile, resolving legacy config if needed.
45+
// ActiveProfileConfig returns the active profile with BW_* env overlays applied.
46+
// The returned *Profile is always a fresh copy — mutating it does not affect
47+
// stored profiles, so writers (Load → Save flows) cannot accidentally leak
48+
// session env values onto disk.
4649
func (c *Config) ActiveProfileConfig() *Profile {
47-
// If we have profiles, use the active one
50+
base := c.activeStoredProfile()
51+
overlay := *base
52+
if v := os.Getenv("BW_CLIENT_ID"); v != "" {
53+
overlay.ClientID = v
54+
}
55+
if v := os.Getenv("BW_ACCOUNT_ID"); v != "" {
56+
overlay.AccountID = v
57+
}
58+
if v := os.Getenv("BW_ENVIRONMENT"); v != "" {
59+
overlay.Environment = v
60+
}
61+
return &overlay
62+
}
63+
64+
// activeStoredProfile returns the profile as persisted on disk, with no env
65+
// overlays. It returns a pointer into c.Profiles when one exists, or a synthetic
66+
// profile from the legacy top-level fields. Callers must not mutate the result
67+
// unless they are intentionally rewriting stored state.
68+
func (c *Config) activeStoredProfile() *Profile {
4869
if len(c.Profiles) > 0 {
4970
name := c.ActiveProfile
5071
if name == "" {
@@ -53,13 +74,10 @@ func (c *Config) ActiveProfileConfig() *Profile {
5374
if p, ok := c.Profiles[name]; ok {
5475
return p
5576
}
56-
// Active profile doesn't exist — return first available
5777
for _, p := range c.Profiles {
5878
return p
5979
}
6080
}
61-
62-
// Legacy: no profiles map, use top-level fields
6381
return &Profile{
6482
ClientID: c.ClientID,
6583
AccountID: c.AccountID,
@@ -133,8 +151,10 @@ func DefaultPath() (string, error) {
133151
return newPath, nil
134152
}
135153

136-
// Load reads config from path, overlays env vars, and returns defaults if the
137-
// file does not exist. The default format is "json".
154+
// Load reads config from path and returns defaults if the file does not exist.
155+
// The default format is "json". BW_* env overlays are applied at read time by
156+
// ActiveProfileConfig, never mutated into stored fields here — otherwise a
157+
// later Save would persist the session value onto disk.
138158
func Load(path string) (*Config, error) {
139159
cfg := &Config{Format: "json"}
140160

@@ -149,24 +169,6 @@ func Load(path string) (*Config, error) {
149169
}
150170
}
151171

152-
// Overlay environment variables on the active profile
153-
p := cfg.ActiveProfileConfig()
154-
if v := os.Getenv("BW_CLIENT_ID"); v != "" {
155-
p.ClientID = v
156-
cfg.ClientID = v
157-
}
158-
if v := os.Getenv("BW_ACCOUNT_ID"); v != "" {
159-
p.AccountID = v
160-
cfg.AccountID = v
161-
}
162-
if v := os.Getenv("BW_FORMAT"); v != "" {
163-
cfg.Format = v
164-
}
165-
if v := os.Getenv("BW_ENVIRONMENT"); v != "" {
166-
p.Environment = v
167-
cfg.Environment = v
168-
}
169-
170172
return cfg, nil
171173
}
172174

internal/config/config_test.go

Lines changed: 112 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,18 @@ func TestEnvVarOverride(t *testing.T) {
123123
t.Fatalf("Load() error: %v", err)
124124
}
125125

126-
if cfg.AccountID != "FROM_ENV" {
127-
t.Errorf("AccountID = %q, want %q (env override)", cfg.AccountID, "FROM_ENV")
126+
// Env overlay is applied at read time via ActiveProfileConfig,
127+
// not mutated into stored fields during Load.
128+
p := cfg.ActiveProfileConfig()
129+
if p.AccountID != "FROM_ENV" {
130+
t.Errorf("ActiveProfileConfig().AccountID = %q, want %q (env override)", p.AccountID, "FROM_ENV")
131+
}
132+
if p.ClientID != "FROM_FILE" {
133+
t.Errorf("ActiveProfileConfig().ClientID = %q, want %q", p.ClientID, "FROM_FILE")
128134
}
129-
// Other fields should still come from file
130-
if cfg.ClientID != "FROM_FILE" {
131-
t.Errorf("ClientID = %q, want %q", cfg.ClientID, "FROM_FILE")
135+
// Stored fields must remain untouched so Save can't leak env values to disk.
136+
if cfg.AccountID != "ACC_FROM_FILE" {
137+
t.Errorf("stored cfg.AccountID = %q, want %q (Load must not mutate stored fields)", cfg.AccountID, "ACC_FROM_FILE")
132138
}
133139
}
134140

@@ -326,30 +332,122 @@ func TestAllEnvVarOverrides(t *testing.T) {
326332
path := filepath.Join(dir, "config.json")
327333

328334
base := &Config{Format: "json"}
335+
base.SetProfile("default", &Profile{ClientID: "fileclientid", AccountID: "fileaccount", Environment: "prod"})
329336
if err := Save(path, base); err != nil {
330337
t.Fatalf("Save() error: %v", err)
331338
}
332339

333340
t.Setenv("BW_CLIENT_ID", "envclientid")
334341
t.Setenv("BW_ACCOUNT_ID", "envaccount")
335-
t.Setenv("BW_FORMAT", "table")
336342
t.Setenv("BW_ENVIRONMENT", "custom")
337343

338344
cfg, err := Load(path)
339345
if err != nil {
340346
t.Fatalf("Load() error: %v", err)
341347
}
342348

343-
if cfg.ClientID != "envclientid" {
344-
t.Errorf("ClientID = %q, want %q", cfg.ClientID, "envclientid")
349+
p := cfg.ActiveProfileConfig()
350+
if p.ClientID != "envclientid" {
351+
t.Errorf("ActiveProfileConfig().ClientID = %q, want %q", p.ClientID, "envclientid")
345352
}
346-
if cfg.AccountID != "envaccount" {
347-
t.Errorf("AccountID = %q, want %q", cfg.AccountID, "envaccount")
353+
if p.AccountID != "envaccount" {
354+
t.Errorf("ActiveProfileConfig().AccountID = %q, want %q", p.AccountID, "envaccount")
348355
}
349-
if cfg.Format != "table" {
350-
t.Errorf("Format = %q, want %q", cfg.Format, "table")
356+
if p.Environment != "custom" {
357+
t.Errorf("ActiveProfileConfig().Environment = %q, want %q", p.Environment, "custom")
351358
}
352-
if cfg.Environment != "custom" {
353-
t.Errorf("Environment = %q, want %q", cfg.Environment, "custom")
359+
360+
// Stored profile must remain untouched.
361+
stored := cfg.Profiles["default"]
362+
if stored.ClientID != "fileclientid" || stored.AccountID != "fileaccount" || stored.Environment != "prod" {
363+
t.Errorf("stored profile mutated by Load: %+v", stored)
364+
}
365+
}
366+
367+
// TestLoad_EnvOverlayDoesNotPersistOntoStoredProfiles guards against the
368+
// regression where Load applied env vars to the live *Profile pointer in
369+
// cfg.Profiles, so that any subsequent Save (login, switch, etc.) would
370+
// silently rewrite the previously-active profile on disk with env values.
371+
func TestLoad_EnvOverlayDoesNotPersistOntoStoredProfiles(t *testing.T) {
372+
dir := t.TempDir()
373+
path := filepath.Join(dir, "config.json")
374+
375+
cfg := &Config{Format: "json"}
376+
cfg.SetProfile("prod", &Profile{ClientID: "prod-id", AccountID: "ACCT_A", Environment: "prod"})
377+
cfg.SetProfile("dev", &Profile{ClientID: "dev-id", AccountID: "ACCT_B", Environment: "test"})
378+
cfg.ActiveProfile = "prod"
379+
if err := Save(path, cfg); err != nil {
380+
t.Fatal(err)
381+
}
382+
383+
t.Setenv("BW_ACCOUNT_ID", "ENV_ACCT_Z")
384+
t.Setenv("BW_CLIENT_ID", "ENV_CLIENT_Z")
385+
t.Setenv("BW_ENVIRONMENT", "ENV_HOST_Z")
386+
387+
// Simulate a writer flow: Load → mutate something unrelated → Save.
388+
loaded, err := Load(path)
389+
if err != nil {
390+
t.Fatal(err)
391+
}
392+
loaded.ActiveProfile = "dev"
393+
if err := Save(path, loaded); err != nil {
394+
t.Fatal(err)
395+
}
396+
397+
// Re-read with env vars cleared to see only what was persisted.
398+
t.Setenv("BW_ACCOUNT_ID", "")
399+
t.Setenv("BW_CLIENT_ID", "")
400+
t.Setenv("BW_ENVIRONMENT", "")
401+
402+
fresh, err := Load(path)
403+
if err != nil {
404+
t.Fatal(err)
405+
}
406+
407+
prod := fresh.Profiles["prod"]
408+
if prod.AccountID != "ACCT_A" || prod.ClientID != "prod-id" || prod.Environment != "prod" {
409+
t.Errorf("prod profile leaked env values: %+v", prod)
410+
}
411+
dev := fresh.Profiles["dev"]
412+
if dev.AccountID != "ACCT_B" || dev.ClientID != "dev-id" || dev.Environment != "test" {
413+
t.Errorf("dev profile leaked env values: %+v", dev)
414+
}
415+
}
416+
417+
func TestActiveProfileConfig_AppliesEnvOverlay(t *testing.T) {
418+
cfg := &Config{}
419+
cfg.SetProfile("default", &Profile{ClientID: "id1", AccountID: "ACCT_A", Environment: "prod"})
420+
421+
t.Setenv("BW_ACCOUNT_ID", "ENV_ACCT_Z")
422+
t.Setenv("BW_CLIENT_ID", "ENV_CLIENT_Z")
423+
t.Setenv("BW_ENVIRONMENT", "test")
424+
425+
p := cfg.ActiveProfileConfig()
426+
if p.AccountID != "ENV_ACCT_Z" {
427+
t.Errorf("AccountID = %q, want %q", p.AccountID, "ENV_ACCT_Z")
428+
}
429+
if p.ClientID != "ENV_CLIENT_Z" {
430+
t.Errorf("ClientID = %q, want %q", p.ClientID, "ENV_CLIENT_Z")
431+
}
432+
if p.Environment != "test" {
433+
t.Errorf("Environment = %q, want %q", p.Environment, "test")
434+
}
435+
436+
// Stored profile must not be mutated by ActiveProfileConfig.
437+
stored := cfg.Profiles["default"]
438+
if stored.AccountID != "ACCT_A" || stored.ClientID != "id1" || stored.Environment != "prod" {
439+
t.Errorf("stored profile mutated by ActiveProfileConfig: %+v", stored)
440+
}
441+
}
442+
443+
func TestActiveProfileConfig_ReturnsCopySafeToMutate(t *testing.T) {
444+
cfg := &Config{}
445+
cfg.SetProfile("default", &Profile{ClientID: "id1", AccountID: "ACCT_A"})
446+
447+
p := cfg.ActiveProfileConfig()
448+
p.AccountID = "MUTATED"
449+
450+
if cfg.Profiles["default"].AccountID != "ACCT_A" {
451+
t.Errorf("mutating ActiveProfileConfig() result leaked into stored profile: %q", cfg.Profiles["default"].AccountID)
354452
}
355453
}

0 commit comments

Comments
 (0)