Files
coder/cli/configssh_internal_test.go
T
Mathias Fredriksson ed4311b2cb ci: add Git usr/bin to PATH on Windows (#25939)
## Summary

Fixes all 9 Windows CI test failures caused by the mise CI refactor
(`fe257666d7`, PR #25727).

### Root cause

`jdx/mise-action` exports `Path` (Windows convention) via `GITHUB_ENV`.
Bash on Windows maintains its own `PATH`. When Go's `os.Environ()`
returns both, `cmd.exe` subprocesses non-deterministically pick the
MSYS-translated `PATH` (forward slashes), causing Windows executables
(`printf`, `powershell.exe`, `cmd.exe`) to be unresolvable.

These failures only appeared on `main` (where `-count=1` forces real
test execution) and were masked on PRs by Go test cache.

### Fixes applied

**CI (`setup-mise` action)**:
- Write both `Path` and `PATH` to `GITHUB_ENV` with Git usr/bin
prepended

**Code (`cli/root.go`)**:
- Add `appendAndDedupEnv` helper that deduplicates case-insensitive env
vars on Windows, preferring native Windows paths (backslashes) over MSYS
paths

**Code (`cli/configssh_windows.go`)**:
- Use absolute paths for `powershell.exe` and `cmd.exe` in the SSH
config `Match exec` escape function, avoiding PATH resolution entirely

**Tests**:
- Switch `--header-command` tests from `printf` to `echo` (cmd.exe
builtin) for reliable cross-platform execution
- Add env dedup in `Test_sshConfigMatchExecEscape` for subprocess PATH
consistency

Fixes coder/internal#1556, coder/internal#1558, coder/internal#1559

> 🤖 Generated by Coder agent, will be reviewed by @mafredri. 🏂🏻
2026-06-02 11:51:16 +10:00

387 lines
9.5 KiB
Go

package cli
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
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, "%%", "%")
c := exec.Command(cmd, arg, escaped) //nolint:gosec
if runtime.GOOS == "windows" {
// Deduplicate Path/PATH env vars so cmd.exe
// subprocesses (like powershell.exe used for
// paths with spaces) resolve correctly.
c.Env = appendAndDedupEnv(os.Environ())
}
b, err := c.CombinedOutput()
require.NoError(t, err, "command output: %s", string(b))
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_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)
})
}
}