Ctrl K
ComunidadGuías…

cli/root_internal_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package cli

import (
	"bytes"
	"context"
	"crypto/tls"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"os"
	"runtime"
	"testing"
	"time"

	"github.com/stretchr/testify/require"
	"go.uber.org/goleak"

	"cdr.dev/slog/v3"
	"cdr.dev/slog/v3/sloggers/sloghuman"
	"github.com/coder/coder/v2/cli/cliui"
	"github.com/coder/coder/v2/cli/telemetry"
	"github.com/coder/coder/v2/coderd/tracing"
	"github.com/coder/coder/v2/codersdk"
	"github.com/coder/coder/v2/testutil"
	"github.com/coder/pretty"
	"github.com/coder/serpent"
)

func TestMain(m *testing.M) {
	// Report multi-second pauses of the whole process, see coder/internal#1365.
	testutil.StartStallDetector(5 * time.Second)
	// Fail with a heap profile before a runaway allocation pushes the runner into swap.
	testutil.StartMemoryGuardFromEnv()
	if runtime.GOOS == "windows" {
		// Don't run goleak on windows tests, they're super flaky right now.
		// See: https://github.com/coder/coder/issues/8954
		os.Exit(m.Run())
	}
	goleak.VerifyTestMain(m, testutil.GoleakOptions...)
}

func Test_formatExamples(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name        string
		examples    []Example
		wantMatches []string
	}{
		{
			name:        "No examples",
			examples:    nil,
			wantMatches: nil,
		},
		{
			name: "Output examples",
			examples: []Example{
				{
					Description: "Hello world.",
					Command:     "echo hello",
				},
				{
					Description: "Bye bye.",
					Command:     "echo bye",
				},
			},
			wantMatches: []string{
				"Hello world", "echo hello",
				"Bye bye", "echo bye",
			},
		},
		{
			name: "No description outputs commands",
			examples: []Example{
				{
					Command: "echo hello",
				},
			},
			wantMatches: []string{
				"echo hello",
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			got := FormatExamples(tt.examples...)
			if len(tt.wantMatches) == 0 {
				require.Empty(t, got)
			} else {
				for _, want := range tt.wantMatches {
					require.Contains(t, got, want)
				}
			}
		})
	}
}

