mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
test: add memory guard and heap profiles for CI timeouts Add a memory guard to `cli`, `agent`, and `enterprise/cli` to diagnose recurring macOS `test-go-pg` timeouts. Recent logs show runaway memory usage and heavy swapping, but the allocation source remains unknown. - Check Go runtime memory once per second, with per-process CI limits of 5 GiB on macOS and 6 GiB on Linux and Windows. Disabled when `CODER_TEST_MEMORY_LIMIT_MIB` is unset. - On exceeding the limit, save a heap profile, attempt a second profile after forced GC with a five-second wait limit, then panic with memory usage, profile paths, and all goroutine stacks. - Upload heap profiles from unsuccessful CI jobs on every OS, retaining them for seven days. - Test guard lifecycle, environment parsing, panic diagnostics, profile validity, and post-GC allocation attribution. Validate with race tests, vet, lint, and a manual macOS test. Refs https://linear.app/codercom/issue/PLAT-251/flake-test-go-pg-macos-job-timeoutcancelled Refs https://github.com/coder/internal/issues/1365
770 lines
23 KiB
Go
770 lines
23 KiB
Go
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 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")
|
|
})
|
|
}
|