1package config
2
3import (
4 "fmt"
5 "maps"
6 "os"
7 "path/filepath"
8 "slices"
9 "testing"
10
11 "github.com/charmbracelet/crush/internal/env"
12 "github.com/stretchr/testify/require"
13)
14
15// These tests exercise the full shell-expansion path (no mocks,
16// no injected Expander) to catch regressions that only surface when
17// internal/shell actually runs the value. Table-level unit tests with
18// fake expanders live in resolve_test.go.
19
20// realShellResolver builds a resolver backed by a shell env that
21// contains PATH + the caller-supplied overrides. Production callers
22// get PATH for free via env.New(); these tests need it so $(cat ...)
23// and similar inner commands can resolve.
24func realShellResolver(vars map[string]string) VariableResolver {
25 m := map[string]string{"PATH": os.Getenv("PATH")}
26 maps.Copy(m, vars)
27 return NewShellVariableResolver(env.NewFromMap(m))
28}
29
30func writeTempFile(t *testing.T, content string) string {
31 t.Helper()
32 dir := t.TempDir()
33 p := filepath.Join(dir, "secret")
34 require.NoError(t, os.WriteFile(p, []byte(content), 0o600))
35 return p
36}
37
38// TestResolvedEnv_RealShell_Success covers the shell constructs the
39// PLAN calls out: $(cat tempfile) with and without trailing newline,
40// ${VAR:-default} for unset vars, literal-space preservation around
41// $(...), nested parens, quoted args inside $(echo ...), and a
42// glob-like literal round-tripping unchanged.
43func TestResolvedEnv_RealShell_Success(t *testing.T) {
44 t.Parallel()
45
46 withNL := writeTempFile(t, "token-with-nl\n")
47 noNL := writeTempFile(t, "token-no-nl")
48
49 m := MCPConfig{
50 Env: map[string]string{
51 // POSIX strips exactly one trailing newline from $(...)
52 // output, so both forms land on the same value.
53 "TOK_NL": fmt.Sprintf("$(cat %s)", withNL),
54 "TOK_NO": fmt.Sprintf("$(cat %s)", noNL),
55
56 // ${VAR:-default} must not error on unset: this is the
57 // opt-in escape hatch for "empty is fine".
58 "FALLBACK": "${MCP_MISSING:-fallback}",
59
60 // Leading/trailing literal spaces around $(...) must be
61 // preserved — single-value contract, no field splitting.
62 "PADDED": " $(echo v) ",
63
64 // ")" inside a quoted arg to echo is a regression guard
65 // for the old hand-rolled paren matcher.
66 "PAREN": `$(echo ")")`,
67
68 // Embedded space inside a quoted arg must survive
69 // verbatim; no word-splitting side effect.
70 "SPACEY": `$(echo "a b")`,
71
72 // Glob-like literals must not expand.
73 "GLOB": "*.go",
74 },
75 }
76
77 got, err := m.ResolvedEnv(realShellResolver(nil))
78 require.NoError(t, err)
79
80 // ResolvedEnv returns "KEY=value" sorted by key.
81 want := []string{
82 "FALLBACK=fallback",
83 "GLOB=*.go",
84 "PADDED= v ",
85 "PAREN=)",
86 "SPACEY=a b",
87 "TOK_NL=token-with-nl",
88 "TOK_NO=token-no-nl",
89 }
90 require.Equal(t, want, got)
91}
92
93// TestResolvedEnv_RealShell_DoesNotMutate pins that both success and
94// failure paths leave m.Env untouched. Prior behaviour rewrote the
95// value in place on error; that was the exact mechanism that shipped
96// empty credentials to MCP servers.
97func TestResolvedEnv_RealShell_DoesNotMutate(t *testing.T) {
98 t.Parallel()
99
100 t.Run("success path leaves Env untouched", func(t *testing.T) {
101 t.Parallel()
102 m := MCPConfig{Env: map[string]string{"TOKEN": "$(echo shh)"}}
103 orig := maps.Clone(m.Env)
104
105 _, err := m.ResolvedEnv(realShellResolver(nil))
106 require.NoError(t, err)
107 require.Equal(t, orig, m.Env)
108 })
109
110 t.Run("failure path leaves Env untouched", func(t *testing.T) {
111 t.Parallel()
112 m := MCPConfig{Env: map[string]string{"BROKEN": "$(false)"}}
113 orig := maps.Clone(m.Env)
114
115 _, err := m.ResolvedEnv(realShellResolver(nil))
116 require.Error(t, err)
117 require.Equal(t, orig, m.Env, "map must be preserved on error")
118 })
119}
120
121// TestResolvedEnv_RealShell_Idempotent pins the pure-function contract:
122// two calls on the same config return deeply-equal slices.
123func TestResolvedEnv_RealShell_Idempotent(t *testing.T) {
124 t.Parallel()
125
126 m := MCPConfig{
127 Env: map[string]string{
128 "A": "$(echo one)",
129 "B": "$(echo two)",
130 "C": "literal",
131 },
132 }
133 r := realShellResolver(nil)
134
135 first, err := m.ResolvedEnv(r)
136 require.NoError(t, err)
137 second, err := m.ResolvedEnv(r)
138 require.NoError(t, err)
139 require.Equal(t, first, second)
140}
141
142// TestResolvedEnv_RealShell_Deterministic guards against Go's
143// randomized map iteration leaking into the returned slice order.
144func TestResolvedEnv_RealShell_Deterministic(t *testing.T) {
145 t.Parallel()
146
147 m := MCPConfig{Env: map[string]string{
148 "Z": "z",
149 "A": "a",
150 "M": "m",
151 }}
152
153 got, err := m.ResolvedEnv(realShellResolver(nil))
154 require.NoError(t, err)
155 require.True(t, slices.IsSorted(got), "env slice must be sorted; got %v", got)
156}
157
158// TestResolvedEnv_RealShell_NounsetRegression is the single most
159// important assertion in this file: an unset variable is an error, not
160// an empty expansion. Swapping the hand-rolled parser for mvdan's
161// default-expansion behaviour without nounset would re-introduce
162// Defect 1 via a typo'd variable name.
163func TestResolvedEnv_RealShell_NounsetRegression(t *testing.T) {
164 t.Parallel()
165
166 m := MCPConfig{Env: map[string]string{
167 // Intentional typo: user meant $FORGEJO_TOKEN.
168 "FORGEJO_ACCESS_TOKEN": "$FORGJO_TOKEN",
169 }}
170 got, err := m.ResolvedEnv(realShellResolver(nil))
171 require.Error(t, err, "unset var must not silently expand to empty")
172 require.Nil(t, got)
173 require.Contains(t, err.Error(), "FORGEJO_ACCESS_TOKEN")
174 require.Contains(t, err.Error(), "$FORGJO_TOKEN")
175}
176
177// TestResolvedEnv_RealShell_FailureDetail pins that a failing inner
178// command surfaces enough detail (exit code + stderr) to diagnose
179// without forcing the user to re-run the command by hand. Also
180// verifies the template is included so they know which Env entry
181// blew up.
182func TestResolvedEnv_RealShell_FailureDetail(t *testing.T) {
183 t.Parallel()
184
185 missing := filepath.Join(t.TempDir(), "definitely-not-here")
186 m := MCPConfig{Env: map[string]string{
187 "FORGEJO_ACCESS_TOKEN": fmt.Sprintf("$(cat %s)", missing),
188 }}
189
190 _, err := m.ResolvedEnv(realShellResolver(nil))
191 require.Error(t, err)
192 msg := err.Error()
193 require.Contains(t, msg, "FORGEJO_ACCESS_TOKEN", "must identify the failing env var")
194 require.Contains(t, msg, missing, "must include the template so users see what failed")
195 require.Contains(t, msg, "exit status", "must surface the inner exit code")
196}
197
198// TestResolvedHeaders_RealShell_FailurePreservesOriginal pins two
199// invariants simultaneously: on failure the returned map is nil (not
200// a partially-populated map) and the receiver's Headers map is
201// unchanged. A test that only asserted on the returned value could
202// hide an in-place mutation regression.
203func TestResolvedHeaders_RealShell_FailurePreservesOriginal(t *testing.T) {
204 t.Parallel()
205
206 m := MCPConfig{Headers: map[string]string{
207 "Authorization": "Bearer $(false)",
208 "X-Static": "kept",
209 }}
210 orig := maps.Clone(m.Headers)
211
212 got, err := m.ResolvedHeaders(realShellResolver(nil))
213 require.Error(t, err)
214 require.Nil(t, got, "headers map must be nil on failure")
215 require.Contains(t, err.Error(), "header Authorization")
216 require.Equal(t, orig, m.Headers, "receiver Headers must be preserved")
217}
218
219// TestResolvedArgs_RealShell exercises both success and failure for
220// m.Args symmetrically with Env. Args are ordered so error messages
221// must identify a positional index, not a key.
222func TestResolvedArgs_RealShell(t *testing.T) {
223 t.Parallel()
224
225 t.Run("success expands each element", func(t *testing.T) {
226 t.Parallel()
227 m := MCPConfig{Args: []string{"--token", "$(echo shh)", "--host", "example.com"}}
228 got, err := m.ResolvedArgs(realShellResolver(nil))
229 require.NoError(t, err)
230 require.Equal(t, []string{"--token", "shh", "--host", "example.com"}, got)
231 })
232
233 t.Run("failure identifies offending index", func(t *testing.T) {
234 t.Parallel()
235 m := MCPConfig{Args: []string{"--token", "$(false)"}}
236 orig := slices.Clone(m.Args)
237
238 got, err := m.ResolvedArgs(realShellResolver(nil))
239 require.Error(t, err)
240 require.Nil(t, got)
241 require.Contains(t, err.Error(), "arg 1")
242 require.Equal(t, orig, m.Args, "receiver Args must be preserved")
243 })
244
245 t.Run("nil args returns nil, no error", func(t *testing.T) {
246 t.Parallel()
247 m := MCPConfig{}
248 got, err := m.ResolvedArgs(realShellResolver(nil))
249 require.NoError(t, err)
250 require.Nil(t, got)
251 })
252}
253
254// TestMCPConfig_IdentityResolver pins the client-mode contract: every
255// Resolved* method round-trips the template verbatim and never errors
256// on unset variables. Local expansion would double-expand when the
257// server does its own — this has to stay a pure pass-through.
258func TestMCPConfig_IdentityResolver(t *testing.T) {
259 t.Parallel()
260
261 m := MCPConfig{
262 Command: "$CMD",
263 Args: []string{"--token", "$MCP_MISSING_TOKEN", "$(vault read -f secret)"},
264 Env: map[string]string{
265 "TOKEN": "$(cat /run/secrets/x)",
266 "HOST": "$MCP_MISSING_HOST",
267 },
268 Headers: map[string]string{
269 "Authorization": "Bearer $(vault read -f token)",
270 },
271 URL: "https://$MCP_HOST/$(vault read -f path)",
272 }
273 r := IdentityResolver()
274
275 args, err := m.ResolvedArgs(r)
276 require.NoError(t, err)
277 require.Equal(t, m.Args, args)
278
279 envs, err := m.ResolvedEnv(r)
280 require.NoError(t, err)
281 // Sorted "KEY=value".
282 require.Equal(t, []string{
283 "HOST=$MCP_MISSING_HOST",
284 "TOKEN=$(cat /run/secrets/x)",
285 }, envs)
286
287 headers, err := m.ResolvedHeaders(r)
288 require.NoError(t, err)
289 require.Equal(t, m.Headers, headers)
290
291 u, err := m.ResolvedURL(r)
292 require.NoError(t, err)
293 require.Equal(t, m.URL, u)
294}