func Test_wrapTransportWithVersionCheck(t *testing.T) {
	t.Parallel()

	t.Run("NoOutput", func(t *testing.T) {
		t.Parallel()
		r := &RootCmd{}
		cmd, err := r.Command(nil)
		require.NoError(t, err)
		var buf bytes.Buffer
		inv := cmd.Invoke()
		inv.Stderr = &buf
		rt := wrapTransportWithVersionCheck(roundTripper(func(req *http.Request) (*http.Response, error) {
			return &http.Response{
				StatusCode: http.StatusOK,
				Header: http.Header{
					// Provider a version that will not match!
					codersdk.BuildVersionHeader: []string{"v2.0.0"},
				},
				Body: io.NopCloser(nil),
			}, nil
		}), inv, "v2.0.0", nil)
		req := httptest.NewRequest(http.MethodGet, "http://example.com", nil)
		res, err := rt.RoundTrip(req)
		require.NoError(t, err)
		defer res.Body.Close()
		require.Equal(t, "", buf.String())
	})

	t.Run("CustomUpgradeMessage", func(t *testing.T) {
		t.Parallel()

		r := &RootCmd{}

		cmd, err := r.Command(nil)
		require.NoError(t, err)

		var buf bytes.Buffer
		inv := cmd.Invoke()
		inv.Stderr = &buf
		expectedUpgradeMessage := "My custom upgrade message"
		rt := wrapTransportWithVersionCheck(roundTripper(func(req *http.Request) (*http.Response, error) {
			return &http.Response{
				StatusCode: http.StatusOK,
				Header: http.Header{
					// Provider a version that will not match!
					codersdk.BuildVersionHeader: []string{"v1.0.0"},
				},
				Body: io.NopCloser(nil),
			}, nil
		}), inv, "v2.0.0", func(ctx context.Context) (codersdk.BuildInfoResponse, error) {
			return codersdk.BuildInfoResponse{
				UpgradeMessage: expectedUpgradeMessage,
			}, nil
		})
		req := httptest.NewRequest(http.MethodGet, "http://example.com", nil)
		res, err := rt.RoundTrip(req)
		require.NoError(t, err)
		defer res.Body.Close()

		// Run this twice to ensure the upgrade message is only printed once.
		res, err = rt.RoundTrip(req)
		require.NoError(t, err)
		defer res.Body.Close()

		fmtOutput := fmt.Sprintf("version mismatch: client v2.0.0, server v1.0.0\n%s", expectedUpgradeMessage)
		expectedOutput := fmt.Sprintln(pretty.Sprint(cliui.DefaultStyles.Warn, fmtOutput))
		require.Equal(t, expectedOutput, buf.String())
	})

	t.Run("ServerStableVersion", func(t *testing.T) {
		t.Parallel()
		r := &RootCmd{}
		cmd, err := r.Command(nil)
		require.NoError(t, err)
		var buf bytes.Buffer
		inv := cmd.Invoke()
		inv.Stderr = &buf
		rt := wrapTransportWithVersionCheck(roundTripper(func(req *http.Request) (*http.Response, error) {
			return &http.Response{
				StatusCode: http.StatusOK,
				Header: http.Header{
					codersdk.BuildVersionHeader: []string{"v2.31.0"},
				},
				Body: io.NopCloser(nil),
			}, nil
		}), inv, "v2.31.0", nil)
		req := httptest.NewRequest(http.MethodGet, "http://example.com", nil)
		res, err := rt.RoundTrip(req)
		require.NoError(t, err)
		defer res.Body.Close()
		require.Empty(t, buf.String())
	})
}

func Test_serverVersionMessage(t *testing.T) {
	t.Parallel()

	cases := []struct {
		name     string
		version  string
		expected string
	}{
		{"Stable", "v2.31.0", ""},
		{"Dev", "v0.0.0-devel+abc123", "the server is running a development version of Coder (v0.0.0-devel+abc123)"},
		{"RC", "v2.31.0-rc.1", "the server is running a release candidate of Coder (v2.31.0-rc.1)"},
		{"RCDevel", "v2.33.0-rc.1-devel+727ec00f7", "the server is running a release candidate of Coder (v2.33.0-rc.1-devel+727ec00f7)"},
		{"Empty", "", ""},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			t.Parallel()
			require.Equal(t, c.expected, serverVersionMessage(c.version))
		})
	}
}

func Test_wrapTransportWithTelemetryHeader(t *testing.T) {
	t.Parallel()

	rt := wrapTransportWithTelemetryHeader(roundTripper(func(req *http.Request) (*http.Response, error) {
		return &http.Response{
			Body: io.NopCloser(nil),
		}, nil
	}), &serpent.Invocation{
		Command: &serpent.Command{
			Use: "test",
			Options: serpent.OptionSet{{
				Name:        "bananas",
				Description: "hey",
			}},
		},
	})
	req := httptest.NewRequest(http.MethodGet, "http://example.com", nil)
	res, err := rt.RoundTrip(req)
	require.NoError(t, err)
	defer res.Body.Close()
	resp := req.Header.Get(codersdk.CLITelemetryHeader)
	require.NotEmpty(t, resp)
	data, err := base64.StdEncoding.DecodeString(resp)
	require.NoError(t, err)
	var ti telemetry.Invocation
	err = json.Unmarshal(data, &ti)
	require.NoError(t, err)
	require.Equal(t, ti.Command, "test")
}

