Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions cmd/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,55 @@ func TestCustomerProfilesMatcherNotOverBroad(t *testing.T) {
}
}

// TestStatusPlainAuthenticatedViaEnvSecret guards against the headless/CI
// path being broken: `band auth login` accepts BW_CLIENT_ID/BW_CLIENT_SECRET
// with no keychain available (see cmdutil.loadConfigAndAuth), so `auth
// status` must report authenticated=true under the same env var rather than
// consulting the keychain alone — otherwise a caller with no keychain sees
// "not authenticated" immediately before every other command succeeds using
// that same env var.
func TestStatusPlainAuthenticatedViaEnvSecret(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("BW_CLIENT_SECRET", "some-secret-not-in-any-keychain")

cfgPath, err := config.DefaultPath()
if err != nil {
t.Fatal(err)
}
cfg := &config.Config{Format: "json"}
cfg.SetProfile("default", &config.Profile{
ClientID: "id-with-no-keychain-entry",
AccountID: "ACCT_A",
})
if err := config.Save(cfgPath, cfg); err != nil {
t.Fatal(err)
}

wrap := &cobra.Command{Use: "status", RunE: runStatus}
root := testutil.NewTestRoot(wrap)
root.SetArgs([]string{"status", "--plain"})

out := testutil.CaptureStdout(t, func() {
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
})

var got statusJSON
if err := json.Unmarshal([]byte(out), &got); err != nil {
t.Fatalf("unmarshal output: %v\noutput: %s", err, out)
}

if !got.Authenticated {
t.Errorf("Authenticated = false with BW_CLIENT_SECRET set and no keychain entry, want true. Error field: %q", got.Error)
}
if got.Error != "" {
t.Errorf("Error = %q, want empty when authenticated via env var", got.Error)
}
}

// TestRunSwitch_PersistsTargetIntoActiveProfile guards against the bug where
// switch only updated the legacy top-level cfg.AccountID, leaving the active
// profile's AccountID stale — so subsequent commands continued targeting the
Expand Down
11 changes: 8 additions & 3 deletions cmd/auth/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,17 @@ func runStatus(cmd *cobra.Command, args []string) error {
profileName = "default"
}

// BW_CLIENT_SECRET satisfies auth the same way it does for every other
// command (see cmdutil.loadConfigAndAuth) — status must agree with what
// commands will actually do, or a headless caller with the env var set
// sees "not authenticated" right before every other command succeeds.
_, keychainErr := intauth.GetPassword(p.ClientID)
authenticated := keychainErr == nil || os.Getenv("BW_CLIENT_SECRET") != ""

if plain {
caps := Capabilities(p.Roles)
out := statusJSON{
Authenticated: keychainErr == nil,
Authenticated: authenticated,
Profile: profileName,
ClientID: p.ClientID,
AccountID: p.AccountID,
Expand All @@ -99,13 +104,13 @@ func runStatus(cmd *cobra.Command, args []string) error {
SIP: sipCapability(hasRole(p.Roles, "sip credentials")),
TenDLC: tendlcCapability(caps["campaign_management"]),
}
if keychainErr != nil {
if !authenticated {
out.Error = "credentials not found in keychain"
}
return emitJSON(out)
}

if keychainErr != nil {
if !authenticated {
fmt.Printf("Client ID: %s\n", ui.ID(p.ClientID))
fmt.Printf("Account: %s\n", ui.ID(p.AccountID))
// Show environment only when it's informative.
Expand Down
15 changes: 14 additions & 1 deletion internal/cmdutil/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,22 @@ func loadConfigAndAuth() (*config.Config, *config.Profile, string, error) {
return nil, nil, "", fmt.Errorf("not logged in — run `band auth login` first")
}

// BW_CLIENT_SECRET, like BW_CLIENT_ID (already overlaid into p by
// ActiveProfileConfig), lets headless/CI callers skip the OS keychain
// entirely. This matters in practice: the keychain backend (go-keyring)
// needs a running D-Bus session + keyring daemon, which `band auth
// login` can complete without (it only stores), but which many headless
// Linux hosts don't have at all. Without this fallback, `auth login`
// would succeed via the env var while every subsequent command failed
// looking up the keychain — the documented "headless and CI/CD" flow
// wouldn't actually hold together end to end.
if secret := os.Getenv("BW_CLIENT_SECRET"); secret != "" {
return cfg, p, secret, nil
}

clientSecret, err := auth.GetPassword(p.ClientID)
if err != nil {
return nil, nil, "", fmt.Errorf("credentials not found in keychain for %s — run `band auth login`", p.ClientID)
return nil, nil, "", fmt.Errorf("credentials not found in keychain for %s — run `band auth login`, or set BW_CLIENT_ID/BW_CLIENT_SECRET env vars for headless use", p.ClientID)
}

return cfg, p, clientSecret, nil
Expand Down
70 changes: 69 additions & 1 deletion internal/cmdutil/helpers_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,74 @@
package cmdutil

import "testing"
import (
"path/filepath"
"testing"

"github.com/Bandwidth/cli/internal/config"
)

// TestLoadConfigAndAuth_BW_CLIENT_SECRET_SkipsKeychain guards the headless/CI
// path: `band auth login --client-id X --client-secret Y` succeeds with no
// keychain available (it only verifies + writes config.json; storing the
// secret in the OS keychain is a separate, best-effort step). Without this
// fallback, every command *after* login would fail with "credentials not
// found in keychain" on a host with no D-Bus/keyring stack, even though the
// same BW_CLIENT_SECRET the caller already has would work fine.
func TestLoadConfigAndAuth_BW_CLIENT_SECRET_SkipsKeychain(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("BW_CLIENT_SECRET", "headless-secret-not-in-any-keychain")

cfgPath, err := config.DefaultPath()
if err != nil {
t.Fatal(err)
}
cfg := &config.Config{Format: "json"}
cfg.SetProfile("default", &config.Profile{
ClientID: "id-with-no-keychain-entry",
AccountID: "ACCT_A",
})
if err := config.Save(cfgPath, cfg); err != nil {
t.Fatal(err)
}

_, _, secret, err := loadConfigAndAuth()
if err != nil {
t.Fatalf("loadConfigAndAuth() error = %v, want nil (BW_CLIENT_SECRET should bypass the keychain lookup)", err)
}
if secret != "headless-secret-not-in-any-keychain" {
t.Errorf("secret = %q, want the BW_CLIENT_SECRET env var value", secret)
}
}

// TestLoadConfigAndAuth_NoSecretNoKeychain_ReturnsActionableError guards the
// other side: without BW_CLIENT_SECRET and with no keychain entry, the error
// must still mention both remediation paths, not just `band auth login`.
func TestLoadConfigAndAuth_NoSecretNoKeychain_ReturnsActionableError(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("BW_CLIENT_SECRET", "")

cfgPath, err := config.DefaultPath()
if err != nil {
t.Fatal(err)
}
cfg := &config.Config{Format: "json"}
cfg.SetProfile("default", &config.Profile{
ClientID: "id-with-no-keychain-entry",
AccountID: "ACCT_A",
})
if err := config.Save(cfgPath, cfg); err != nil {
t.Fatal(err)
}

_, _, _, err = loadConfigAndAuth()
if err == nil {
t.Fatal("loadConfigAndAuth() error = nil, want an error with no keychain entry and no BW_CLIENT_SECRET")
}
}

func TestVoiceHostForEnvironment(t *testing.T) {
tests := []struct {
Expand Down
Loading