diff --git a/cmd/auth/check/cmd.go b/cmd/auth/check/cmd.go index 99ca6f273..0139b1686 100644 --- a/cmd/auth/check/cmd.go +++ b/cmd/auth/check/cmd.go @@ -43,33 +43,12 @@ func checkCLICredentials(w io.Writer) bool { return true } - // If env vars were set but invalid, report the error + // If env vars were set but invalid, report the error. Unlike + // EnsureAuthenticated this deliberately skips the "not falling back to + // the stored profile" line: check evaluates the stored profile itself + // right below and reports that result on its own. if !errors.Is(err, auth.ErrEnvCredentialsNotSet) { - if errors.Is(err, context.DeadlineExceeded) { - envDatarobotHost, _ := config.SchemeHostOnly(creds.Endpoint) - - fmt.Fprint(w, tui.BaseTextStyle.Render("❌ Connection to ")) - fmt.Fprint(w, tui.InfoStyle.Render(envDatarobotHost)) - fmt.Fprintln(w, tui.BaseTextStyle.Render(" timed out. Check your network and try again.")) - - return false - } - - // Distinguish a malformed DATAROBOT_ENDPOINT from an invalid token so - // the user fixes the right thing. A quoted endpoint (e.g. from running - // `$(dr auth export)` instead of `eval "$(dr auth export)"`) fails here - // and must not be reported as a token problem. - if epErr := auth.ValidateEndpoint(creds.Endpoint); epErr != nil { - fmt.Fprintln(w, tui.BaseTextStyle.Render("❌ DATAROBOT_ENDPOINT environment variable is invalid: "+epErr.Error())) - fmt.Fprintln(w, tui.BaseTextStyle.Render("Set it to a valid DataRobot URL and try again.")) - } else { - fmt.Fprintln(w, tui.BaseTextStyle.Render("❌ DATAROBOT_API_TOKEN environment variable is invalid or expired.")) - fmt.Fprintln(w, tui.BaseTextStyle.Render("Unset it and try again:")) - fmt.Fprint(w, tui.InfoStyle.Render(" unset DATAROBOT_API_TOKEN")) - fmt.Fprint(w, tui.BaseTextStyle.Render(" (or ")) - fmt.Fprint(w, tui.InfoStyle.Render("Remove-Item Env:\\DATAROBOT_API_TOKEN")) - fmt.Fprintln(w, tui.BaseTextStyle.Render(" on Windows)")) - } + auth.ReportEnvCredentialsError(w, creds, err) return false } diff --git a/docs/commands/auth.md b/docs/commands/auth.md index 75d84b82b..b60e2eed6 100644 --- a/docs/commands/auth.md +++ b/docs/commands/auth.md @@ -170,6 +170,11 @@ $ dr auth check ❌ DATAROBOT_API_TOKEN environment variable is invalid or expired. Unset it and try again: unset DATAROBOT_API_TOKEN (or Remove-Item Env:\DATAROBOT_API_TOKEN on Windows) + +# Unreachable endpoint (the token was never checked, so it is not blamed) +$ dr auth check +❌ Could not connect to https://app.example.com: dial tcp: lookup app.example.com: no such host +Check DATAROBOT_ENDPOINT and your network, then try again. ``` > [!TIP] diff --git a/docs/development/authentication.md b/docs/development/authentication.md index df950f032..6f9fefc7c 100644 --- a/docs/development/authentication.md +++ b/docs/development/authentication.md @@ -29,10 +29,11 @@ var MyCmd = &cobra.Command{ The hook functions are outlined below. -1. **Checks for valid credentials**: Checks if a valid API key already exists. -2. **Auto-configures URL if missing**: If no DataRobot URL is configured, prompts you to set it up. -3. **Retrieves new credentials**: If credentials are missing or expired, the hook automatically triggers the browser-based login flow. -4. **Fails early**: If authentication cannot be established, the command will not run and returns an error. +1. **Checks environment credentials first**: A complete `DATAROBOT_ENDPOINT` (or `DATAROBOT_API_ENDPOINT`) and `DATAROBOT_API_TOKEN` pair takes precedence over the config file. If the pair fails verification, the command fails with the reason (timeout, malformed endpoint, unreachable endpoint, or invalid token). It never falls back to the stored profile and never starts the login flow, because that would silently run the command against a different DataRobot instance than the one requested. +2. **Checks for valid credentials**: With no complete environment pair, checks if a valid API key already exists in the config file. +3. **Auto-configures URL if missing**: If no DataRobot URL is configured, prompts you to set it up. +4. **Retrieves new credentials**: If config-file credentials are missing or expired, the hook automatically triggers the browser-based login flow. +5. **Fails early**: If authentication cannot be established, the command will not run and returns an error. ### Direct call for non-command code diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 7fc15738c..1277b2225 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -18,10 +18,13 @@ import ( "context" "errors" "fmt" + "io" + "net/url" "os" "strings" "testing" + "github.com/charmbracelet/lipgloss" "github.com/datarobot/cli/internal/config" "github.com/datarobot/cli/internal/config/viperx" "github.com/datarobot/cli/internal/log" @@ -45,16 +48,49 @@ var ErrEnvCredentialsNotSet = errors.New("environment credentials not set") // PrintUnsetTokenInstructions prints platform-specific instructions for unsetting DATAROBOT_API_TOKEN. func PrintUnsetTokenInstructions() { - fmt.Print(tui.InfoStyle.Render(" unset DATAROBOT_API_TOKEN")) - fmt.Print(tui.BaseTextStyle.Render(" (or ")) - fmt.Print(tui.InfoStyle.Render("Remove-Item Env:\\DATAROBOT_API_TOKEN")) - fmt.Println(tui.BaseTextStyle.Render(" on Windows)")) + FprintUnsetTokenInstructions(os.Stdout) +} + +// FprintUnsetTokenInstructions writes platform-specific instructions for +// unsetting DATAROBOT_API_TOKEN to w. +func FprintUnsetTokenInstructions(w io.Writer) { + base, info := writerStyles(w) + + fmt.Fprint(w, info.Render(" unset DATAROBOT_API_TOKEN")) + fmt.Fprint(w, base.Render(" (or ")) + fmt.Fprint(w, info.Render("Remove-Item Env:\\DATAROBOT_API_TOKEN")) + fmt.Fprintln(w, base.Render(" on Windows)")) +} + +// writerStyles binds the shared text styles to w's renderer. The package +// defaults probe stdout for color support, so styled text sent to a different +// stream (stderr redirected to a file while stdout is a TTY, or a buffer in +// tests) would otherwise carry ANSI codes the destination cannot display. +func writerStyles(w io.Writer) (base, info lipgloss.Style) { + r := lipgloss.NewRenderer(w) + + return tui.BaseTextStyle.Renderer(r), tui.InfoStyle.Renderer(r) } // EnvCredentials holds environment variable authentication credentials. type EnvCredentials struct { Endpoint string Token string + // EndpointVar is the environment variable Endpoint was read from: + // DATAROBOT_ENDPOINT, or the SDK-style DATAROBOT_API_ENDPOINT fallback. + // Error messages name it so the user fixes the variable that is actually + // set. + EndpointVar string +} + +// endpointVarName returns EndpointVar, defaulting to DATAROBOT_ENDPOINT for +// zero-value credentials constructed outside GetEnvCredentials. +func (c *EnvCredentials) endpointVarName() string { + if c.EndpointVar == "" { + return "DATAROBOT_ENDPOINT" + } + + return c.EndpointVar } // GetEnvCredentials reads DATAROBOT_ENDPOINT and DATAROBOT_API_TOKEN from environment. @@ -66,14 +102,18 @@ type EnvCredentials struct { // need to report why env credentials failed should use ValidateEndpoint to // distinguish a malformed endpoint from an invalid token. func GetEnvCredentials() EnvCredentials { - endpoint := os.Getenv("DATAROBOT_ENDPOINT") + endpointVar := "DATAROBOT_ENDPOINT" + + endpoint := os.Getenv(endpointVar) if endpoint == "" { - endpoint = os.Getenv("DATAROBOT_API_ENDPOINT") + endpointVar = "DATAROBOT_API_ENDPOINT" + endpoint = os.Getenv(endpointVar) } return EnvCredentials{ - Endpoint: endpoint, - Token: os.Getenv("DATAROBOT_API_TOKEN"), + Endpoint: endpoint, + Token: os.Getenv("DATAROBOT_API_TOKEN"), + EndpointVar: endpointVar, } } @@ -93,6 +133,104 @@ func ValidateEndpoint(endpoint string) error { return err } +// ReportEnvCredentialsError writes a classified explanation of why an +// explicitly supplied DATAROBOT_ENDPOINT/DATAROBOT_API_TOKEN pair failed +// verification: a network timeout, a malformed endpoint, an unreachable +// endpoint, or an invalid token. Shared by EnsureAuthenticated (writing to +// stderr) and `dr auth check`. +func ReportEnvCredentialsError(w io.Writer, creds *EnvCredentials, err error) { + base, info := writerStyles(w) + + if errors.Is(err, context.DeadlineExceeded) { + envDatarobotHost, _ := config.SchemeHostOnly(creds.Endpoint) + + fmt.Fprint(w, base.Render("❌ Connection to ")) + fmt.Fprint(w, info.Render(envDatarobotHost)) + fmt.Fprintln(w, base.Render(" timed out. Check your network and try again.")) + + return + } + + // Distinguish a malformed endpoint from an invalid token so the user + // fixes the right thing. A quoted endpoint (e.g. from running + // `$(dr auth export)` instead of `eval "$(dr auth export)"`) fails here + // and must not be reported as a token problem. + if epErr := ValidateEndpoint(creds.Endpoint); epErr != nil { + fmt.Fprintln(w, base.Render("❌ "+creds.endpointVarName()+" environment variable is invalid: "+epErr.Error())) + fmt.Fprintln(w, base.Render("Set it to a valid DataRobot URL and try again.")) + + return + } + + // VerifyToken uses the endpoint value exactly as the user set it, but + // ValidateEndpoint above is more forgiving: it accepts a bare host with + // no scheme and trims stray whitespace. The two checks below catch the + // values that slipped through that gap, so they are reported as a bad + // endpoint instead of as a connection failure naming a URL the CLI never + // actually requested. + var urlErr *url.Error + + hasURLErr := errors.As(err, &urlErr) + + if !strings.Contains(strings.TrimSpace(creds.Endpoint), "://") { + fmt.Fprintln(w, base.Render("❌ "+creds.endpointVarName()+" environment variable is invalid: missing URL scheme (https://...)")) + fmt.Fprintln(w, base.Render("Set it to a valid DataRobot URL and try again.")) + + return + } + + if hasURLErr && urlErr.Op == "parse" { + fmt.Fprintln(w, base.Render("❌ "+creds.endpointVarName()+" environment variable is invalid: "+urlErr.Err.Error())) + fmt.Fprintln(w, base.Render("Set it to a valid DataRobot URL and try again.")) + + return + } + + // A transport-level failure (connection refused, DNS, TLS, proxy) means + // the instance was never reached and the token was never judged, so + // advising the user to unset the token would point them at the wrong + // thing. VerifyToken wraps transport errors in *url.Error; a rejected + // token comes back as a plain error from the HTTP status check. + if hasURLErr { + envDatarobotHost, _ := config.SchemeHostOnly(creds.Endpoint) + + fmt.Fprint(w, base.Render("❌ Could not connect to ")) + fmt.Fprint(w, info.Render(envDatarobotHost)) + fmt.Fprintln(w, base.Render(": "+urlErr.Err.Error())) + fmt.Fprintln(w, base.Render("Check "+creds.endpointVarName()+" and your network, then try again.")) + + return + } + + fmt.Fprintln(w, base.Render("❌ DATAROBOT_API_TOKEN environment variable is invalid or expired.")) + fmt.Fprintln(w, base.Render("Unset it and try again:")) + FprintUnsetTokenInstructions(w) +} + +// reportStoredProfileNotUsed names both sides of the substitution this CLI +// refuses to make: the endpoint the environment asked for and the stored +// profile it will NOT fall back to. Printed only when a stored profile exists, +// since otherwise there is nothing to substitute. +func reportStoredProfileNotUsed(w io.Writer, creds *EnvCredentials) { + storedHost := config.GetBaseURL() + if storedHost == "" { + return + } + + requestedHost := creds.Endpoint + if host, err := config.SchemeHostOnly(creds.Endpoint); err == nil { + requestedHost = host + } + + base, info := writerStyles(w) + + fmt.Fprint(w, base.Render("Environment credentials for ")) + fmt.Fprint(w, info.Render(requestedHost)) + fmt.Fprint(w, base.Render(" failed to verify; not falling back to the stored profile for ")) + fmt.Fprint(w, info.Render(storedHost)) + fmt.Fprintln(w, base.Render(".")) +} + // VerifyEnvCredentials checks if environment variable credentials are valid. // Returns credentials and nil error if valid, credentials and error otherwise. func VerifyEnvCredentials(ctx context.Context) (*EnvCredentials, error) { @@ -107,11 +245,12 @@ func VerifyEnvCredentials(ctx context.Context) (*EnvCredentials, error) { } // EnsureAuthenticatedE checks if valid authentication exists, and if not, -// triggers the login flow automatically. Returns an error if authentication -// fails, suitable for use in Cobra PreRunE hooks. +// triggers the login flow automatically (see EnsureAuthenticated for the +// exceptions). Returns an error if authentication fails, suitable for use in +// Cobra PreRunE hooks. func EnsureAuthenticatedE(cmd *cobra.Command, _ []string) error { if !EnsureAuthenticated(cmd.Context()) { - return errors.New("Authentication failed.") + return errors.New("authentication failed") } return nil @@ -120,6 +259,12 @@ func EnsureAuthenticatedE(cmd *cobra.Command, _ []string) error { // EnsureAuthenticated checks if valid authentication exists, and if not, // triggers the login flow automatically. Returns true if authentication // is valid or was successfully obtained. +// +// A complete DATAROBOT_ENDPOINT/DATAROBOT_API_TOKEN pair that fails +// verification returns false without falling back to the stored profile and +// without starting the login flow: environment credentials are an explicit +// instance request, and substituting the profile would silently run against +// the wrong instance. func EnsureAuthenticated(ctx context.Context) bool { //nolint: cyclop if viperx.GetBool(config.SkipAuthKey) { log.Warn("Authentication checks are disabled via the '--skip-auth' flag. This may cause API calls to fail.") @@ -140,6 +285,17 @@ func EnsureAuthenticated(ctx context.Context) bool { //nolint: cyclop return true } + // A complete pair of environment credentials is an explicit request for + // that instance (the same precedence `dr auth export` documents). Falling + // back to the stored profile here would silently swap instances, so fail + // loudly instead and never touch the profile. + if !errors.Is(envErr, ErrEnvCredentialsNotSet) { + ReportEnvCredentialsError(os.Stderr, creds, envErr) + reportStoredProfileNotUsed(os.Stderr, creds) + + return false + } + datarobotHost := GetBaseURLOrAsk() if datarobotHost == "" { // Appropriate error message was already displayed in GetBaseURLOrAsk() and SetURLAction() @@ -154,25 +310,14 @@ func EnsureAuthenticated(ctx context.Context) bool { //nolint: cyclop skipAuthFlow := false - if errors.Is(envErr, context.DeadlineExceeded) { - envDatarobotHost, _ := config.SchemeHostOnly(creds.Endpoint) - - fmt.Print(tui.BaseTextStyle.Render("❌ Connection to ")) - fmt.Print(tui.InfoStyle.Render(envDatarobotHost)) - fmt.Println(tui.BaseTextStyle.Render(" from DATAROBOT_ENDPOINT environment variable timed out.")) - fmt.Println(tui.BaseTextStyle.Render("Check your network and try again.")) - - skipAuthFlow = true - } else if creds.Token != "" { - if epErr := ValidateEndpoint(creds.Endpoint); epErr != nil { - fmt.Println(tui.BaseTextStyle.Render("Your DATAROBOT_ENDPOINT environment variable is invalid:")) - fmt.Println(tui.BaseTextStyle.Render(epErr.Error())) - fmt.Println(tui.BaseTextStyle.Render("Set it to a valid DataRobot URL and try again.")) - } else { - fmt.Println(tui.BaseTextStyle.Render("Your DATAROBOT_API_TOKEN environment variable")) - fmt.Println(tui.BaseTextStyle.Render("contains an expired or invalid token. Unset it:")) - PrintUnsetTokenInstructions() - } + if creds.Token != "" { + // Partial env: DATAROBOT_API_TOKEN is set but no endpoint accompanies + // it (a complete pair was handled above), so the token was never + // verified. Point at it rather than starting a login flow that would + // shadow it. + fmt.Println(tui.BaseTextStyle.Render("Your DATAROBOT_API_TOKEN environment variable is set")) + fmt.Println(tui.BaseTextStyle.Render("without a DATAROBOT_ENDPOINT. Set that too, or unset the token:")) + PrintUnsetTokenInstructions() skipAuthFlow = true } diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index f1e8f4a79..70a6b571d 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -15,10 +15,12 @@ package auth import ( + "bytes" "context" "errors" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" "testing" @@ -161,6 +163,187 @@ func TestEnsureAuthenticated_ValidEnvironmentToken(t *testing.T) { assert.Equal(t, "valid-token", apiKey, "Expected GetAPIKey after EnsureAuthenticated to return valid token") } +// TestEnsureAuthenticated_EnvInvalidStoredValid is the regression test for the +// silent-fallback bug: a complete pair of environment credentials that fails +// verification must fail the command, never silently substitute the stored +// profile (which is valid here and would have made the old code return true). +func TestEnsureAuthenticated_EnvInvalidStoredValid(t *testing.T) { + server, cleanup := setupTestEnvironment(t) + defer cleanup() + + viperx.Set(config.DataRobotAPIKey, "valid-token") + t.Setenv("DATAROBOT_ENDPOINT", server.URL+"/api/v2") + t.Setenv("DATAROBOT_API_TOKEN", "expired-token") + + // The login flow must not start either; fail the test if it does. + APIKeyCallbackFunc = func(_ context.Context, _ string) (string, error) { + t.Error("login flow must not start when explicit env credentials fail") + + return "", errors.New("unexpected login flow") + } + + result := EnsureAuthenticated(context.Background()) + assert.False(t, result, + "Expected EnsureAuthenticated to fail instead of falling back to the valid stored profile") +} + +// TestEnsureAuthenticated_EnvMalformedEndpointStoredValid covers the malformed +// endpoint variant of the same rule (e.g. a quoted URL from running +// `$(dr auth export)` without eval). +func TestEnsureAuthenticated_EnvMalformedEndpointStoredValid(t *testing.T) { + server, cleanup := setupTestEnvironment(t) + defer cleanup() + + viperx.Set(config.DataRobotAPIKey, "valid-token") + t.Setenv("DATAROBOT_ENDPOINT", `"`+server.URL+`/api/v2"`) + t.Setenv("DATAROBOT_API_TOKEN", "valid-token") + + result := EnsureAuthenticated(context.Background()) + assert.False(t, result, + "Expected EnsureAuthenticated to fail on a malformed endpoint instead of using the stored profile") +} + +func TestReportEnvCredentialsError(t *testing.T) { + t.Run("timeout", func(t *testing.T) { + var buf bytes.Buffer + + creds := &EnvCredentials{Endpoint: "https://app.example.com/api/v2", Token: "some-token"} + ReportEnvCredentialsError(&buf, creds, context.DeadlineExceeded) + + assert.Contains(t, buf.String(), "timed out") + assert.Contains(t, buf.String(), "https://app.example.com") + }) + + t.Run("malformed endpoint", func(t *testing.T) { + var buf bytes.Buffer + + creds := &EnvCredentials{Endpoint: `"https://app.example.com/api/v2"`, Token: "some-token"} + ReportEnvCredentialsError(&buf, creds, errors.New("invalid token")) + + assert.Contains(t, buf.String(), "DATAROBOT_ENDPOINT environment variable is invalid") + assert.NotContains(t, buf.String(), "DATAROBOT_API_TOKEN environment variable is invalid or expired") + }) + + t.Run("invalid token", func(t *testing.T) { + var buf bytes.Buffer + + creds := &EnvCredentials{Endpoint: "https://app.example.com/api/v2", Token: "bad-token"} + ReportEnvCredentialsError(&buf, creds, errors.New("invalid token")) + + assert.Contains(t, buf.String(), "DATAROBOT_API_TOKEN environment variable is invalid or expired") + assert.Contains(t, buf.String(), "unset DATAROBOT_API_TOKEN") + }) + + t.Run("scheme-less endpoint is an endpoint problem, not transport", func(t *testing.T) { + var buf bytes.Buffer + + creds := &EnvCredentials{Endpoint: "app.example.com/api/v2", Token: "some-token"} + dialErr := &url.Error{ + Op: "Get", + URL: creds.Endpoint + "/version/", + Err: errors.New(`unsupported protocol scheme ""`), + } + ReportEnvCredentialsError(&buf, creds, dialErr) + + assert.Contains(t, buf.String(), "missing URL scheme") + assert.NotContains(t, buf.String(), "Could not connect") + }) + + t.Run("raw parse failure is an endpoint problem, not transport", func(t *testing.T) { + var buf bytes.Buffer + + // Leading whitespace fails VerifyToken's raw url.Parse but survives + // ValidateEndpoint, which trims before parsing. + creds := &EnvCredentials{Endpoint: " https://app.example.com/api/v2", Token: "some-token"} + parseErr := &url.Error{ + Op: "parse", + URL: creds.Endpoint, + Err: errors.New("first path segment in URL cannot contain colon"), + } + ReportEnvCredentialsError(&buf, creds, parseErr) + + assert.Contains(t, buf.String(), "environment variable is invalid") + assert.NotContains(t, buf.String(), "Could not connect") + }) + + t.Run("unreachable endpoint is not a token problem", func(t *testing.T) { + var buf bytes.Buffer + + creds := &EnvCredentials{Endpoint: "https://app.example.com/api/v2", Token: "valid-but-never-judged"} + transportErr := &url.Error{ + Op: "Get", + URL: creds.Endpoint + "/version/", + Err: errors.New("dial tcp 203.0.113.1:443: connect: connection refused"), + } + ReportEnvCredentialsError(&buf, creds, transportErr) + + assert.Contains(t, buf.String(), "Could not connect to") + assert.Contains(t, buf.String(), "connection refused") + assert.NotContains(t, buf.String(), "unset DATAROBOT_API_TOKEN") + }) + + t.Run("names DATAROBOT_API_ENDPOINT when the endpoint came from the fallback var", func(t *testing.T) { + var buf bytes.Buffer + + t.Setenv("DATAROBOT_ENDPOINT", "") + t.Setenv("DATAROBOT_API_ENDPOINT", `"https://app.example.com/api/v2"`) + t.Setenv("DATAROBOT_API_TOKEN", "some-token") + + creds := GetEnvCredentials() + ReportEnvCredentialsError(&buf, &creds, errors.New("parse error")) + + assert.Contains(t, buf.String(), "DATAROBOT_API_ENDPOINT environment variable is invalid") + assert.NotContains(t, buf.String(), "DATAROBOT_ENDPOINT environment variable is invalid") + }) +} + +// TestEnsureAuthenticated_EnvUnreachableStoredValid proves VerifyToken's +// transport errors really surface as *url.Error and classify as +// could-not-connect end to end, still failing instead of using the stored +// profile. +func TestEnsureAuthenticated_EnvUnreachableStoredValid(t *testing.T) { + _, cleanup := setupTestEnvironment(t) + defer cleanup() + + // A second server, closed immediately, donates a port that refuses + // connections. + deadServer := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) + deadServer.Close() + + viperx.Set(config.DataRobotAPIKey, "valid-token") + t.Setenv("DATAROBOT_ENDPOINT", deadServer.URL+"/api/v2") + t.Setenv("DATAROBOT_API_TOKEN", "valid-token") + + result := EnsureAuthenticated(context.Background()) + assert.False(t, result, + "Expected EnsureAuthenticated to fail on an unreachable endpoint instead of using the stored profile") +} + +func TestReportStoredProfileNotUsed(t *testing.T) { + _, cleanup := setupTestEnvironment(t) + defer cleanup() + + creds := &EnvCredentials{Endpoint: "https://requested.example.com/api/v2", Token: "bad-token"} + + t.Run("names both endpoints when a stored profile exists", func(t *testing.T) { + var buf bytes.Buffer + + reportStoredProfileNotUsed(&buf, creds) + + assert.Contains(t, buf.String(), "https://requested.example.com") + assert.Contains(t, buf.String(), "not falling back to the stored profile") + }) + + t.Run("silent without a stored profile", func(t *testing.T) { + var buf bytes.Buffer + + viperx.Set(config.DataRobotURL, "") + reportStoredProfileNotUsed(&buf, creds) + + assert.Empty(t, buf.String()) + }) +} + func TestEnsureAuthenticated_SkipAuth(t *testing.T) { _, cleanup := setupTestEnvironment(t) defer cleanup()