//nolint:tparallel,paralleltest // This test modifies environment variables.
func TestPrintDeprecatedOptions(t *testing.T) {
	newValue := serpent.StringOf(new(string))

	// Both the "new" option and the deprecated option point at the
	// same Value, mirroring how codersdk/deployment.go wires the
	// CODER_EMAIL_* / CODER_NOTIFICATIONS_EMAIL_* pairs.
	newOpt := serpent.Option{
		Name:  "new-option",
		Flag:  "new-option",
		Env:   "CODER_TEST_NEW_OPTION",
		Value: newValue,
	}
	deprecatedOpt := serpent.Option{
		Name:       "old-option",
		Flag:       "old-option",
		Env:        "CODER_TEST_OLD_OPTION",
		Value:      newValue, // same pointer
		UseInstead: serpent.OptionSet{newOpt},
	}

	makeCmd := func(opts serpent.OptionSet) *serpent.Command {
		return &serpent.Command{
			Use:        "test",
			Options:    opts,
			Middleware: PrintDeprecatedOptions(),
			Handler: func(_ *serpent.Invocation) error {
				return nil
			},
		}
	}

	t.Run("EnvOnlyNew_NoWarning", func(t *testing.T) {
		t.Setenv("CODER_TEST_NEW_OPTION", "val")

		cmd := makeCmd(serpent.OptionSet{newOpt, deprecatedOpt})
		var stderr bytes.Buffer
		inv := cmd.Invoke()
		inv.Environ = serpent.ParseEnviron(os.Environ(), "")
		inv.Stderr = &stderr
		err := inv.Run()
		require.NoError(t, err)
		require.Empty(t, stderr.String(),
			"setting only the new env var should not produce a deprecation warning")
	})

	t.Run("EnvOnlyOld_Warning", func(t *testing.T) {
		t.Setenv("CODER_TEST_OLD_OPTION", "val")

		cmd := makeCmd(serpent.OptionSet{newOpt, deprecatedOpt})
		var stderr bytes.Buffer
		inv := cmd.Invoke()
		inv.Environ = serpent.ParseEnviron(os.Environ(), "")
		inv.Stderr = &stderr
		err := inv.Run()
		require.NoError(t, err)
		require.Contains(t, stderr.String(), "is deprecated",
			"setting the deprecated env var should produce a warning")
	})

	t.Run("EnvBothSet_Warning", func(t *testing.T) {
		t.Setenv("CODER_TEST_NEW_OPTION", "new")
		t.Setenv("CODER_TEST_OLD_OPTION", "old")

		cmd := makeCmd(serpent.OptionSet{newOpt, deprecatedOpt})
		var stderr bytes.Buffer
		inv := cmd.Invoke()
		inv.Environ = serpent.ParseEnviron(os.Environ(), "")
		inv.Stderr = &stderr
		err := inv.Run()
		require.NoError(t, err)
		require.Contains(t, stderr.String(), "is deprecated",
			"setting both env vars should still warn about the deprecated one")
	})

	t.Run("DeprecatedEnvAndNewFlag_Warning", func(t *testing.T) {
		t.Setenv("CODER_TEST_OLD_OPTION", "val")

		cmd := makeCmd(serpent.OptionSet{newOpt, deprecatedOpt})
		var stderr bytes.Buffer
		inv := cmd.Invoke("--new-option", "val")
		inv.Environ = serpent.ParseEnviron(os.Environ(), "")
		inv.Stderr = &stderr
		err := inv.Run()
		require.NoError(t, err)
		require.Contains(t, stderr.String(), "`CODER_TEST_OLD_OPTION` is deprecated",
			"setting the deprecated env var should still warn even if the replacement flag overrides the value")
		require.NotContains(t, stderr.String(), "`--old-option` is deprecated",
			"the deprecated environment variable should not be misreported as a deprecated flag")
	})

	t.Run("FlagOnlyNew_NoWarning", func(t *testing.T) {
		cmd := makeCmd(serpent.OptionSet{newOpt, deprecatedOpt})
		var stderr bytes.Buffer
		inv := cmd.Invoke("--new-option", "val")
		inv.Stderr = &stderr
		err := inv.Run()
		require.NoError(t, err)
		require.Empty(t, stderr.String(),
			"passing only the new flag should not produce a deprecation warning")
	})

	t.Run("FlagOnlyOld_Warning", func(t *testing.T) {
		cmd := makeCmd(serpent.OptionSet{newOpt, deprecatedOpt})
		var stderr bytes.Buffer
		inv := cmd.Invoke("--old-option", "val")
		inv.Stderr = &stderr
		err := inv.Run()
		require.NoError(t, err)
		require.Contains(t, stderr.String(), "is deprecated",
			"passing the deprecated flag should produce a warning")
	})

	t.Run("CODER_EMAIL_FROM_NoWarning", func(t *testing.T) {
		t.Setenv("CODER_EMAIL_FROM", "noreply@example.com")

		deploymentValues := new(codersdk.DeploymentValues)
		cmd := makeCmd(deploymentValues.Options())
		var stderr bytes.Buffer
		inv := cmd.Invoke()
		inv.Environ = serpent.ParseEnviron([]string{"CODER_EMAIL_FROM=noreply@example.com"}, "")
		inv.Stderr = &stderr
		err := inv.Run()
		require.NoError(t, err)
		require.NotContains(t, stderr.String(), "is deprecated",
			"setting only CODER_EMAIL_FROM should not produce any deprecation warning")
	})

	t.Run("NothingSet_NoWarning", func(t *testing.T) {
		t.Parallel()

		cmd := makeCmd(serpent.OptionSet{newOpt, deprecatedOpt})
		var stderr bytes.Buffer
		inv := cmd.Invoke()
		inv.Stderr = &stderr
		err := inv.Run()
		require.NoError(t, err)
		require.Empty(t, stderr.String(),
			"setting nothing should not produce a deprecation warning")
	})
}

