From 81d1f6032b68b28b1265c1ff57eaac16bbb6986b Mon Sep 17 00:00:00 2001 From: codebien <2103732+codebien@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:49:52 +0200 Subject: [PATCH 01/16] Enforce stack in k6 cloud run command --- internal/cmd/cloud.go | 34 ++++++---- internal/cmd/cloud_test.go | 72 ++++++++++++++++++--- internal/cmd/tests/cmd_cloud_test.go | 24 +++++-- internal/cmd/tests/cmd_cloud_upload_test.go | 5 +- 4 files changed, 109 insertions(+), 26 deletions(-) diff --git a/internal/cmd/cloud.go b/internal/cmd/cloud.go index 938c986f13..d1bb1ecce6 100644 --- a/internal/cmd/cloud.go +++ b/internal/cmd/cloud.go @@ -36,6 +36,27 @@ var errUserUnauthenticated = errors.New("To run tests in Grafana Cloud, you must " https://grafana.com/docs/grafana-cloud/testing/k6/author-run/tokens-and-cli-authentication" + " for additional authentication methods.") +// errStackNotConfigured represents a configuration error when trying to run +// Grafana Cloud tests without a stack being configured. +// +//nolint:staticcheck // the error is shown to the user so here punctuation and capital are required +var errStackNotConfigured = errors.New( + "To run tests in Grafana Cloud, a stack must be configured." + + " Run `k6 cloud login --stack ` to set your default stack," + + " or set the K6_CLOUD_STACK_ID environment variable.") + +// checkCloudLogin verifies that both a token and a stack are configured. +// Together they represent a complete Grafana Cloud login. +func checkCloudLogin(conf cloudapi.Config) error { + if !conf.Token.Valid || conf.Token.String == "" { + return errUserUnauthenticated + } + if !conf.StackID.Valid || conf.StackID.Int64 == 0 { + return errStackNotConfigured + } + return nil +} + // cmdCloud handles the `k6 cloud` sub-command type cmdCloud struct { gs *state.GlobalState @@ -130,8 +151,8 @@ func (c *cmdCloud) run(cmd *cobra.Command, args []string) error { if err != nil { return err } - if !cloudConfig.Token.Valid { - return errUserUnauthenticated + if err := checkCloudLogin(cloudConfig); err != nil { + return err } // Display config warning if needed @@ -481,14 +502,5 @@ func resolveAndSetProjectID( cloudConfig.ProjectID = null.IntFrom(projectID) } - if !cloudConfig.StackID.Valid || cloudConfig.StackID.Int64 == 0 { - fallBackMsg := "" - if !cloudConfig.ProjectID.Valid || cloudConfig.ProjectID.Int64 == 0 { - fallBackMsg = "Falling back to the first available stack. " - } - gs.Logger.Warn("DEPRECATED: No stack specified. " + fallBackMsg + - "Consider setting a default stack via the `k6 cloud login` command or the `K6_CLOUD_STACK_ID` " + - "environment variable as this will become mandatory in the next major release.") - } return nil } diff --git a/internal/cmd/cloud_test.go b/internal/cmd/cloud_test.go index 61213a4dd8..c241d73ac9 100644 --- a/internal/cmd/cloud_test.go +++ b/internal/cmd/cloud_test.go @@ -112,6 +112,67 @@ func TestResolveDefaultProjectID(t *testing.T) { } } +func TestCheckCloudLogin(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + conf cloudapi.Config + wantErr error + }{ + { + name: "valid token and stack passes", + conf: cloudapi.Config{ + Token: null.StringFrom("valid-token"), + StackID: null.IntFrom(1234), + }, + wantErr: nil, + }, + { + name: "missing token returns unauthenticated error", + conf: cloudapi.Config{ + StackID: null.IntFrom(1234), + }, + wantErr: errUserUnauthenticated, + }, + { + name: "empty token string returns unauthenticated error", + conf: cloudapi.Config{ + Token: null.StringFrom(""), + StackID: null.IntFrom(1234), + }, + wantErr: errUserUnauthenticated, + }, + { + name: "missing stack returns stack not configured error", + conf: cloudapi.Config{ + Token: null.StringFrom("valid-token"), + }, + wantErr: errStackNotConfigured, + }, + { + name: "zero stack ID returns stack not configured error", + conf: cloudapi.Config{ + Token: null.StringFrom("valid-token"), + StackID: null.IntFrom(0), + }, + wantErr: errStackNotConfigured, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := checkCloudLogin(tc.conf) + if tc.wantErr == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, tc.wantErr) + } + }) + } +} + func TestResolveAndSetProjectID(t *testing.T) { t.Parallel() @@ -120,7 +181,6 @@ func TestResolveAndSetProjectID(t *testing.T) { cloudConfig *cloudapi.Config expectedError string expectedProjectID int64 - logContains string }{ { name: "sets projectID in all places when projectID > 0", @@ -129,16 +189,14 @@ func TestResolveAndSetProjectID(t *testing.T) { }, expectedError: "", expectedProjectID: 123, - logContains: "No stack specified", }, { - name: "logs warnings when projectID is 0 and no StackID", + name: "returns 0 when projectID is 0 and no StackID", cloudConfig: &cloudapi.Config{ ProjectID: null.IntFrom(0), }, expectedError: "", expectedProjectID: 0, - logContains: "No stack specified", }, { name: "propagates error from resolveDefaultProjectID", @@ -184,11 +242,7 @@ func TestResolveAndSetProjectID(t *testing.T) { } logs := ts.LoggerHook.Drain() - if tc.logContains != "" { - assert.True(t, testutils.LogContains(logs, logrus.WarnLevel, tc.logContains)) - } else { - assert.Len(t, logs, 0) - } + assert.Len(t, logs, 0) }) } } diff --git a/internal/cmd/tests/cmd_cloud_test.go b/internal/cmd/tests/cmd_cloud_test.go index 26170261ef..5d8e53a4f2 100644 --- a/internal/cmd/tests/cmd_cloud_test.go +++ b/internal/cmd/tests/cmd_cloud_test.go @@ -45,6 +45,19 @@ func runCloudTests(t *testing.T, setupCmd setupCommandFunc) { assert.Contains(t, stdout, `must first authenticate`) }) + t.Run("TestCloudStackNotConfigured", func(t *testing.T) { + t.Parallel() + + ts := getSimpleCloudTestState(t, nil, setupCmd, nil, nil, nil) + delete(ts.Env, "K6_CLOUD_STACK_ID") + ts.ExpectedExitCode = -1 + cmd.ExecuteWithGlobalState(ts.GlobalState) + + stdout := ts.Stdout.String() + t.Log(stdout) + assert.Contains(t, stdout, `stack must be configured`) + }) + t.Run("TestCloudLoggedInWithScriptToken", func(t *testing.T) { t.Parallel() @@ -196,9 +209,10 @@ func runCloudTests(t *testing.T, setupCmd setupCommandFunc) { require.NoError(t, fsext.WriteFile(ts.FS, filepath.Join(ts.Cwd, "archive.tar"), data, 0o644)) ts.CmdArgs = []string{"k6", "cloud", "--verbose", "--log-output=stdout", "archive.tar"} - ts.Env["K6_SHOW_CLOUD_LOGS"] = "false" // no mock for the logs yet + ts.Env["K6_SHOW_CLOUD_LOGS"] = "false" // no mock for the logs yet ts.Env["K6_CLOUD_HOST"] = srv.URL - ts.Env["K6_CLOUD_TOKEN"] = "foo" // doesn't matter, we mock the cloud + ts.Env["K6_CLOUD_TOKEN"] = "foo" // doesn't matter, we mock the cloud + ts.Env["K6_CLOUD_STACK_ID"] = "1234" // doesn't matter, we mock the cloud cmd.ExecuteWithGlobalState(ts.GlobalState) @@ -317,9 +331,11 @@ func getSimpleCloudTestState(t *testing.T, script []byte, setupCmd setupCommandF ts := NewGlobalTestState(t) require.NoError(t, fsext.WriteFile(ts.FS, filepath.Join(ts.Cwd, "test.js"), script, 0o644)) ts.CmdArgs = setupCmd(cliFlags) - ts.Env["K6_SHOW_CLOUD_LOGS"] = "false" // no mock for the logs yet + ts.Env["K6_SHOW_CLOUD_LOGS"] = "false" // no mock for the logs yet ts.Env["K6_CLOUD_HOST"] = srv.URL - ts.Env["K6_CLOUD_TOKEN"] = "foo" // doesn't matter, we mock the cloud + ts.Env["K6_CLOUD_TOKEN"] = "foo" // doesn't matter, we mock the cloud + ts.Env["K6_CLOUD_STACK_ID"] = "1234" // doesn't matter, we mock the cloud + ts.Env["K6_CLOUD_PROJECT_ID"] = "1234" // doesn't matter, we mock the cloud return ts } diff --git a/internal/cmd/tests/cmd_cloud_upload_test.go b/internal/cmd/tests/cmd_cloud_upload_test.go index a5d2ef4a52..e61ee1616c 100644 --- a/internal/cmd/tests/cmd_cloud_upload_test.go +++ b/internal/cmd/tests/cmd_cloud_upload_test.go @@ -133,9 +133,10 @@ func TestK6CloudUpload(t *testing.T) { require.NoError(t, fsext.WriteFile(ts.FS, filepath.Join(ts.Cwd, "archive.tar"), data, 0o644)) ts.CmdArgs = []string{"k6", "cloud", "upload", "archive.tar"} - ts.Env["K6_SHOW_CLOUD_LOGS"] = "false" // no mock for the logs yet + ts.Env["K6_SHOW_CLOUD_LOGS"] = "false" // no mock for the logs yet ts.Env["K6_CLOUD_HOST"] = srv.URL - ts.Env["K6_CLOUD_TOKEN"] = "foo" // doesn't matter, we mock the cloud + ts.Env["K6_CLOUD_TOKEN"] = "foo" // doesn't matter, we mock the cloud + ts.Env["K6_CLOUD_STACK_ID"] = "1234" // doesn't matter, we mock the cloud cmd.ExecuteWithGlobalState(ts.GlobalState) From f1a76b5580cadca0e0b75109e5175230bcc6c099 Mon Sep 17 00:00:00 2001 From: codebien <2103732+codebien@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:26:30 +0200 Subject: [PATCH 02/16] Enforce stack in cloud --local-execution command --- internal/cmd/outputs_cloud.go | 14 ++---------- internal/cmd/tests/cmd_cloud_run_test.go | 27 +++++++++++++++++++++++- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/internal/cmd/outputs_cloud.go b/internal/cmd/outputs_cloud.go index 55e83fbfc3..d8812c17f0 100644 --- a/internal/cmd/outputs_cloud.go +++ b/internal/cmd/outputs_cloud.go @@ -50,18 +50,8 @@ func createCloudTest(gs *state.GlobalState, test *loadedAndConfiguredTest) error gs.Logger.Warn(warn) } - if conf.Token.String == "" { - return errUserUnauthenticated - } - - if !conf.StackID.Valid || conf.StackID.Int64 == 0 { - fallBackMsg := "" - if !conf.ProjectID.Valid || conf.ProjectID.Int64 == 0 { - fallBackMsg = "Falling back to the first available stack. " - } - gs.Logger.Warn("DEPRECATED: No stack specified. " + fallBackMsg + - "Consider setting a default stack via the `k6 cloud login` command or the `K6_CLOUD_STACK_ID` " + - "environment variable as this will become mandatory in the next major release.") + if err := checkCloudLogin(conf); err != nil { + return err } // If not, we continue with some validations and the creation of the test run. diff --git a/internal/cmd/tests/cmd_cloud_run_test.go b/internal/cmd/tests/cmd_cloud_run_test.go index 8e12a974fc..7373dfc987 100644 --- a/internal/cmd/tests/cmd_cloud_run_test.go +++ b/internal/cmd/tests/cmd_cloud_run_test.go @@ -212,6 +212,30 @@ export default function() { assert.Contains(t, stdout, "output: cloud (https://app.k6.io/runs/1337)") assert.Contains(t, stdout, "The test run id is "+strconv.Itoa(testRunID)) }) + + t.Run("should error when no stack is configured", func(t *testing.T) { + t.Parallel() + + script := ` +export const options = { + cloud: { + name: 'Hello k6 Cloud!', + projectID: 123456, + }, +}; + +export default function() {};` + + ts := makeTestState(t, script, []string{"--local-execution"}, 0) + ts.ExpectedExitCode = -1 + delete(ts.Env, "K6_CLOUD_STACK_ID") + + cmd.ExecuteWithGlobalState(ts.GlobalState) + + stderr := ts.Stderr.String() + t.Log(stderr) + assert.Contains(t, stderr, "stack must be configured") + }) } func makeTestState(tb testing.TB, script string, cliFlags []string, expExitCode exitcodes.ExitCode) *GlobalTestState { @@ -223,7 +247,8 @@ func makeTestState(tb testing.TB, script string, cliFlags []string, expExitCode require.NoError(tb, fsext.WriteFile(ts.FS, filepath.Join(ts.Cwd, "test.js"), []byte(script), 0o644)) ts.CmdArgs = append(append([]string{"k6", "cloud", "run"}, cliFlags...), "test.js") ts.ExpectedExitCode = int(expExitCode) - ts.Env["K6_CLOUD_TOKEN"] = "foo" // doesn't matter, we mock the cloud + ts.Env["K6_CLOUD_TOKEN"] = "foo" // doesn't matter, we mock the cloud + ts.Env["K6_CLOUD_STACK_ID"] = "1234" // doesn't matter, we mock the cloud return ts } From 28cb3ca7a567fac511f9bb9e955e68de53649ffb Mon Sep 17 00:00:00 2001 From: codebien <2103732+codebien@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:29:02 +0200 Subject: [PATCH 03/16] Use stdout assertion in missing-stack test for consistency --- internal/cmd/tests/cmd_cloud_run_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/cmd/tests/cmd_cloud_run_test.go b/internal/cmd/tests/cmd_cloud_run_test.go index 7373dfc987..8cf1c3ad46 100644 --- a/internal/cmd/tests/cmd_cloud_run_test.go +++ b/internal/cmd/tests/cmd_cloud_run_test.go @@ -226,15 +226,15 @@ export const options = { export default function() {};` - ts := makeTestState(t, script, []string{"--local-execution"}, 0) + ts := makeTestState(t, script, []string{"--local-execution", "--log-output=stdout"}, 0) ts.ExpectedExitCode = -1 delete(ts.Env, "K6_CLOUD_STACK_ID") cmd.ExecuteWithGlobalState(ts.GlobalState) - stderr := ts.Stderr.String() - t.Log(stderr) - assert.Contains(t, stderr, "stack must be configured") + stdout := ts.Stdout.String() + t.Log(stdout) + assert.Contains(t, stdout, "stack must be configured") }) } From 02b4ae4be7cd1bf453e7712c65fc318746e3688c Mon Sep 17 00:00:00 2001 From: codebien <2103732+codebien@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:34:17 +0200 Subject: [PATCH 04/16] Require stack in k6 cloud login, remove v1 token validation fallback --- internal/cmd/cloud_login.go | 58 ++++++---------------- internal/cmd/tests/cmd_cloud_login_test.go | 19 ++++--- 2 files changed, 23 insertions(+), 54 deletions(-) diff --git a/internal/cmd/cloud_login.go b/internal/cmd/cloud_login.go index 6a19f37d17..3161c77c33 100644 --- a/internal/cmd/cloud_login.go +++ b/internal/cmd/cloud_login.go @@ -224,53 +224,23 @@ func validateInputs( gs.Logger.Warn(warn) } - stackValue := stackInput.String - if stackInput.Valid && stackValue != "" && stackValue != "None" { - stackURL, stackID, defaultProjectID, err := validateTokenV6( - gs, consolidatedCurrentConfig, token.String, stackValue) - if err != nil { - return fmt.Errorf( - "your stack is invalid - please, consult the documentation "+ - "for instructions on how to get yours: "+ - "https://grafana.com/docs/grafana-cloud/testing/k6/author-run/configure-stack. "+ - "Error details: %w", - err) - } - config.StackURL = null.StringFrom(stackURL) - config.StackID = null.IntFrom(stackID) - config.DefaultProjectID = null.IntFrom(defaultProjectID) - } else { - err = validateTokenV1(gs, consolidatedCurrentConfig, config.Token.String) - if err != nil { - return err - } + stackValue := strings.TrimSpace(stackInput.String) + if !stackInput.Valid || stackValue == "" || stackValue == "None" { + return errStackNotConfigured } - - return nil -} - -// validateTokenV1 validates a token using v1 cloud API. -// -// Deprecated: use validateTokenV6 instead if a stack name is provided. -func validateTokenV1(gs *state.GlobalState, config cloudapi.Config, token string) error { - client := cloudapi.NewClient( - gs.Logger, - token, - config.Host.String, - build.Version, - config.Timeout.TimeDuration(), - ) - - res, err := client.ValidateToken() + stackURL, stackID, defaultProjectID, err := validateTokenV6( + gs, consolidatedCurrentConfig, token.String, stackValue) if err != nil { - return fmt.Errorf("can't validate the API token: %s", err.Error()) - } - - if !res.IsValid { - return errors.New("your API token is invalid - " + - "please, consult the documentation for instructions on how to generate a new one:\n" + - "https://grafana.com/docs/grafana-cloud/testing/k6/author-run/tokens-and-cli-authentication") + return fmt.Errorf( + "your stack is invalid - please, consult the documentation "+ + "for instructions on how to get yours: "+ + "https://grafana.com/docs/grafana-cloud/testing/k6/author-run/configure-stack. "+ + "Error details: %w", + err) } + config.StackURL = null.StringFrom(stackURL) + config.StackID = null.IntFrom(stackID) + config.DefaultProjectID = null.IntFrom(defaultProjectID) return nil } diff --git a/internal/cmd/tests/cmd_cloud_login_test.go b/internal/cmd/tests/cmd_cloud_login_test.go index 0f37bae667..8cbe7bcac5 100644 --- a/internal/cmd/tests/cmd_cloud_login_test.go +++ b/internal/cmd/tests/cmd_cloud_login_test.go @@ -33,12 +33,11 @@ func TestCloudLoginWithArgs(t *testing.T) { wantStdoutContains []string }{ { - name: "valid token", + name: "valid token without stack fails", token: validToken, - wantErr: false, + wantErr: true, wantStdoutContains: []string{ - "Logged in successfully", - fmt.Sprintf("token: %s", validToken), + "stack must be configured", }, }, { @@ -55,20 +54,20 @@ func TestCloudLoginWithArgs(t *testing.T) { }, }, { - name: "valid token and 'None' stack", + name: "valid token and 'None' stack fails", token: validToken, stack: "None", - wantErr: false, + wantErr: true, wantStdoutContains: []string{ - "Logged in successfully", - fmt.Sprintf("token: %s", validToken), + "stack must be configured", }, }, { - name: "invalid token", + name: "invalid token and valid stack", token: "invalid-token", + stack: validStack, wantErr: true, - wantStdoutContains: []string{"your API token is invalid"}, + wantStdoutContains: []string{"your stack is invalid"}, }, { name: "valid token and invalid stack", From 8401eb20e8f228b61aa607a8b3eea60e7082090b Mon Sep 17 00:00:00 2001 From: codebien <2103732+codebien@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:38:08 +0200 Subject: [PATCH 05/16] Remove dead v1 validate-token handler from mock server --- internal/cmd/tests/cmd_cloud_login_test.go | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/internal/cmd/tests/cmd_cloud_login_test.go b/internal/cmd/tests/cmd_cloud_login_test.go index 8cbe7bcac5..5fc2218fbd 100644 --- a/internal/cmd/tests/cmd_cloud_login_test.go +++ b/internal/cmd/tests/cmd_cloud_login_test.go @@ -1,9 +1,7 @@ package tests import ( - "encoding/json" "fmt" - "io" "net/http" "net/http/httptest" "testing" @@ -125,24 +123,6 @@ func mockValidateTokenServer(t *testing.T) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { switch req.URL.Path { - // v1 path to validate token only - case "/v1/validate-token": - body, err := io.ReadAll(req.Body) - require.NoError(t, err) - - var payload map[string]any - err = json.Unmarshal(body, &payload) - require.NoError(t, err) - - assert.Contains(t, payload, "token") - if payload["token"] == validToken { - _, err = fmt.Fprintf(w, `{"is_valid": true, "message": "Token is valid"}`) - require.NoError(t, err) - return - } - _, err = fmt.Fprintf(w, `{"is_valid": false, "message": "Token is invalid"}`) - require.NoError(t, err) - // v6 path to validate token and stack case "/cloud/v6/auth": authHeader := req.Header.Get("Authorization") From 069bc1ed83ef48e7cb422ace8065fe5dc19d29c5 Mon Sep 17 00:00:00 2001 From: codebien <2103732+codebien@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:20:56 +0200 Subject: [PATCH 06/16] Stack is now a mandatory field --- internal/cmd/cloud_login.go | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/internal/cmd/cloud_login.go b/internal/cmd/cloud_login.go index 3161c77c33..93a12da4d7 100644 --- a/internal/cmd/cloud_login.go +++ b/internal/cmd/cloud_login.go @@ -35,9 +35,6 @@ func getCmdCloudLogin(gs *state.GlobalState) *cobra.Command { # Authenticate interactively with Grafana Cloud $ {{.}} cloud login - # Store a token in k6's persistent configuration - $ {{.}} cloud login -t - # Store a token in k6's persistent configuration and set the stack $ {{.}} cloud login -t --stack @@ -139,9 +136,8 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { "You can enter a full URL (e.g. https://my-team.grafana.net) or just the slug (e.g. my-team):", Fields: []ui.Field{ ui.StringField{ - Key: "Stack", - Label: "Stack", - Default: "None", + Key: "Stack", + Label: "Stack", }, }, } @@ -150,6 +146,9 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { return err } stackInput := null.StringFrom(strings.TrimSpace(stackVals["Stack"])) + if !stackInput.Valid { + return errors.New("stack cannot be empty") + } err = validateInputs(gs, &newCloudConf, currentJSONConfigRaw, tokenInput, stackInput) if err != nil { From 370eba27f9222c9c6c04b93f1720f88794be1a8a Mon Sep 17 00:00:00 2001 From: codebien <2103732+codebien@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:55:39 +0200 Subject: [PATCH 07/16] Partially mask the returned token --- internal/cmd/cloud_login.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/cmd/cloud_login.go b/internal/cmd/cloud_login.go index 93a12da4d7..abfe36ff09 100644 --- a/internal/cmd/cloud_login.go +++ b/internal/cmd/cloud_login.go @@ -182,8 +182,11 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { } func printConfig(gs *state.GlobalState, cloudConf cloudapi.Config) { + token := cloudConf.Token.String + asterisks := strings.Repeat("*", len(token)-8) + maskedToken := token[:4] + asterisks + token[len(token)-4:] valueColor := getColor(gs.Flags.NoColor || !gs.Stdout.IsTTY, color.FgCyan) - printToStdout(gs, fmt.Sprintf(" token: %s\n", valueColor.Sprint(cloudConf.Token.String))) + printToStdout(gs, fmt.Sprintf(" token: %s\n", valueColor.Sprint(maskedToken))) if !cloudConf.StackID.Valid && !cloudConf.StackURL.Valid { printToStdout(gs, " stack-id: \n") From 7b831d2d2ca422d295636405eea89c27dbb46576 Mon Sep 17 00:00:00 2001 From: codebien <2103732+codebien@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:37:31 +0200 Subject: [PATCH 08/16] Improved the visualization's UX --- internal/cmd/cloud_login.go | 38 ++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/internal/cmd/cloud_login.go b/internal/cmd/cloud_login.go index abfe36ff09..2deef3d2fb 100644 --- a/internal/cmd/cloud_login.go +++ b/internal/cmd/cloud_login.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "strconv" "strings" "syscall" @@ -65,6 +66,8 @@ func getCmdCloudLogin(gs *state.GlobalState) *cobra.Command { // //nolint:funlen func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { + printBanner(c.globalState) + currentDiskConf, err := readDiskConfig(c.globalState) if err != nil { return err @@ -182,30 +185,31 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { } func printConfig(gs *state.GlobalState, cloudConf cloudapi.Config) { - token := cloudConf.Token.String - asterisks := strings.Repeat("*", len(token)-8) - maskedToken := token[:4] + asterisks + token[len(token)-4:] - valueColor := getColor(gs.Flags.NoColor || !gs.Stdout.IsTTY, color.FgCyan) - printToStdout(gs, fmt.Sprintf(" token: %s\n", valueColor.Sprint(maskedToken))) - - if !cloudConf.StackID.Valid && !cloudConf.StackURL.Valid { - printToStdout(gs, " stack-id: \n") - printToStdout(gs, " stack-url: \n") - printToStdout(gs, " default-project-id: \n") - - return + const notSet = "" + token, stackID, stackURL, defProj := notSet, notSet, notSet, notSet + + if cloudConf.Token.String != "" { + // If a token is set then we assume we have a valid token longer than 8 chars + // print the token with all the chars masked, except the first and the last four + unmasked := cloudConf.Token.String + asterisks := strings.Repeat("*", len(unmasked)-8) + token = unmasked[:4] + asterisks + unmasked[len(unmasked)-4:] } - if cloudConf.StackID.Valid { - printToStdout(gs, fmt.Sprintf(" stack-id: %s\n", valueColor.Sprint(cloudConf.StackID.Int64))) + stackID = strconv.FormatInt(cloudConf.StackID.Int64, 10) } if cloudConf.StackURL.Valid { - printToStdout(gs, fmt.Sprintf(" stack-url: %s\n", valueColor.Sprint(cloudConf.StackURL.String))) + stackURL = cloudConf.StackURL.String } if cloudConf.DefaultProjectID.Valid { - printToStdout(gs, fmt.Sprintf(" default-project-id: %s\n", - valueColor.Sprint(cloudConf.DefaultProjectID.Int64))) + defProj = strconv.FormatInt(cloudConf.DefaultProjectID.Int64, 10) } + + valueColor := getColor(gs.Flags.NoColor || !gs.Stdout.IsTTY, color.FgCyan) + printToStdout(gs, fmt.Sprintf(" token: %s\n", valueColor.Sprint(token))) + printToStdout(gs, fmt.Sprintf(" stack-id: %s\n", valueColor.Sprint(stackID))) + printToStdout(gs, fmt.Sprintf(" stack-url: %s\n", valueColor.Sprint(stackURL))) + printToStdout(gs, fmt.Sprintf(" default-project-id: %s\n", valueColor.Sprint(defProj))) } // validateInputs validates a token and a stack if provided From 15bc60d8d17d49eb9ea1e1202239ea05800a2d8e Mon Sep 17 00:00:00 2001 From: codebien <2103732+codebien@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:44:26 +0200 Subject: [PATCH 09/16] Allow login by flags only if they provide both --- internal/cmd/cloud_login.go | 38 +++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/internal/cmd/cloud_login.go b/internal/cmd/cloud_login.go index 2deef3d2fb..b7edc1d3fa 100644 --- a/internal/cmd/cloud_login.go +++ b/internal/cmd/cloud_login.go @@ -88,7 +88,7 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { show := getNullBool(cmd.Flags(), "show") reset := getNullBool(cmd.Flags(), "reset") - token := getNullString(cmd.Flags(), "token") + tokenInput := getNullString(cmd.Flags(), "token") stackInput := getNullString(cmd.Flags(), "stack") switch { @@ -97,12 +97,18 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { newCloudConf.StackID = null.IntFromPtr(nil) newCloudConf.StackURL = null.StringFromPtr(nil) newCloudConf.DefaultProjectID = null.IntFromPtr(nil) - printToStdout(c.globalState, " token and stack info reset\n") + printToStdout(c.globalState, "\nToken and stack info have been reset.\n") case show.Bool: printConfig(c.globalState, newCloudConf) return nil - case token.Valid: - err := validateInputs(c.globalState, &newCloudConf, currentJSONConfigRaw, token, stackInput) + case tokenInput.Valid || stackInput.Valid: + if !stackInput.Valid || stackInput.String == "" { + return errors.New("Stack value is required but it was not passed or is empty") + } + if !tokenInput.Valid || tokenInput.String == "" { + return errors.New("Token value is required but it was not passed or is empty") + } + err := validateInputs(c.globalState, &newCloudConf, currentJSONConfigRaw, tokenInput, stackInput) if err != nil { return err } @@ -129,8 +135,8 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { return err } tokenInput := null.StringFrom(tokenVals["Token"]) - if !tokenInput.Valid { - return errors.New("token cannot be empty") + if tokenInput.String == "" { + return errors.New("Token cannot be empty") } /* Stack form */ @@ -149,8 +155,8 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { return err } stackInput := null.StringFrom(strings.TrimSpace(stackVals["Stack"])) - if !stackInput.Valid { - return errors.New("stack cannot be empty") + if stackInput.String == "" { + return errors.New("Stack cannot be empty") } err = validateInputs(gs, &newCloudConf, currentJSONConfigRaw, tokenInput, stackInput) @@ -218,7 +224,7 @@ func validateInputs( gs *state.GlobalState, config *cloudapi.Config, rawConfig json.RawMessage, - token, stackInput null.String, + token, stack null.String, ) error { config.Token = token consolidatedCurrentConfig, warn, err := cloudapi.GetConsolidatedConfig( @@ -230,18 +236,14 @@ func validateInputs( gs.Logger.Warn(warn) } - stackValue := strings.TrimSpace(stackInput.String) - if !stackInput.Valid || stackValue == "" || stackValue == "None" { - return errStackNotConfigured - } stackURL, stackID, defaultProjectID, err := validateTokenV6( - gs, consolidatedCurrentConfig, token.String, stackValue) + gs, consolidatedCurrentConfig, token.String, stack.String) if err != nil { return fmt.Errorf( - "your stack is invalid - please, consult the documentation "+ - "for instructions on how to get yours: "+ - "https://grafana.com/docs/grafana-cloud/testing/k6/author-run/configure-stack. "+ - "Error details: %w", + "Authentication failed as provided token or stack might not be valid."+ + " Learn more: https://grafana.com/docs/grafana-cloud/testing/k6/author-run/tokens-and-cli-authentication."+ + " Server error for details: %w", + err) } config.StackURL = null.StringFrom(stackURL) From 0a4f7ecd74355102071fa31444216f4ee4fa4fbe Mon Sep 17 00:00:00 2001 From: codebien <2103732+codebien@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:57:44 +0200 Subject: [PATCH 10/16] Adjusted the tests for the new logic --- internal/cmd/tests/cmd_cloud_login_test.go | 27 +++++++++++----------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/internal/cmd/tests/cmd_cloud_login_test.go b/internal/cmd/tests/cmd_cloud_login_test.go index 5fc2218fbd..f85501c37d 100644 --- a/internal/cmd/tests/cmd_cloud_login_test.go +++ b/internal/cmd/tests/cmd_cloud_login_test.go @@ -30,14 +30,6 @@ func TestCloudLoginWithArgs(t *testing.T) { wantErr bool wantStdoutContains []string }{ - { - name: "valid token without stack fails", - token: validToken, - wantErr: true, - wantStdoutContains: []string{ - "stack must be configured", - }, - }, { name: "valid token and valid stack", token: validToken, @@ -45,19 +37,26 @@ func TestCloudLoginWithArgs(t *testing.T) { wantErr: false, wantStdoutContains: []string{ "Logged in successfully", - fmt.Sprintf("token: %s", validToken), + fmt.Sprintf("token: %s", "vali***oken"), fmt.Sprintf("stack-id: %d", validStackID), fmt.Sprintf("stack-url: %s", validStackURL), fmt.Sprintf("default-project-id: %d", defaultProjectID), }, }, { - name: "valid token and 'None' stack fails", + name: "valid token without stack fails", token: validToken, - stack: "None", wantErr: true, wantStdoutContains: []string{ - "stack must be configured", + "Stack value is required", + }, + }, + { + name: "valid stack without token fails", + stack: validStack, + wantErr: true, + wantStdoutContains: []string{ + "Token value is required", }, }, { @@ -65,14 +64,14 @@ func TestCloudLoginWithArgs(t *testing.T) { token: "invalid-token", stack: validStack, wantErr: true, - wantStdoutContains: []string{"your stack is invalid"}, + wantStdoutContains: []string{"Authentication failed"}, }, { name: "valid token and invalid stack", token: validToken, stack: "invalid-stack", wantErr: true, - wantStdoutContains: []string{"your stack is invalid"}, + wantStdoutContains: []string{"Authentication failed"}, }, } From d864201e7ddcd41b8e972455d87026b15a678502 Mon Sep 17 00:00:00 2001 From: codebien <2103732+codebien@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:08:03 +0200 Subject: [PATCH 11/16] Refined the unautheticated error on cloud run --- internal/cmd/cloud.go | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/internal/cmd/cloud.go b/internal/cmd/cloud.go index d1bb1ecce6..9f96190061 100644 --- a/internal/cmd/cloud.go +++ b/internal/cmd/cloud.go @@ -31,19 +31,10 @@ import ( // Grafana Cloud without being logged in or having a valid token. // //nolint:staticcheck // the error is shown to the user so here punctuation and capital are required -var errUserUnauthenticated = errors.New("To run tests in Grafana Cloud, you must first authenticate." + - " Run the `k6 cloud login` command, or check the docs" + +var errUserUnauthenticated = errors.New("You must first authenticate to run tests in Grafana Cloud." + + " Run the `k6 cloud login` command providing the stack and token, or check the docs" + " https://grafana.com/docs/grafana-cloud/testing/k6/author-run/tokens-and-cli-authentication" + - " for additional authentication methods.") - -// errStackNotConfigured represents a configuration error when trying to run -// Grafana Cloud tests without a stack being configured. -// -//nolint:staticcheck // the error is shown to the user so here punctuation and capital are required -var errStackNotConfigured = errors.New( - "To run tests in Grafana Cloud, a stack must be configured." + - " Run `k6 cloud login --stack ` to set your default stack," + - " or set the K6_CLOUD_STACK_ID environment variable.") + " for additional methods.") // checkCloudLogin verifies that both a token and a stack are configured. // Together they represent a complete Grafana Cloud login. @@ -52,7 +43,7 @@ func checkCloudLogin(conf cloudapi.Config) error { return errUserUnauthenticated } if !conf.StackID.Valid || conf.StackID.Int64 == 0 { - return errStackNotConfigured + return errUserUnauthenticated } return nil } From 473f762429bc00c7aa0ba1d22637651854d9aa11 Mon Sep 17 00:00:00 2001 From: codebien <2103732+codebien@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:16:47 +0200 Subject: [PATCH 12/16] Migrated a few additional tests --- internal/cmd/cloud_test.go | 4 ++-- internal/cmd/tests/cmd_cloud_run_test.go | 6 +++--- internal/cmd/tests/cmd_cloud_test.go | 16 ++++++++-------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/internal/cmd/cloud_test.go b/internal/cmd/cloud_test.go index c241d73ac9..8513b73711 100644 --- a/internal/cmd/cloud_test.go +++ b/internal/cmd/cloud_test.go @@ -148,7 +148,7 @@ func TestCheckCloudLogin(t *testing.T) { conf: cloudapi.Config{ Token: null.StringFrom("valid-token"), }, - wantErr: errStackNotConfigured, + wantErr: errUserUnauthenticated, }, { name: "zero stack ID returns stack not configured error", @@ -156,7 +156,7 @@ func TestCheckCloudLogin(t *testing.T) { Token: null.StringFrom("valid-token"), StackID: null.IntFrom(0), }, - wantErr: errStackNotConfigured, + wantErr: errUserUnauthenticated, }, } diff --git a/internal/cmd/tests/cmd_cloud_run_test.go b/internal/cmd/tests/cmd_cloud_run_test.go index 8cf1c3ad46..1d26a5e257 100644 --- a/internal/cmd/tests/cmd_cloud_run_test.go +++ b/internal/cmd/tests/cmd_cloud_run_test.go @@ -234,7 +234,7 @@ export default function() {};` stdout := ts.Stdout.String() t.Log(stdout) - assert.Contains(t, stdout, "stack must be configured") + assert.Contains(t, stdout, "must first authenticate") }) } @@ -247,8 +247,8 @@ func makeTestState(tb testing.TB, script string, cliFlags []string, expExitCode require.NoError(tb, fsext.WriteFile(ts.FS, filepath.Join(ts.Cwd, "test.js"), []byte(script), 0o644)) ts.CmdArgs = append(append([]string{"k6", "cloud", "run"}, cliFlags...), "test.js") ts.ExpectedExitCode = int(expExitCode) - ts.Env["K6_CLOUD_TOKEN"] = "foo" // doesn't matter, we mock the cloud - ts.Env["K6_CLOUD_STACK_ID"] = "1234" // doesn't matter, we mock the cloud + ts.Env["K6_CLOUD_TOKEN"] = "foo" // doesn't matter, we mock the cloud + ts.Env["K6_CLOUD_STACK_ID"] = "1234" // doesn't matter, we mock the cloud return ts } diff --git a/internal/cmd/tests/cmd_cloud_test.go b/internal/cmd/tests/cmd_cloud_test.go index 5d8e53a4f2..658ab388d5 100644 --- a/internal/cmd/tests/cmd_cloud_test.go +++ b/internal/cmd/tests/cmd_cloud_test.go @@ -55,7 +55,7 @@ func runCloudTests(t *testing.T, setupCmd setupCommandFunc) { stdout := ts.Stdout.String() t.Log(stdout) - assert.Contains(t, stdout, `stack must be configured`) + assert.Contains(t, stdout, `must first authenticate`) }) t.Run("TestCloudLoggedInWithScriptToken", func(t *testing.T) { @@ -209,10 +209,10 @@ func runCloudTests(t *testing.T, setupCmd setupCommandFunc) { require.NoError(t, fsext.WriteFile(ts.FS, filepath.Join(ts.Cwd, "archive.tar"), data, 0o644)) ts.CmdArgs = []string{"k6", "cloud", "--verbose", "--log-output=stdout", "archive.tar"} - ts.Env["K6_SHOW_CLOUD_LOGS"] = "false" // no mock for the logs yet + ts.Env["K6_SHOW_CLOUD_LOGS"] = "false" // no mock for the logs yet ts.Env["K6_CLOUD_HOST"] = srv.URL - ts.Env["K6_CLOUD_TOKEN"] = "foo" // doesn't matter, we mock the cloud - ts.Env["K6_CLOUD_STACK_ID"] = "1234" // doesn't matter, we mock the cloud + ts.Env["K6_CLOUD_TOKEN"] = "foo" // doesn't matter, we mock the cloud + ts.Env["K6_CLOUD_STACK_ID"] = "1234" // doesn't matter, we mock the cloud cmd.ExecuteWithGlobalState(ts.GlobalState) @@ -331,11 +331,11 @@ func getSimpleCloudTestState(t *testing.T, script []byte, setupCmd setupCommandF ts := NewGlobalTestState(t) require.NoError(t, fsext.WriteFile(ts.FS, filepath.Join(ts.Cwd, "test.js"), script, 0o644)) ts.CmdArgs = setupCmd(cliFlags) - ts.Env["K6_SHOW_CLOUD_LOGS"] = "false" // no mock for the logs yet + ts.Env["K6_SHOW_CLOUD_LOGS"] = "false" // no mock for the logs yet ts.Env["K6_CLOUD_HOST"] = srv.URL - ts.Env["K6_CLOUD_TOKEN"] = "foo" // doesn't matter, we mock the cloud - ts.Env["K6_CLOUD_STACK_ID"] = "1234" // doesn't matter, we mock the cloud - ts.Env["K6_CLOUD_PROJECT_ID"] = "1234" // doesn't matter, we mock the cloud + ts.Env["K6_CLOUD_TOKEN"] = "foo" // doesn't matter, we mock the cloud + ts.Env["K6_CLOUD_STACK_ID"] = "1234" // doesn't matter, we mock the cloud + ts.Env["K6_CLOUD_PROJECT_ID"] = "1234" // doesn't matter, we mock the cloud return ts } From 0b604df3403ca9c40c88872ca738e965af5ce116 Mon Sep 17 00:00:00 2001 From: codebien <2103732+codebien@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:05:54 +0200 Subject: [PATCH 13/16] Refactor to reduce the Cognitive complexity --- internal/cmd/cloud_login.go | 109 ++++++++++++++++++++---------------- 1 file changed, 62 insertions(+), 47 deletions(-) diff --git a/internal/cmd/cloud_login.go b/internal/cmd/cloud_login.go index b7edc1d3fa..6ac9d8e49d 100644 --- a/internal/cmd/cloud_login.go +++ b/internal/cmd/cloud_login.go @@ -108,58 +108,20 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { if !tokenInput.Valid || tokenInput.String == "" { return errors.New("Token value is required but it was not passed or is empty") } - err := validateInputs(c.globalState, &newCloudConf, currentJSONConfigRaw, tokenInput, stackInput) + err := authenticateUserToken(c.globalState, &newCloudConf, currentJSONConfigRaw, tokenInput.String, stackInput.String) if err != nil { return err } default: gs := c.globalState - /* Token form */ - tokenForm := ui.Form{ - Banner: "Enter your token to authenticate with Grafana Cloud.\n" + - "Please, consult the documentation for instructions on how to generate one:\n" + - "https://grafana.com/docs/grafana-cloud/testing/k6/author-run/tokens-and-cli-authentication", - Fields: []ui.Field{ - ui.PasswordField{ - Key: "Token", - Label: "Token", - }, - }, - } - if !term.IsTerminal(int(syscall.Stdin)) { //nolint:unconvert - gs.Logger.Warn("Stdin is not a terminal, falling back to plain text input") - } - tokenVals, err := tokenForm.Run(gs.Stdin, gs.Stdout) - if err != nil { - return err - } - tokenInput := null.StringFrom(tokenVals["Token"]) - if tokenInput.String == "" { - return errors.New("Token cannot be empty") - } - - /* Stack form */ - stackForm := ui.Form{ - Banner: "\nEnter the stack where you want to run k6's commands by default.\n" + - "You can enter a full URL (e.g. https://my-team.grafana.net) or just the slug (e.g. my-team):", - Fields: []ui.Field{ - ui.StringField{ - Key: "Stack", - Label: "Stack", - }, - }, - } - stackVals, err := stackForm.Run(gs.Stdin, gs.Stdout) + userinfo, err := promptUserAuthForm(gs) if err != nil { return err } - stackInput := null.StringFrom(strings.TrimSpace(stackVals["Stack"])) - if stackInput.String == "" { - return errors.New("Stack cannot be empty") - } - err = validateInputs(gs, &newCloudConf, currentJSONConfigRaw, tokenInput, stackInput) + err = authenticateUserToken(gs, &newCloudConf, currentJSONConfigRaw, + userinfo.token, userinfo.stack) if err != nil { return err } @@ -190,6 +152,59 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { return nil } +type userAuthForm struct { + stack string + token string +} + +func promptUserAuthForm(gs *state.GlobalState) (userAuthForm, error) { + /* Token form */ + tokenForm := ui.Form{ + Banner: "Enter your token to authenticate with Grafana Cloud.\n" + + "Please, consult the documentation for instructions on how to generate one:\n" + + "https://grafana.com/docs/grafana-cloud/testing/k6/author-run/tokens-and-cli-authentication", + Fields: []ui.Field{ + ui.PasswordField{ + Key: "Token", + Label: "Token", + }, + }, + } + if !term.IsTerminal(int(syscall.Stdin)) { //nolint:unconvert + gs.Logger.Warn("Stdin is not a terminal, falling back to plain text input") + } + tokenVals, err := tokenForm.Run(gs.Stdin, gs.Stdout) + if err != nil { + return userAuthForm{}, err + } + token := strings.TrimSpace(tokenVals["Token"]) + if token == "" { + return userAuthForm{}, errors.New("Token cannot be empty") + } + + /* Stack form */ + stackForm := ui.Form{ + Banner: "\nEnter the stack where you want to run k6's commands by default.\n" + + "You can enter a full URL (e.g. https://my-team.grafana.net) or just the slug (e.g. my-team):", + Fields: []ui.Field{ + ui.StringField{ + Key: "Stack", + Label: "Stack", + }, + }, + } + stackVals, err := stackForm.Run(gs.Stdin, gs.Stdout) + if err != nil { + return userAuthForm{}, err + } + stack := strings.TrimSpace(stackVals["Stack"]) + if stack == "" { + return userAuthForm{}, errors.New("Stack cannot be empty") + } + + return userAuthForm{token: token, stack: stack}, nil +} + func printConfig(gs *state.GlobalState, cloudConf cloudapi.Config) { const notSet = "" token, stackID, stackURL, defProj := notSet, notSet, notSet, notSet @@ -218,15 +233,15 @@ func printConfig(gs *state.GlobalState, cloudConf cloudapi.Config) { printToStdout(gs, fmt.Sprintf(" default-project-id: %s\n", valueColor.Sprint(defProj))) } -// validateInputs validates a token and a stack if provided +// tokenAuthentication validates a token and a stack // and update the config with the given inputs -func validateInputs( +func authenticateUserToken( gs *state.GlobalState, config *cloudapi.Config, rawConfig json.RawMessage, - token, stack null.String, + token, stack string, ) error { - config.Token = token + config.Token = null.StringFrom(token) consolidatedCurrentConfig, warn, err := cloudapi.GetConsolidatedConfig( rawConfig, gs.Env, "", nil) if err != nil { @@ -237,7 +252,7 @@ func validateInputs( } stackURL, stackID, defaultProjectID, err := validateTokenV6( - gs, consolidatedCurrentConfig, token.String, stack.String) + gs, consolidatedCurrentConfig, token, stack) if err != nil { return fmt.Errorf( "Authentication failed as provided token or stack might not be valid."+ From 335f9233764206d721a3de86bcd0d5da57209024 Mon Sep 17 00:00:00 2001 From: codebien <2103732+codebien@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:39:41 +0200 Subject: [PATCH 14/16] Addressed linter issues --- internal/cmd/cloud_login.go | 10 +++++----- internal/cmd/outputs_cloud.go | 2 +- internal/cmd/tests/cmd_cloud_login_test.go | 4 ++-- internal/cmd/tests/cmd_cloud_run_test.go | 11 +++++------ internal/cmd/tests/cmd_cloud_upload_test.go | 6 +++--- 5 files changed, 16 insertions(+), 17 deletions(-) diff --git a/internal/cmd/cloud_login.go b/internal/cmd/cloud_login.go index 6ac9d8e49d..954e01095d 100644 --- a/internal/cmd/cloud_login.go +++ b/internal/cmd/cloud_login.go @@ -103,10 +103,10 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { return nil case tokenInput.Valid || stackInput.Valid: if !stackInput.Valid || stackInput.String == "" { - return errors.New("Stack value is required but it was not passed or is empty") + return errors.New("stack value is required but it was not passed or is empty") } if !tokenInput.Valid || tokenInput.String == "" { - return errors.New("Token value is required but it was not passed or is empty") + return errors.New("token value is required but it was not passed or is empty") } err := authenticateUserToken(c.globalState, &newCloudConf, currentJSONConfigRaw, tokenInput.String, stackInput.String) if err != nil { @@ -179,7 +179,7 @@ func promptUserAuthForm(gs *state.GlobalState) (userAuthForm, error) { } token := strings.TrimSpace(tokenVals["Token"]) if token == "" { - return userAuthForm{}, errors.New("Token cannot be empty") + return userAuthForm{}, errors.New("token cannot be empty") } /* Stack form */ @@ -199,7 +199,7 @@ func promptUserAuthForm(gs *state.GlobalState) (userAuthForm, error) { } stack := strings.TrimSpace(stackVals["Stack"]) if stack == "" { - return userAuthForm{}, errors.New("Stack cannot be empty") + return userAuthForm{}, errors.New("stack cannot be empty") } return userAuthForm{token: token, stack: stack}, nil @@ -254,7 +254,7 @@ func authenticateUserToken( stackURL, stackID, defaultProjectID, err := validateTokenV6( gs, consolidatedCurrentConfig, token, stack) if err != nil { - return fmt.Errorf( + return fmt.Errorf( //nolint:staticcheck // ST1005: this is a user-facing error "Authentication failed as provided token or stack might not be valid."+ " Learn more: https://grafana.com/docs/grafana-cloud/testing/k6/author-run/tokens-and-cli-authentication."+ " Server error for details: %w", diff --git a/internal/cmd/outputs_cloud.go b/internal/cmd/outputs_cloud.go index d8812c17f0..ccb7e8e5c0 100644 --- a/internal/cmd/outputs_cloud.go +++ b/internal/cmd/outputs_cloud.go @@ -33,7 +33,7 @@ const ( // and to populate the Cloud configuration back in case the Cloud API returned some overrides, // as expected by the Cloud output. // -//nolint:funlen,gocognit,cyclop +//nolint:funlen func createCloudTest(gs *state.GlobalState, test *loadedAndConfiguredTest) error { // Otherwise, we continue normally with the creation of the test run in the k6 Cloud backend services. conf, warn, err := cloudapi.GetConsolidatedConfig( diff --git a/internal/cmd/tests/cmd_cloud_login_test.go b/internal/cmd/tests/cmd_cloud_login_test.go index f85501c37d..392b4f9f24 100644 --- a/internal/cmd/tests/cmd_cloud_login_test.go +++ b/internal/cmd/tests/cmd_cloud_login_test.go @@ -48,7 +48,7 @@ func TestCloudLoginWithArgs(t *testing.T) { token: validToken, wantErr: true, wantStdoutContains: []string{ - "Stack value is required", + "stack value is required", }, }, { @@ -56,7 +56,7 @@ func TestCloudLoginWithArgs(t *testing.T) { stack: validStack, wantErr: true, wantStdoutContains: []string{ - "Token value is required", + "token value is required", }, }, { diff --git a/internal/cmd/tests/cmd_cloud_run_test.go b/internal/cmd/tests/cmd_cloud_run_test.go index 1d26a5e257..e7ff6654c4 100644 --- a/internal/cmd/tests/cmd_cloud_run_test.go +++ b/internal/cmd/tests/cmd_cloud_run_test.go @@ -88,7 +88,7 @@ export const options = { export default function() {};` - ts := makeTestState(t, script, []string{"--local-execution"}, 0) + ts := makeTestState(t, script, []string{"--local-execution"}) testServerHandlerFunc := http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { // When using the local execution mode, the test archive should be uploaded to the cloud @@ -141,7 +141,7 @@ export const options = { export default function() {};` - ts := makeTestState(t, script, []string{"--local-execution", "--no-archive-upload"}, 0) + ts := makeTestState(t, script, []string{"--local-execution", "--no-archive-upload"}) testServerHandlerFunc := http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { body, err := io.ReadAll(req.Body) @@ -198,7 +198,7 @@ export default function() { ` + "console.log(`The test run id is ${__ENV.K6_CLOUDRUN_TEST_RUN_ID}`);" + ` };` - ts := makeTestState(t, script, []string{"--local-execution", "--log-output=stdout"}, 0) + ts := makeTestState(t, script, []string{"--local-execution", "--log-output=stdout"}) const testRunID = 1337 srv := getCloudTestEndChecker(t, testRunID, nil, cloudapi.RunStatusFinished, cloudapi.ResultStatusPassed) @@ -226,7 +226,7 @@ export const options = { export default function() {};` - ts := makeTestState(t, script, []string{"--local-execution", "--log-output=stdout"}, 0) + ts := makeTestState(t, script, []string{"--local-execution", "--log-output=stdout"}) ts.ExpectedExitCode = -1 delete(ts.Env, "K6_CLOUD_STACK_ID") @@ -238,7 +238,7 @@ export default function() {};` }) } -func makeTestState(tb testing.TB, script string, cliFlags []string, expExitCode exitcodes.ExitCode) *GlobalTestState { +func makeTestState(tb testing.TB, script string, cliFlags []string) *GlobalTestState { if cliFlags == nil { cliFlags = []string{"-v", "--log-output=stdout"} } @@ -246,7 +246,6 @@ func makeTestState(tb testing.TB, script string, cliFlags []string, expExitCode ts := NewGlobalTestState(tb) require.NoError(tb, fsext.WriteFile(ts.FS, filepath.Join(ts.Cwd, "test.js"), []byte(script), 0o644)) ts.CmdArgs = append(append([]string{"k6", "cloud", "run"}, cliFlags...), "test.js") - ts.ExpectedExitCode = int(expExitCode) ts.Env["K6_CLOUD_TOKEN"] = "foo" // doesn't matter, we mock the cloud ts.Env["K6_CLOUD_STACK_ID"] = "1234" // doesn't matter, we mock the cloud diff --git a/internal/cmd/tests/cmd_cloud_upload_test.go b/internal/cmd/tests/cmd_cloud_upload_test.go index e61ee1616c..52cad0209b 100644 --- a/internal/cmd/tests/cmd_cloud_upload_test.go +++ b/internal/cmd/tests/cmd_cloud_upload_test.go @@ -133,10 +133,10 @@ func TestK6CloudUpload(t *testing.T) { require.NoError(t, fsext.WriteFile(ts.FS, filepath.Join(ts.Cwd, "archive.tar"), data, 0o644)) ts.CmdArgs = []string{"k6", "cloud", "upload", "archive.tar"} - ts.Env["K6_SHOW_CLOUD_LOGS"] = "false" // no mock for the logs yet + ts.Env["K6_SHOW_CLOUD_LOGS"] = "false" // no mock for the logs yet ts.Env["K6_CLOUD_HOST"] = srv.URL - ts.Env["K6_CLOUD_TOKEN"] = "foo" // doesn't matter, we mock the cloud - ts.Env["K6_CLOUD_STACK_ID"] = "1234" // doesn't matter, we mock the cloud + ts.Env["K6_CLOUD_TOKEN"] = "foo" + ts.Env["K6_CLOUD_STACK_ID"] = "1234" cmd.ExecuteWithGlobalState(ts.GlobalState) From 78d3cfe9815bf9e94f09f637750479c12189fda8 Mon Sep 17 00:00:00 2001 From: codebien <2103732+codebien@users.noreply.github.com> Date: Wed, 15 Apr 2026 18:20:08 +0200 Subject: [PATCH 15/16] Refined the token mask logic --- internal/cmd/cloud_login.go | 22 +++-- internal/cmd/cloud_login_test.go | 98 ++++++++++++++++++++++ internal/cmd/tests/cmd_cloud_login_test.go | 3 +- 3 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 internal/cmd/cloud_login_test.go diff --git a/internal/cmd/cloud_login.go b/internal/cmd/cloud_login.go index 954e01095d..4c5a96612a 100644 --- a/internal/cmd/cloud_login.go +++ b/internal/cmd/cloud_login.go @@ -210,11 +210,7 @@ func printConfig(gs *state.GlobalState, cloudConf cloudapi.Config) { token, stackID, stackURL, defProj := notSet, notSet, notSet, notSet if cloudConf.Token.String != "" { - // If a token is set then we assume we have a valid token longer than 8 chars - // print the token with all the chars masked, except the first and the last four - unmasked := cloudConf.Token.String - asterisks := strings.Repeat("*", len(unmasked)-8) - token = unmasked[:4] + asterisks + unmasked[len(unmasked)-4:] + token = maskToken(cloudConf.Token.String) } if cloudConf.StackID.Valid { stackID = strconv.FormatInt(cloudConf.StackID.Int64, 10) @@ -233,6 +229,22 @@ func printConfig(gs *state.GlobalState, cloudConf cloudapi.Config) { printToStdout(gs, fmt.Sprintf(" default-project-id: %s\n", valueColor.Sprint(defProj))) } +func maskToken(unmasked string) string { + if len(unmasked) < 1 { + return "" + } + // Require at least 4 asterisks in the middle to give a meaningful visual hint. + // Any token shorter than 12 chars would produce fewer, so mask it entirely. + if len(unmasked) < 12 { + return strings.Repeat("*", len(unmasked)) + } + // We try to have a good DX here. + // A valid Cloud token should be 12+ chars, so it prints the token with all + // the chars masked, except the first and the last four. + asterisks := strings.Repeat("*", len(unmasked)-8) + return unmasked[:4] + asterisks + unmasked[len(unmasked)-4:] +} + // tokenAuthentication validates a token and a stack // and update the config with the given inputs func authenticateUserToken( diff --git a/internal/cmd/cloud_login_test.go b/internal/cmd/cloud_login_test.go new file mode 100644 index 0000000000..beae35a6ff --- /dev/null +++ b/internal/cmd/cloud_login_test.go @@ -0,0 +1,98 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "gopkg.in/guregu/null.v3" + + "go.k6.io/k6/cloudapi" + "go.k6.io/k6/internal/cmd/tests" +) + +func TestMaskToken(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + token string + expected string + }{ + { + name: "empty string returns empty string", + token: "", + expected: "", + }, + { + name: "single character is fully masked", + token: "a", + expected: "*", + }, + { + name: "four characters are fully masked", + token: "abcd", + expected: "****", + }, + { + name: "eleven characters are fully masked", + token: "abcdefghijk", + expected: "***********", + }, + { + name: "twelve characters masks the middle four", + token: "abcdefghijkl", + expected: "abcd****ijkl", + }, + { + name: "long token masks all but first and last four", + token: "tok_abcdefghijklmnopqrstuvwxyz1234", + expected: "tok_" + strings.Repeat("*", 26) + "1234", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := maskToken(tc.token) + assert.Equal(t, tc.expected, got) + }) + } +} + +func TestPrintConfigTokenOutput(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + token string + expected string + }{ + { + name: "unset token shows not set placeholder", + token: "", + expected: "", + }, + { + name: "token masked", + token: "abcdefghijkl", + expected: "abcd****ijkl", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ts := tests.NewGlobalTestState(t) + conf := cloudapi.Config{} + if tc.token != "" { + conf.Token = null.StringFrom(tc.token) + } + + printConfig(ts.GlobalState, conf) + assert.Contains(t, ts.Stdout.String(), " token: "+tc.expected) + }) + } +} diff --git a/internal/cmd/tests/cmd_cloud_login_test.go b/internal/cmd/tests/cmd_cloud_login_test.go index 392b4f9f24..dc9ac4d5f4 100644 --- a/internal/cmd/tests/cmd_cloud_login_test.go +++ b/internal/cmd/tests/cmd_cloud_login_test.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -37,7 +38,7 @@ func TestCloudLoginWithArgs(t *testing.T) { wantErr: false, wantStdoutContains: []string{ "Logged in successfully", - fmt.Sprintf("token: %s", "vali***oken"), + fmt.Sprintf("token: %s", strings.Repeat("*", 11)), fmt.Sprintf("stack-id: %d", validStackID), fmt.Sprintf("stack-url: %s", validStackURL), fmt.Sprintf("default-project-id: %d", defaultProjectID), From 36b0253a87a66679fa9c1a0b0a8fbf1bbe1d2f42 Mon Sep 17 00:00:00 2001 From: codebien <2103732+codebien@users.noreply.github.com> Date: Wed, 15 Apr 2026 18:36:21 +0200 Subject: [PATCH 16/16] Reduce again the cognitive complexity after rebase --- internal/cmd/outputs_cloud.go | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/internal/cmd/outputs_cloud.go b/internal/cmd/outputs_cloud.go index ccb7e8e5c0..1effc7ccad 100644 --- a/internal/cmd/outputs_cloud.go +++ b/internal/cmd/outputs_cloud.go @@ -59,17 +59,8 @@ func createCloudTest(gs *state.GlobalState, test *loadedAndConfiguredTest) error return err } - if !conf.Name.Valid || conf.Name.String == "" { - scriptPath := test.source.URL.String() - if scriptPath == "" { - // Script from stdin without a name, likely from stdin - return errors.New("script name not set, please specify K6_CLOUD_NAME or options.cloud.name") - } - - conf.Name = null.StringFrom(filepath.Base(scriptPath)) - } - if conf.Name.String == "-" { - conf.Name = null.StringFrom(defaultTestName) + if conf.Name, err = resolveCloudTestName(conf.Name, test.source.URL.String()); err != nil { + return err } thresholds := make(map[string][]string) @@ -201,3 +192,19 @@ func cloudConfToRawMessage(conf cloudapi.Config) (json.RawMessage, error) { } return buff.Bytes(), nil } + +// resolveCloudTestName returns the test name from the config, or derives it from +// the script path when the config name is unset. A name of "-" is replaced +// with the default test name. +func resolveCloudTestName(name null.String, scriptPath string) (null.String, error) { + if !name.Valid || name.String == "" { + if scriptPath == "" { + return name, errors.New("script name not set, please specify K6_CLOUD_NAME or options.cloud.name") + } + name = null.StringFrom(filepath.Base(scriptPath)) + } + if name.String == "-" { + name = null.StringFrom(defaultTestName) + } + return name, nil +}