Skip to content

Commit 09a7161

Browse files
committed
test(config): build api_key reference configs with encoding/json
Formatting a Windows path (C:\Users\...) into a JSON string produced invalid escapes on the Windows runner. The api_key reference tests now write mcp_config.json through json.Marshal, compare the stored reference by decoding the file, and a new test pins the backslash round trip. The symlink read test skips on Windows when the account cannot create one. Docs say what an absolute path is on Windows.
1 parent 0e70111 commit 09a7161

3 files changed

Lines changed: 57 additions & 10 deletions

File tree

‎docs/features/keyring-integration.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,10 @@ MCPProxy supports three types of secret references that can be used anywhere in
5454
(`docker inspect`, `/proc/<pid>/environ`). The same rules apply wherever it is used:
5555

5656
- The path must be absolute. A symlink is followed (Kubernetes secret volumes are symlinks) but must
57-
point at a regular file.
57+
point at a regular file. On Windows an absolute path has a drive letter (`C:\ProgramData\mcpproxy\key`
58+
or `C:/ProgramData/mcpproxy/key`); a path that starts with `/` and has no drive is not absolute. In
59+
`mcp_config.json` a backslash must be escaped (`"${file:C:\\ProgramData\\mcpproxy\\key}"`), so forward
60+
slashes are easier.
5861
- At most 64 KiB is read. A larger file is an error, never a truncated secret.
5962
- One trailing newline is removed, LF or CRLF, so a file written on Windows resolves to the same value
6063
as one written on Linux. Leading and inner whitespace are kept.

‎internal/config/api_key_env_test.go‎

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package config
22