func TestWarnConflictingOptions(t *testing.T) {
	t.Parallel()

	makeCmd := func() *serpent.Command {
		var (
			columns []string
			output  string
		)
		return &serpent.Command{
			Use: "test",
			Options: serpent.OptionSet{
				{
					Flag:          "column",
					FlagShorthand: "c",
					Default:       "foo,bar",
					Value:         serpent.EnumArrayOf(&columns, "foo", "bar"),
				},
				{
					Flag:          "output",
					FlagShorthand: "o",
					Default:       "table",
					Value:         serpent.EnumOf(&output, "table", "json"),
				},
			},
			Middleware: WarnConflictingOptions(),
			Handler: func(_ *serpent.Invocation) error {
				return nil
			},
		}
	}

	t.Run("ColumnWithNonTableOutput_Warning", func(t *testing.T) {
		t.Parallel()

		cmd := makeCmd()
		var stderr bytes.Buffer
		inv := cmd.Invoke("-c", "foo", "-o", "json")
		inv.Stderr = &stderr
		err := inv.Run()
		require.NoError(t, err)
		require.Contains(t, stderr.String(), "WARN: ")
		require.Contains(t, stderr.String(), "--column only applies to table output")
	})

	t.Run("ColumnWithTableOutput_NoWarning", func(t *testing.T) {
		t.Parallel()

		cmd := makeCmd()
		var stderr bytes.Buffer
		inv := cmd.Invoke("-c", "foo", "-o", "table")
		inv.Stderr = &stderr
		err := inv.Run()
		require.NoError(t, err)
		require.Empty(t, stderr.String())
	})

	t.Run("ColumnWithDefaultTableOutput_NoWarning", func(t *testing.T) {
		t.Parallel()

		cmd := makeCmd()
		var stderr bytes.Buffer
		inv := cmd.Invoke("-c", "foo")
		inv.Stderr = &stderr
		err := inv.Run()
		require.NoError(t, err)
		require.Empty(t, stderr.String())
	})

	t.Run("NoColumnFlag_NoWarning", func(t *testing.T) {
		t.Parallel()

		cmd := makeCmd()
		var stderr bytes.Buffer
		inv := cmd.Invoke("-o", "json")
		inv.Stderr = &stderr
		err := inv.Run()
		require.NoError(t, err)
		require.Empty(t, stderr.String())
	})

	t.Run("NoOutputFlag_NoWarning", func(t *testing.T) {
		t.Parallel()

		var columns []string
		cmd := &serpent.Command{
			Use: "test",
			Options: serpent.OptionSet{
				{
					Flag:          "column",
					FlagShorthand: "c",
					Default:       "foo,bar",
					Value:         serpent.EnumArrayOf(&columns, "foo", "bar"),
				},
			},
			Middleware: WarnConflictingOptions(),
			Handler: func(_ *serpent.Invocation) error {
				return nil
			},
		}
		var stderr bytes.Buffer
		inv := cmd.Invoke("-c", "foo")
		inv.Stderr = &stderr
		err := inv.Run()
		require.NoError(t, err)
		require.Empty(t, stderr.String(),
			"a command with no --output flag always renders a table, so no warning should be printed")
	})
}

