resolve_real_test.go

  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}