33
import (
44
"context"
5+
"encoding/json"
56
"os"
67
"path/filepath"
78
"testing"
@@ -19,6 +20,21 @@ func writeKeyFile(t *testing.T, content string) string {
1920
return path
2021
}
2122

23+
// writeAPIKeyConfig writes a minimal mcp_config.json whose api_key is apiKey.
24+
// The JSON is built with encoding/json, never by formatting apiKey into a
25+
// string: a reference such as ${file:C:\Users\x\key} carries backslashes that
26+
// a Windows path puts into the document and that must be escaped.
27+
func writeAPIKeyConfig(t *testing.T, apiKey string) string {
28+
t.Helper()
29+
raw, err := json.Marshal(map[string]any{
30+
"listen": "127.0.0.1:8080",
31+
"api_key": apiKey,
32+
"mcpServers": []any{},
33+
})
34+
require.NoError(t, err)
35+
return writeOverrideTestFile(t, string(raw))
36+
}
37+
2238
// unsetEnv removes name for the test and restores it afterwards (t.Setenv
2339
// records the original value; the Unsetenv that follows leaves it unset).
2440
func unsetEnv(t *testing.T, name string) {
@@ -275,7 +291,7 @@ func TestLoad_APIKeyReferenceResolvesAsProcessOverride(t *testing.T) {
275291
ResetProcessOverrides()
276292
keyFile := writeKeyFile(t, "ref-secret-key\r\n")
277293
ref := "${file:" + keyFile + "}"
278-
path := writeOverrideTestFile(t, `{"listen": "127.0.0.1:8080", "api_key": "`+ref+`", "mcpServers": []}`)
294+
path := writeAPIKeyConfig(t, ref)
279295
unsetEnv(t, EnvAPIKey)
280296
t.Setenv(EnvAPIKeyFile, "")
281297

@@ -307,7 +323,7 @@ func TestPersistableConfig_APIKeyReferenceSurvivesAnUnrelatedEdit(t *testing.T)
307323
ResetProcessOverrides()
308324
keyFile := writeKeyFile(t, "ref-secret-key\n")
309325
ref := "${file:" + keyFile + "}"
310-
path := writeOverrideTestFile(t, `{"listen": "127.0.0.1:8080", "api_key": "`+ref+`", "mcpServers": []}`)
326+
path := writeAPIKeyConfig(t, ref)
311327
unsetEnv(t, EnvAPIKey)
312328
t.Setenv(EnvAPIKeyFile, "")
313329

@@ -325,7 +341,7 @@ func TestPersistableConfig_EditingAPIKeyUnderAReferenceIsPersisted(t *testing.T)
325341
t.Cleanup(ResetProcessOverrides)
326342
ResetProcessOverrides()
327343
keyFile := writeKeyFile(t, "ref-secret-key\n")
328-
path := writeOverrideTestFile(t, `{"listen": "127.0.0.1:8080", "api_key": "${file:`+keyFile+`}", "mcpServers": []}`)
344+
path := writeAPIKeyConfig(t, "${file:"+keyFile+"}")
329345
unsetEnv(t, EnvAPIKey)
330346
t.Setenv(EnvAPIKeyFile, "")
331347

@@ -348,16 +364,15 @@ func TestLoad_APIKeyReferenceThatCannotBeResolvedFailsTheLoad(t *testing.T) {
348364
t.Run(name, func(t *testing.T) {
349365
t.Cleanup(ResetProcessOverrides)
350366
ResetProcessOverrides()
351-
path := writeOverrideTestFile(t, `{"listen": "127.0.0.1:8080", "api_key": "`+ref+`", "mcpServers": []}`)
367+
path := writeAPIKeyConfig(t, ref)
352368
unsetEnv(t, EnvAPIKey)
353369
t.Setenv(EnvAPIKeyFile, "")
354370

355371
_, err := LoadFromFile(path)
356372
require.Error(t, err, "no key may be generated over a reference that does not resolve")
357373
assert.Contains(t, err.Error(), "api_key")
358-
raw, readErr := os.ReadFile(path)
359-
require.NoError(t, readErr)
360-
assert.Contains(t, string(raw), ref, "the file is untouched")
374+
assert.NotContains(t, err.Error(), ref, "the error never quotes the reference")
375+
assert.Equal(t, ref, readJSON(t, path)["api_key"], "the file is untouched")
361376
})
362377
}
363378
}
@@ -368,7 +383,7 @@ func TestLoad_EnvironmentKeyWinsOverAnAPIKeyReferenceWithoutReadingIt(t *testing
368383
// The referenced file does not exist: with an env key the reference is not
369384
// resolved at all, so that is not an error.
370385
ref := "${file:" + filepath.Join(t.TempDir(), "missing") + "}"
371-
path := writeOverrideTestFile(t, `{"listen": "127.0.0.1:8080", "api_key": "`+ref+`", "mcpServers": []}`)
386+
path := writeAPIKeyConfig(t, ref)
372387
t.Setenv(EnvAPIKey, "env-key")
373388
t.Setenv(EnvAPIKeyFile, "")
374389

@@ -405,3 +420,27 @@ func TestServerConfig_FileReferencesExpandOnACopy(t *testing.T) {
405420
assert.Contains(t, orig.Env["GH_TOKEN"], "${file:", "the stored config keeps the reference")
406421
assert.Contains(t, orig.Headers["Authorization"], "${file:")
407422
}
423+
424+
// Both variables set stays an error when api_key is a reference: the reference
425+
// is not read, and the error is the same fixed text.
426+
func TestLoad_BothSetFailsTheLoadEvenWithAnAPIKeyReference(t *testing.T) {
427+
t.Cleanup(ResetProcessOverrides)
428+
ResetProcessOverrides()
429+
ref := "${file:" + writeKeyFile(t, "ref-secret-key\n") + "}"
430+
path := writeAPIKeyConfig(t, ref)
431+
t.Setenv(EnvAPIKey, "env-secret-value-1234")
432+
t.Setenv(EnvAPIKeyFile, writeKeyFile(t, "file-secret-value-5678\n"))
433+
434+
_, err := LoadFromFile(path)
435+
require.ErrorIs(t, err, ErrAPIKeyBothSet)
436+
assert.NotContains(t, err.Error(), ref)
437+
assert.NotContains(t, err.Error(), "ref-secret-key")
438+
}
439+
440+
// The helper must produce valid JSON for a Windows-style path, which is what
441+
// the tests above feed it when they run on Windows.
442+
func TestWriteAPIKeyConfig_EscapesBackslashes(t *testing.T) {
443+
ref := `${file:C:\Users\ci\AppData\Local\Temp\key}`
444+
path := writeAPIKeyConfig(t, ref)
445+
assert.Equal(t, ref, readJSON(t, path)["api_key"])
446+
}

‎internal/secret/file_read_test.go‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,12 @@ func TestReadFile_FollowsSymlinkToRegularFile(t *testing.T) {
103103
// Kubernetes secret volumes expose each key as a symlink into ..data/.
104104
target := writeSecretFile(t, "from-target\n")
105105
link := filepath.Join(t.TempDir(), "link")
106-
require.NoError(t, os.Symlink(target, link))
106+
if err := os.Symlink(target, link); err != nil {
107+
if runtime.GOOS == "windows" {
108+
t.Skipf("creating a symlink needs a privilege this Windows account lacks: %v", err)
109+
}
110+
require.NoError(t, err)
111+
}
107112

108113
got, err := ReadFile(link)
109114
require.NoError(t, err)

0 commit comments

Comments
 (0)