func Test_wrapTransportWithEntitlementsCheck(t *testing.T) {
	t.Parallel()

	lines := []string{"First Warning", "Second Warning"}
	var buf bytes.Buffer
	rt := wrapTransportWithEntitlementsCheck(roundTripper(func(req *http.Request) (*http.Response, error) {
		return &http.Response{
			StatusCode: http.StatusOK,
			Header: http.Header{
				codersdk.EntitlementsWarningHeader: lines,
			},
			Body: io.NopCloser(nil),
		}, nil
	}), &buf)
	res, err := rt.RoundTrip(httptest.NewRequest(http.MethodGet, "http://example.com", nil))
	require.NoError(t, err)
	defer res.Body.Close()
	expectedOutput := fmt.Sprintf("%s\n%s\n", pretty.Sprint(cliui.DefaultStyles.Warn, lines[0]),
		pretty.Sprint(cliui.DefaultStyles.Warn, lines[1]))
	require.Equal(t, expectedOutput, buf.String())
}

func Test_ensureTLSConfig(t *testing.T) {
	t.Parallel()

	t.Run("NoFilesSpecified", func(t *testing.T) {
		t.Parallel()
		r := &RootCmd{}
		err := r.ensureTLSConfig()
		require.NoError(t, err)
		require.Nil(t, r.tlsConfig)
	})

	t.Run("OnlyCertFileErrors", func(t *testing.T) {
		t.Parallel()
		r := &RootCmd{
			tlsClientCertFile: "/some/cert.pem",
		}
		err := r.ensureTLSConfig()
		require.Error(t, err)
		require.Contains(t, err.Error(), "must be specified together")
	})

	t.Run("OnlyKeyFileErrors", func(t *testing.T) {
		t.Parallel()
		r := &RootCmd{
			tlsClientKeyFile: "/some/key.pem",
		}
		err := r.ensureTLSConfig()
		require.Error(t, err)
		require.Contains(t, err.Error(), "must be specified together")
	})

	t.Run("InvalidCAFileErrors", func(t *testing.T) {
		t.Parallel()
		r := &RootCmd{
			tlsCAFile: "/nonexistent/ca.pem",
		}
		err := r.ensureTLSConfig()
		require.Error(t, err)
		require.Contains(t, err.Error(), "read TLS CA file")
	})

	t.Run("AlreadySetSkipsLoading", func(t *testing.T) {
		t.Parallel()
		existingConfig := &tls.Config{MinVersion: tls.VersionTLS13}
		r := &RootCmd{
			tlsConfig:         existingConfig,
			tlsClientCertFile: "/some/cert.pem",
		}
		err := r.ensureTLSConfig()
		require.NoError(t, err)
		require.Same(t, existingConfig, r.tlsConfig)
	})

	t.Run("InvalidPEMContentErrors", func(t *testing.T) {
		t.Parallel()
		tmpFile, err := os.CreateTemp("", "invalid-ca-*.pem")
		require.NoError(t, err)
		defer os.Remove(tmpFile.Name())
		_, err = tmpFile.WriteString("this is not valid PEM data")
		require.NoError(t, err)
		require.NoError(t, tmpFile.Close())

		r := &RootCmd{
			tlsCAFile: tmpFile.Name(),
		}
		err = r.ensureTLSConfig()
		require.Error(t, err)
		require.Contains(t, err.Error(), "failed to parse CA certificate")
	})
}

