cli/configssh_internal_test.go
Rama main · réplica de lectura del repositorio original.
package cli
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/codersdk"
)
func Test_sshConfigSplitOnCoderSection(t *testing.T) {
t.Parallel()
testCases := []struct {
Name string
Input string
Before string
Section string
After string
Err bool
}{
{
Name: "Empty",
Input: "",
Before: "",
Section: "",
After: "",
Err: false,
},
{
Name: "JustSection",
Input: strings.Join([]string{sshStartToken, sshEndToken}, "\n"),
Before: "",
Section: strings.Join([]string{sshStartToken, sshEndToken}, "\n"),
After: "",
Err: false,
},
{
Name: "NoSection",
Input: strings.Join([]string{"# Some content"}, "\n"),
Before: "# Some content",
Section: "",
After: "",
Err: false,
},
{
Name: "Normal",
Input: strings.Join([]string{
"# Content before the section",
sshStartToken,
sshEndToken,
"# Content after the section",
}, "\n"),
Before: "# Content before the section",
Section: strings.Join([]string{"", sshStartToken, sshEndToken, ""}, "\n"),
After: "# Content after the section",
Err: false,
},
{
Name: "OutOfOrder",
Input: strings.Join([]string{
"# Content before the section",
sshEndToken,
sshStartToken,
"# Content after the section",
}, "\n"),
Err: true,
},
{
Name: "MissingStart",
Input: strings.Join([]string{
"# Content before the section",
sshEndToken,
"# Content after the section",
}, "\n"),
Err: true,
},
{
Name: "MissingEnd",
Input: strings.Join([]string{
"# Content before the section",
sshEndToken,
"# Content after the section",
}, "\n"),
Err: true,
},
{
Name: "ExtraStart",
Input: strings.Join([]string{
"# Content before the section",
sshStartToken,
sshEndToken,
sshStartToken,
"# Content after the section",
}, "\n"),
Err: true,
},
{
Name: "ExtraEnd",
Input: strings.Join([]string{
"# Content before the section",
sshStartToken,
sshEndToken,
sshEndToken,
"# Content after the section",
}, "\n"),
Err: true,
},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
before, section, after, err := sshConfigSplitOnCoderSection([]byte(tc.Input))
if tc.Err {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tc.Before, string(before), "before")
require.Equal(t, tc.Section, string(section), "section")
require.Equal(t, tc.After, string(after), "after")
})
}
}
// This test tries to mimic the behavior of OpenSSH when executing e.g. a ProxyCommand.
// nolint:paralleltest
func Test_sshConfigProxyCommandEscape(t *testing.T) {
// Don't run this test, or any of its subtests in parallel. The test works by writing a file and then immediately
// executing it. Other tests might also exec a subprocess, and if they do in parallel, there is a small race
// condition where our file is open when they fork, and remains open while we attempt to execute it, causing
// a "text file busy" error.
tests := []struct {
name string
path string
wantErr bool
}{
{"windows path", `C:\Program Files\Coder\bin\coder.exe`, false},
{"no spaces", "simple", false},
{"spaces", "path with spaces", false},
{"quotes", "path with \"quotes\"", false},
{"backslashes", "path with \\backslashes", false},
{"tabs", "path with \ttabs", false},
{"newline fails", "path with \nnewline", true},
}
// nolint:paralleltest // Fixes a flake
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows doesn't typically execute via /bin/sh or cmd.exe, so this test is not applicable.")
}
dir := filepath.Join(t.TempDir(), tt.path)
err := os.MkdirAll(dir, 0o755)
require.NoError(t, err)
bin := filepath.Join(dir, "coder")
contents := []byte("#!/bin/sh\necho yay\n")
err = os.WriteFile(bin, contents, 0o755) //nolint:gosec
require.NoError(t, err)
escaped, err := sshConfigProxyCommandEscape(bin, false)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
b, err := exec.Command("/bin/sh", "-c", escaped).CombinedOutput() //nolint:gosec
require.NoError(t, err)
got := strings.TrimSpace(string(b))
require.Equal(t, "yay", got)
})
}
}
// This test tries to mimic the behavior of OpenSSH
// when executing e.g. a match exec command.
// nolint:tparallel
func Test_sshConfigMatchExecEscape(t *testing.T) {
t.Parallel()
tests := []struct {
name string
path string
wantErrOther bool
wantErrWindows bool
}{
{"no spaces", "simple", false, false},
{"spaces", "path with spaces", false, false},
{"quotes", "path with \"quotes\"", true, true},
{"backslashes", "path with\\backslashes", false, false},
{"tabs", "path with \ttabs", false, true},
{"newline fails", "path with \nnewline", true, true},
}
// nolint:paralleltest // Fixes a flake
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := "/bin/sh"
arg := "-c"
contents := []byte("#!/bin/sh\necho yay\n")
if runtime.GOOS == "windows" {
cmd = "cmd.exe"
arg = "/c"
contents = []byte("@echo yay\n")
}
dir := filepath.Join(t.TempDir(), tt.path)
bin := filepath.Join(dir, "coder.bat") // Windows will treat it as batch, Linux doesn't care
escaped, err := sshConfigMatchExecEscape(bin)
if (runtime.GOOS == "windows" && tt.wantErrWindows) || (runtime.GOOS != "windows" && tt.wantErrOther) {
require.Error(t, err)
return
}
require.NoError(t, err)
err = os.MkdirAll(dir, 0o755)
require.NoError(t, err)
err = os.WriteFile(bin, contents, 0o755) //nolint:gosec
require.NoError(t, err)
// OpenSSH processes %% escape sequences into %
escaped = strings.ReplaceAll(escaped, "%%", "%")
b, err := exec.Command(cmd, arg, escaped).CombinedOutput() //nolint:gosec
require.NoError(t, err)
got := strings.TrimSpace(string(b))
require.Equal(t, "yay", got)
})
}
}
func Test_sshConfigExecEscapeSeparatorForce(t *testing.T) {
t.Parallel()
tests := []struct {
name string
path string
// Behavior is different on Windows
expWindowsPath string
expOtherPath string
forceUnix bool
wantErr bool
}{
{
name: "windows_keep_forward_slashes_with_spaces",
// Has a space, expect quotes
path: `C:\Program Files\Coder\bin\coder.exe`,
expWindowsPath: `"C:\Program Files\Coder\bin\coder.exe"`,
expOtherPath: `"C:\Program Files\Coder\bin\coder.exe"`,
forceUnix: false,
wantErr: false,
},
{
name: "windows_keep_forward_slashes",
path: `C:\ProgramFiles\Coder\bin\coder.exe`,
expWindowsPath: `C:\ProgramFiles\Coder\bin\coder.exe`,
expOtherPath: `C:\ProgramFiles\Coder\bin\coder.exe`,
forceUnix: false,
wantErr: false,
},
{
name: "windows_force_unix_with_spaces",
path: `C:\Program Files\Coder\bin\coder.exe`,
expWindowsPath: `"C:/Program Files/Coder/bin/coder.exe"`,
expOtherPath: `"C:\Program Files\Coder\bin\coder.exe"`,
forceUnix: true,
wantErr: false,
},
{
name: "windows_force_unix",
path: `C:\ProgramFiles\Coder\bin\coder.exe`,
expWindowsPath: `C:/ProgramFiles/Coder/bin/coder.exe`,
expOtherPath: `C:\ProgramFiles\Coder\bin\coder.exe`,
forceUnix: true,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
found, err := sshConfigProxyCommandEscape(tt.path, tt.forceUnix)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
if runtime.GOOS == "windows" {
require.Equal(t, tt.expWindowsPath, found, "(Windows) expected path")
} else {
// this is a noop on non-windows!
require.Equal(t, tt.expOtherPath, found, "(Non-Windows) expected path")
}
})
}
}
func Test_mergeSSHOptions_RejectsUnsafeServerConfig(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
coderd codersdk.SSHConfigResponse
wantErr string
}{
{
name: "HostnameSuffix",
coderd: codersdk.SSHConfigResponse{
HostnameSuffix: "coder\nHost *",
},
wantErr: "workspace hostname suffix",
},
{
name: "HostnamePrefix",
coderd: codersdk.SSHConfigResponse{
HostnamePrefix: "coder.\nHost *",
},
wantErr: "workspace hostname prefix",
},
{
name: "ProxyCommand",
coderd: codersdk.SSHConfigResponse{
SSHConfigOptions: map[string]string{"ProxyCommand": "ssh -W %h:%p bastion"},
},
wantErr: `ssh config option "ProxyCommand" is not allowed`,
},
{
name: "PermitLocalCommand",
coderd: codersdk.SSHConfigResponse{
SSHConfigOptions: map[string]string{"PermitLocalCommand": "yes"},
},
wantErr: `ssh config option "PermitLocalCommand" is not allowed`,
},
{
name: "KnownHostsCommand",
coderd: codersdk.SSHConfigResponse{
SSHConfigOptions: map[string]string{"KnownHostsCommand": "echo key"},
},
wantErr: `ssh config option "KnownHostsCommand" is not allowed`,
},
{
name: "PKCS11Provider",
coderd: codersdk.SSHConfigResponse{
SSHConfigOptions: map[string]string{"PKCS11Provider": "/tmp/evil.so"},
},
wantErr: `ssh config option "PKCS11Provider" is not allowed`,
},
{
name: "NewlineInValue",
coderd: codersdk.SSHConfigResponse{
SSHConfigOptions: map[string]string{"UserKnownHostsFile": "/tmp/known_hosts\nHost *"},
},
wantErr: `ssh config option "UserKnownHostsFile" must not contain carriage return, newline, or NUL characters`,
},
{
name: "SmartcardDevice",
coderd: codersdk.SSHConfigResponse{
SSHConfigOptions: map[string]string{"SmartcardDevice": "/path/to/lib"},
},
wantErr: `not allowed`,
},
{
name: "XAuthLocation",
coderd: codersdk.SSHConfigResponse{
SSHConfigOptions: map[string]string{"XAuthLocation": "/usr/bin/xauth"},
},
wantErr: `not allowed`,
},
{
name: "ProxyJump",
coderd: codersdk.SSHConfigResponse{
SSHConfigOptions: map[string]string{"ProxyJump": "bastion.example.com"},
},
wantErr: `conflicts with`,
},
{
name: "HostnameSuffixGlob",
coderd: codersdk.SSHConfigResponse{
HostnameSuffix: "*",
},
wantErr: `glob`,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := mergeSSHOptions(sshConfigOptions{}, tt.coderd, t.TempDir(), "/tmp/coder")
require.ErrorContains(t, err, tt.wantErr)
})
}
}
func Test_mergeSSHOptions_UserOptionsOverrideServerConfig(t *testing.T) {
t.Parallel()
user := sshConfigOptions{
userHostPrefix: "dev.",
hostnameSuffix: "local",
userHostPrefixExplicit: true,
hostnameSuffixExplicit: true,
}
got, err := mergeSSHOptions(user, codersdk.SSHConfigResponse{
HostnamePrefix: "coder.",
HostnameSuffix: "coder",
}, t.TempDir(), "/tmp/coder")
require.NoError(t, err)
require.Equal(t, "dev.", got.userHostPrefix)
require.Equal(t, "local", got.hostnameSuffix)
}
func Test_mergeSSHOptions_ExplicitEmptyNotOverridden(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
userHostPrefixSet bool
hostnameSuffixSet bool
wantUserHostPrefix string
wantHostnameSuffix string
}{
{
name: "PrefixExplicitlyEmpty",
userHostPrefixSet: true,
hostnameSuffixSet: false,
wantUserHostPrefix: "",
wantHostnameSuffix: "coder",
},
{
name: "SuffixExplicitlyEmpty",
userHostPrefixSet: false,
hostnameSuffixSet: true,
wantUserHostPrefix: "coder.",
wantHostnameSuffix: "",
},
{
name: "BothExplicitlyEmpty",
userHostPrefixSet: true,
hostnameSuffixSet: true,
wantUserHostPrefix: "",
wantHostnameSuffix: "",
},
{
name: "NeitherSet",
userHostPrefixSet: false,
hostnameSuffixSet: false,
wantUserHostPrefix: "coder.",
wantHostnameSuffix: "coder",
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
user := sshConfigOptions{
userHostPrefixExplicit: tt.userHostPrefixSet,
hostnameSuffixExplicit: tt.hostnameSuffixSet,
}
got, err := mergeSSHOptions(user, codersdk.SSHConfigResponse{
HostnamePrefix: "coder.",
HostnameSuffix: "coder",
}, t.TempDir(), "/tmp/coder")
require.NoError(t, err)
require.Equal(t, tt.wantUserHostPrefix, got.userHostPrefix)
require.Equal(t, tt.wantHostnameSuffix, got.hostnameSuffix)
})
}
}
func Test_mergeSSHOptions_AllowsSafeServerConfig(t *testing.T) {
t.Parallel()
got, err := mergeSSHOptions(sshConfigOptions{}, codersdk.SSHConfigResponse{
HostnamePrefix: "coder.",
HostnameSuffix: "coder",
SSHConfigOptions: map[string]string{
"HostName": "example.com",
"User": "coder",
"Port": "22",
"SetEnv": "FOO=bar BAZ=qux",
"UserKnownHostsFile": "/tmp/coder_known_hosts",
},
}, t.TempDir(), "/tmp/coder")
require.NoError(t, err)
require.Equal(t, "coder.", got.userHostPrefix)
require.Equal(t, "coder", got.hostnameSuffix)
require.Contains(t, got.sshOptions, "HostName example.com")
require.Contains(t, got.sshOptions, "SetEnv FOO=bar BAZ=qux")
}
func Test_sshConfigOptions_addOption(t *testing.T) {
t.Parallel()
testCases := []struct {
Name string
Start []string
Add []string
Expect []string
ExpectError bool
}{
{
Name: "Empty",
},
{
Name: "AddOne",
Add: []string{"foo bar"},
Expect: []string{
"foo bar",
},
},
{
Name: "AddTwo",
Start: []string{
"foo bar",
},
Add: []string{"Foo baz"},
Expect: []string{
"foo bar",
"Foo baz",
},
},
{
Name: "AddAndRemove",
Start: []string{
"foo bar",
"buzz bazz",
},
Add: []string{
"b c",
"a ", // Empty value, means remove all following entries that start with "a", i.e. next line.
"A hello",
"hello world",
},
Expect: []string{
"foo bar",
"buzz bazz",
"b c",
"hello world",
},
},
{
Name: "Error",
Add: []string{"novalue"},
ExpectError: true,
},
}
for _, tt := range testCases {
t.Run(tt.Name, func(t *testing.T) {
t.Parallel()
o := sshConfigOptions{
sshOptions: tt.Start,
}
err := o.addOptions(tt.Add...)
if tt.ExpectError {
require.Error(t, err)
return
}
require.NoError(t, err)
slices.Sort(tt.Expect)
slices.Sort(o.sshOptions)
require.Equal(t, tt.Expect, o.sshOptions)
})
}
}
func TestSSHConfigOptions_writeToBuffer(t *testing.T) {
t.Parallel()
tests := []struct {
name string
opts sshConfigOptions
want []string // substrings that must appear
notWant []string // substrings that must not appear
}{
{
name: "wildcard suffix",
opts: sshConfigOptions{
coderBinaryPath: "/usr/bin/coder",
globalConfigPath: "/tmp/coder",
hostnameSuffix: "coder",
waitEnum: "auto",
},
want: []string{"Host *.coder\n", "ProxyCommand", "--hostname-suffix coder %h"},
notWant: []string{"Host workspace"},
},
{
name: "wildcard prefix",
opts: sshConfigOptions{
coderBinaryPath: "/usr/bin/coder",
globalConfigPath: "/tmp/coder",
userHostPrefix: "coder.",
waitEnum: "auto",
},
want: []string{"Host coder.*\n", "ProxyCommand", "--ssh-host-prefix coder. %h"},
notWant: []string{"Host coder.workspace"},
},
{
name: "no-wildcard suffix with workspaces",
opts: sshConfigOptions{
coderBinaryPath: "/usr/bin/coder",
globalConfigPath: "/tmp/coder",
hostnameSuffix: "coder",
noWildcard: true,
hostNames: []string{"workspace1", "workspace2"},
waitEnum: "auto",
},
want: []string{
"Host workspace1.coder\n",
"Host workspace2.coder\n",
"Match host workspace1.coder !exec",
"Match host workspace2.coder !exec",
"--hostname-suffix coder %h",
},
notWant: []string{"Host *.coder", "Match host *.coder"},
},
{
name: "no-wildcard suffix with zero workspaces produces no host entries",
opts: sshConfigOptions{
coderBinaryPath: "/usr/bin/coder",
globalConfigPath: "/tmp/coder",
hostnameSuffix: "coder",
noWildcard: true,
hostNames: nil,
waitEnum: "auto",
},
notWant: []string{"Host", "ProxyCommand", "Match"},
},
{
name: "no-wildcard prefix with workspaces",
opts: sshConfigOptions{
coderBinaryPath: "/usr/bin/coder",
globalConfigPath: "/tmp/coder",
userHostPrefix: "coder.",
noWildcard: true,
hostNames: []string{"workspace1", "workspace2"},
waitEnum: "auto",
},
want: []string{
"Host coder.workspace1\n",
"Host coder.workspace2\n",
"--ssh-host-prefix coder. %h",
},
notWant: []string{"Host coder.*"},
},
{
name: "no-wildcard suffix skips proxy command when skipProxyCommand is set",
opts: sshConfigOptions{
coderBinaryPath: "/usr/bin/coder",
globalConfigPath: "/tmp/coder",
hostnameSuffix: "coder",
noWildcard: true,
hostNames: []string{"workspace1"},
skipProxyCommand: true,
waitEnum: "auto",
},
want: []string{"Host workspace1.coder\n"},
notWant: []string{"ProxyCommand", "Match host", "Host *.coder"},
},
{
name: "no-wildcard prefix skips proxy command when skipProxyCommand is set",
opts: sshConfigOptions{
coderBinaryPath: "/usr/bin/coder",
globalConfigPath: "/tmp/coder",
userHostPrefix: "coder.",
noWildcard: true,
hostNames: []string{"workspace1"},
skipProxyCommand: true,
waitEnum: "auto",
},
want: []string{"Host coder.workspace1\n"},
notWant: []string{"ProxyCommand", "Host coder.*"},
},
{
name: "no-wildcard suffix SSH options appear in every workspace entry",
opts: sshConfigOptions{
coderBinaryPath: "/usr/bin/coder",
globalConfigPath: "/tmp/coder",
hostnameSuffix: "coder",
noWildcard: true,
hostNames: []string{"workspace1", "workspace2"},
sshOptions: []string{"ForwardAgent=yes", "LogLevel=DEBUG"},
waitEnum: "auto",
},
want: []string{
"Host workspace1.coder\n",
"\tForwardAgent=yes\n",
"\tLogLevel=DEBUG\n",
"Host workspace2.coder\n",
},
},
{
name: "wildcard suffix SSH options appear in host block",
opts: sshConfigOptions{
coderBinaryPath: "/usr/bin/coder",
globalConfigPath: "/tmp/coder",
hostnameSuffix: "coder",
sshOptions: []string{"ForwardAgent=yes"},
waitEnum: "auto",
},
want: []string{
"Host *.coder\n",
"\tForwardAgent=yes\n",
},
},
{
name: "no-wildcard with both prefix and suffix generates entries for both",
opts: sshConfigOptions{
coderBinaryPath: "/usr/bin/coder",
globalConfigPath: "/tmp/coder",
userHostPrefix: "coder.",
hostnameSuffix: "testy",
noWildcard: true,
hostNames: []string{"workspace1"},
waitEnum: "auto",
},
want: []string{
"Host coder.workspace1\n",
"Host workspace1.testy\n",
"Match host workspace1.testy !exec",
},
notWant: []string{"Host coder.*", "Host *.testy"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
err := tt.opts.writeToBuffer(&buf)
require.NoError(t, err)
got := buf.String()
for _, w := range tt.want {
assert.Contains(t, got, w, "expected substring not found")
}
for _, nw := range tt.notWant {
assert.NotContains(t, got, nw, "unexpected substring found")
}
})
}
}
func Test_noWildcardHostNames(t *testing.T) {
t.Parallel()
resource := func(transition codersdk.WorkspaceTransition, agents ...string) codersdk.WorkspaceResource {
r := codersdk.WorkspaceResource{Transition: transition}
for _, name := range agents {
r.Agents = append(r.Agents, codersdk.WorkspaceAgent{Name: name})
}
return r
}
workspace := func(name string, workspaceID uuid.UUID, resources ...codersdk.WorkspaceResource) codersdk.Workspace {
return codersdk.Workspace{
ID: workspaceID,
Name: name,
OwnerName: "alice",
LatestBuild: codersdk.WorkspaceBuild{
TemplateVersionID: uuid.New(),
Resources: resources,
},
}
}
fetched, missing := uuid.New(), uuid.New()
workspaceResources := map[uuid.UUID][]codersdk.WorkspaceResource{
fetched: {
resource(codersdk.WorkspaceTransitionStart, "main"),
// Stop-transition resources must not contribute agent entries.
resource(codersdk.WorkspaceTransitionStop, "ignored"),
},
}
got := noWildcardHostNames([]codersdk.Workspace{
// Agents of a running workspace come from its latest build, sorted,
// skipping names that are unsafe to interpolate into SSH config.
workspace("running", fetched, resource(codersdk.WorkspaceTransitionStart, "web", "dev", "dev_box", "bad name", "bad\nname", "bad*", "bad.name", "bad;name", "-bad", "bad--name")),
// A stopped workspace uses its last successful start resources.
workspace("stopped", fetched, resource(codersdk.WorkspaceTransitionStop)),
// If previous start resources could not be fetched, only the workspace
// itself gets an entry.
workspace("unknown", missing),
// So does a workspace whose name is not a valid name, such as one
// created before underscores were rejected.
workspace("legacy_name", fetched, resource(codersdk.WorkspaceTransitionStart, "dev")),
}, workspaceResources)
require.Equal(t, []string{
"running",
"dev.running.alice",
"dev_box.running.alice",
"web.running.alice",
"stopped",
"main.stopped.alice",
"unknown",
"legacy_name",
}, got)
}
func Test_previousStartResources(t *testing.T) {
t.Parallel()
versionID := uuid.New()
resources := []codersdk.WorkspaceResource{{
Transition: codersdk.WorkspaceTransitionStart,
Agents: []codersdk.WorkspaceAgent{{Name: "main"}},
}}
start := codersdk.WorkspaceBuild{
BuildNumber: 1,
Transition: codersdk.WorkspaceTransitionStart,
TemplateVersionID: versionID,
Job: codersdk.ProvisionerJob{Status: codersdk.ProvisionerJobSucceeded},
Resources: resources,
}
future := start
future.BuildNumber = 32
future.Resources = []codersdk.WorkspaceResource{{Agents: []codersdk.WorkspaceAgent{{Name: "future"}}}}
failed := start
failed.BuildNumber = 30
failed.Resources = []codersdk.WorkspaceResource{{Agents: []codersdk.WorkspaceAgent{{Name: "failed"}}}}
failed.Job.Status = codersdk.ProvisionerJobFailed
paged := []codersdk.WorkspaceBuild{future, failed}
for number := int32(29); number >= 2; number-- {
paged = append(paged, codersdk.WorkspaceBuild{
BuildNumber: number,
Transition: codersdk.WorkspaceTransitionStop,
Job: codersdk.ProvisionerJob{Status: codersdk.ProvisionerJobSucceeded},
})
}
paged = append(paged, start)
changedVersion := start
changedVersion.BuildNumber = 2
changedVersion.TemplateVersionID = uuid.New()
for _, tc := range []struct {
name string
builds []codersdk.WorkspaceBuild
fail bool
want []codersdk.WorkspaceResource
}{
{name: "PaginatedHistory", builds: paged, want: resources},
{name: "ChangedTemplateVersion", builds: []codersdk.WorkspaceBuild{changedVersion, start}},
{name: "NoSuccessfulStart", builds: []codersdk.WorkspaceBuild{failed}},
{name: "FetchError", fail: true},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ws := codersdk.Workspace{
ID: uuid.New(),
LatestBuild: codersdk.WorkspaceBuild{
BuildNumber: 31,
TemplateVersionID: versionID,
},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v2/workspaces/"+ws.ID.String()+"/builds", r.URL.Path)
if tc.fail {
w.WriteHeader(http.StatusInternalServerError)
assert.NoError(t, json.NewEncoder(w).Encode(codersdk.Response{Message: "history unavailable"}))
return
}
offset := 0
if value := r.URL.Query().Get("offset"); value != "" {
var err error
offset, err = strconv.Atoi(value)
assert.NoError(t, err)
}
limit, err := strconv.Atoi(r.URL.Query().Get("limit"))
assert.NoError(t, err)
assert.Positive(t, limit)
assert.NoError(t, json.NewEncoder(w).Encode(tc.builds[offset:min(offset+limit, len(tc.builds))]))
}))
t.Cleanup(srv.Close)
u, err := url.Parse(srv.URL)
require.NoError(t, err)
got, err := previousStartResources(t.Context(), codersdk.New(u), ws)
if tc.fail {
require.ErrorContains(t, err, "history unavailable")
} else {
require.NoError(t, err)
}
require.Equal(t, tc.want, got)
})
}
}