func TestNewHTTPTransportClonesDefaultTransport(t *testing.T) {
	t.Parallel()

	transport, err := newHTTPTransport(nil)
	require.NoError(t, err)
	require.NotSame(t, http.DefaultTransport, transport)
	require.IsType(t, &http.Transport{}, transport)
}

func TestNewHTTPTransportAppliesTLSConfigToClone(t *testing.T) {
	t.Parallel()

	tlsConfig := &tls.Config{MinVersion: tls.VersionTLS13}
	transport, err := newHTTPTransport(tlsConfig)
	require.NoError(t, err)
	require.NotSame(t, http.DefaultTransport, transport)

	httpTransport, ok := transport.(*http.Transport)
	require.True(t, ok)
	require.Same(t, tlsConfig, httpTransport.TLSClientConfig)
}

func TestResolveClientSessionID(t *testing.T) {
	t.Parallel()

	t.Run("GeneratesWhenUnset", func(t *testing.T) {
		t.Parallel()

		inv := &serpent.Invocation{Stderr: io.Discard}
		id, err := resolveClientSessionID(inv)
		require.NoError(t, err)
		require.True(t, tracing.ValidSessionID(id), "generated session ID must be valid")
	})

	t.Run("UsesValidEnv", func(t *testing.T) {
		t.Parallel()

		const want = "0123456789abcdef0123456789abcdef"
		inv := &serpent.Invocation{Stderr: io.Discard}
		inv.Environ.Set(clientSessionIDEnv, want)
		id, err := resolveClientSessionID(inv)
		require.NoError(t, err)
		require.Equal(t, want, id)
	})

	t.Run("UsesMalformedEnvVerbatim", func(t *testing.T) {
		t.Parallel()

		const want = "not-a-valid-session-id"
		inv := &serpent.Invocation{Stderr: io.Discard}
		inv.Environ.Set(clientSessionIDEnv, want)
		id, err := resolveClientSessionID(inv)
		require.NoError(t, err)
		require.Equal(t, want, id, "a set CODER_TRACE_SESSION_ID is used verbatim")
	})

	t.Run("GeneratesWhenEnvEmpty", func(t *testing.T) {
		t.Parallel()

		inv := &serpent.Invocation{Stderr: io.Discard}
		inv.Environ.Set(clientSessionIDEnv, "")
		id, err := resolveClientSessionID(inv)
		require.NoError(t, err)
		require.True(t, tracing.ValidSessionID(id), "empty env must fall back to a generated ID")
	})
}

func TestClientSessionIDMiddleware(t *testing.T) {
	t.Parallel()

	// runMiddleware runs clientSessionIDMiddleware around a handler that
	// captures the resulting invocation, returning it for assertions. The
	// invocation opts in with the annotationClientSessionID annotation unless
	// shouldAttachSessionID is false.
	runMiddleware := func(t *testing.T, inv *serpent.Invocation, shouldAttachSessionID bool) *serpent.Invocation {
		t.Helper()
		annotations := serpent.Annotations{}
		if shouldAttachSessionID {
			annotations = annotations.Mark(annotationClientSessionID, "")
		}
		inv.Command = &serpent.Command{Annotations: annotations}
		var got *serpent.Invocation
		handler := clientSessionIDMiddleware()(func(i *serpent.Invocation) error {
			got = i
			return nil
		})
		require.NoError(t, handler(inv))
		require.NotNil(t, got)
		return got
	}

	t.Run("GeneratesAndStoresOnContext", func(t *testing.T) {
		t.Parallel()

		inv := (&serpent.Invocation{Stderr: io.Discard}).WithContext(t.Context())
		got := runMiddleware(t, inv, true)
		id := clientSessionIDFromContext(got.Context())
		require.True(t, tracing.ValidSessionID(id), "middleware must store a valid generated ID")
	})

	t.Run("UsesEnv", func(t *testing.T) {
		t.Parallel()

		const want = "0123456789abcdef0123456789abcdef"
		inv := (&serpent.Invocation{Stderr: io.Discard}).WithContext(t.Context())
		inv.Environ.Set(clientSessionIDEnv, want)
		got := runMiddleware(t, inv, true)
		require.Equal(t, want, clientSessionIDFromContext(got.Context()))
	})

	t.Run("AttachesSlogField", func(t *testing.T) {
		t.Parallel()

		inv := (&serpent.Invocation{Stderr: io.Discard}).WithContext(t.Context())
		got := runMiddleware(t, inv, true)
		id := clientSessionIDFromContext(got.Context())
		require.NotEmpty(t, id)

		// A fresh logger that logs with the invocation context must include the
		// client_session_id field, proving the field rides on the context rather
		// than a specific logger instance.
		var buf bytes.Buffer
		logger := slog.Make(sloghuman.Sink(&buf))
		logger.Info(got.Context(), "session id log line")
		require.Contains(t, buf.String(), "client_session_id="+id)
	})

	t.Run("SkipsWithoutOptIn", func(t *testing.T) {
		t.Parallel()

		inv := (&serpent.Invocation{Stderr: io.Discard}).WithContext(t.Context())
		inv.Environ.Set(clientSessionIDEnv, "0123456789abcdef0123456789abcdef")
		got := runMiddleware(t, inv, false)
		require.Empty(t, clientSessionIDFromContext(got.Context()),
			"commands without the opt-in annotation must not resolve a session ID")
	})

	t.Run("SkipsCompletionMode", func(t *testing.T) {
		t.Parallel()

		inv := (&serpent.Invocation{Stderr: io.Discard}).
			WithContext(t.Context())
		inv.Environ.Set(serpent.CompletionModeEnv, "1")
		got := runMiddleware(t, inv, true)
		require.Empty(t, clientSessionIDFromContext(got.Context()),
			"completion mode must not resolve a session ID")
	})
}

func TestWrapTransportWithSessionIDHeader(t *testing.T) {
	t.Parallel()

	const sessionID = "0123456789abcdef0123456789abcdef"

	var gotHeader http.Header
	srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
		gotHeader = r.Header.Clone()
		rw.WriteHeader(http.StatusOK)
	}))
	defer srv.Close()

	client := &http.Client{
		Transport: wrapTransportWithSessionIDHeader(http.DefaultTransport, sessionID),
	}

	req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL, nil)
	require.NoError(t, err)
	resp, err := client.Do(req)
	require.NoError(t, err)
	defer resp.Body.Close()

	// The baggage header the server receives must carry the session ID under
	// the client_session_id key so the tracing middleware can extract it.
	require.Equal(t, tracing.SessionIDBaggageKey+"="+sessionID, gotHeader.Get("baggage"))
}

func Test_createHTTPClientRedirects(t *testing.T) {
	t.Parallel()

	// newRedirectingServers returns a stale server that 301s every request
	// to a target serving the "list" shape on GET, so that following the
	// redirect would silently turn a create into a list.
	newRedirectingServers := func(t *testing.T) (stale, target *httptest.Server) {
		target = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			w.Header().Set("Content-Type", "application/json")
			_, _ = w.Write([]byte("[]"))
		}))
		t.Cleanup(target.Close)

		stale = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			http.Redirect(w, r, target.URL+r.URL.Path, http.StatusMovedPermanently)
		}))
		t.Cleanup(stale.Close)
		return stale, target
	}

	newClient := func(t *testing.T, r *RootCmd, rawURL string) *codersdk.Client {
		serverURL, err := url.Parse(rawURL)
		require.NoError(t, err)
		inv := &serpent.Invocation{Command: &serpent.Command{Use: "test"}}
		httpClient, err := r.createHTTPClient(context.Background(), serverURL, inv)
		require.NoError(t, err)
		t.Cleanup(httpClient.CloseIdleConnections)
		return codersdk.New(serverURL, codersdk.WithHTTPClient(httpClient))
	}

	t.Run("RejectedByDefault", func(t *testing.T) {
		t.Parallel()
		stale, target := newRedirectingServers(t)

		r := &RootCmd{noVersionCheck: true, noFeatureWarning: true}
		client := newClient(t, r, stale.URL)
		_, err := client.CreateToken(context.Background(), codersdk.Me, codersdk.CreateTokenRequest{})
		require.Error(t, err)

		var redirectErr *redirectError
		require.ErrorAs(t, err, &redirectErr)
		require.Equal(t, stale.URL+"/api/v2/users/me/keys/tokens", redirectErr.from.String())
		require.Equal(t, target.URL+"/api/v2/users/me/keys/tokens", redirectErr.to.String())

		msg, special := cliHumanFormatError("", err, nil)
		require.True(t, special)
		require.Contains(t, msg, "server redirected request from "+stale.URL)
		require.Contains(t, msg, fmt.Sprintf("Run %q to log in against the new URL", "coder login "+target.URL))
		require.Contains(t, msg, "--allow-redirects")
	})

	t.Run("AllowRedirects", func(t *testing.T) {
		t.Parallel()
		stale, _ := newRedirectingServers(t)

		r := &RootCmd{noVersionCheck: true, noFeatureWarning: true, allowRedirects: true}
		client := newClient(t, r, stale.URL)
		tokens, err := client.Tokens(context.Background(), codersdk.Me, codersdk.TokensFilter{})
		require.NoError(t, err)
		require.Empty(t, tokens)

		// Legacy behavior: the POST is followed as a GET and fails on the
		// mismatched body rather than on the redirect itself.
		_, err = client.CreateToken(context.Background(), codersdk.Me, codersdk.CreateTokenRequest{})
		require.Error(t, err)
		var redirectErr *redirectError
		require.False(t, errors.As(err, &redirectErr))
	})
}

func Test_redirectErrorHelper(t *testing.T) {
	t.Parallel()

	mustParse := func(s string) *url.URL {
		u, err := url.Parse(s)
		require.NoError(t, err)
		return u
	}

	t.Run("DifferentHost", func(t *testing.T) {
		t.Parallel()
		err := &redirectError{
			from: mustParse("https://old.example.com/api/v2/users/me"),
			to:   mustParse("https://new.example.com/api/v2/users/me"),
		}
		require.Contains(t, err.Helper(), `"coder login https://new.example.com"`)
	})

	t.Run("SchemeUpgrade", func(t *testing.T) {
		t.Parallel()
		err := &redirectError{
			from: mustParse("http://coder.example.com/api/v2/users/me"),
			to:   mustParse("https://coder.example.com/api/v2/users/me"),
		}
		require.Contains(t, err.Helper(), `"coder login https://coder.example.com"`)
	})

	t.Run("SameDeployment", func(t *testing.T) {
		t.Parallel()
		err := &redirectError{
			from: mustParse("https://coder.example.com/api/v2/users/me"),
			to:   mustParse("https://coder.example.com/api/v2/users/me/"),
		}
		require.Contains(t, err.Helper(), "redirected within the same deployment")
		require.NotContains(t, err.Helper(), "coder login")
	})
}