1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
4
5package appconfig
6
7import (
8 "context"
9 "os"
10 "path/filepath"
11 "strings"
12 "testing"
13
14 "git.secluded.site/cooked-mcp/internal/configvalue"
15)
16
17func TestLoadACIDAuthenticationCredentials21ResolvesTOMLConfigValues(t *testing.T) {
18 t.Setenv("COOKED_USERNAME_FROM_MY_SHELL", "shell-user")
19
20 configPath := filepath.Join(t.TempDir(), "config.toml")
21 writeConfig(t, configPath, `[user]
22name = "$COOKED_USERNAME_FROM_MY_SHELL"
23password = "!printf shell-password"
24`)
25
26 got, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
27 if err != nil {
28 t.Fatalf("Load() error = %v", err)
29 }
30
31 if got.Username != "shell-user" {
32 t.Fatalf("Username = %q, want shell-user", got.Username)
33 }
34 if got.Password != "shell-password" {
35 t.Fatalf("Password = %q, want shell-password", got.Password)
36 }
37}
38
39func TestLoadACIDAuthenticationCredentials11EnvironmentOverridesTOML(t *testing.T) {
40 t.Setenv("COOKED_LOGIN_USERNAME", "override-user")
41 t.Setenv("COOKED_LOGIN_PASSWORD", "override-password")
42
43 configPath := filepath.Join(t.TempDir(), "config.toml")
44 writeConfig(t, configPath, `[user]
45name = "toml-user"
46password = "toml-password"
47`)
48
49 got, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
50 if err != nil {
51 t.Fatalf("Load() error = %v", err)
52 }
53
54 if got.Username != "override-user" {
55 t.Fatalf("Username = %q, want override-user", got.Username)
56 }
57 if got.Password != "override-password" {
58 t.Fatalf("Password = %q, want override-password", got.Password)
59 }
60}
61
62func TestLoadDefaultsHTTPAddr(t *testing.T) {
63 configPath := filepath.Join(t.TempDir(), "config.toml")
64 writeConfig(t, configPath, `[user]
65name = "toml-user"
66password = "toml-password"
67`)
68
69 got, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
70 if err != nil {
71 t.Fatalf("Load() error = %v", err)
72 }
73
74 if got.HTTPAddr != "127.0.0.1:8123" {
75 t.Fatalf("HTTPAddr = %q, want default loopback address", got.HTTPAddr)
76 }
77}
78
79// server.CONFIG.8-2
80func TestLoadACIDServerConfig8_2LoadsTOMLHTTPAddr(t *testing.T) {
81 configPath := filepath.Join(t.TempDir(), "config.toml")
82 writeConfig(t, configPath, `[user]
83name = "toml-user"
84password = "toml-password"
85
86[mcp]
87http_addr = "127.0.0.1:9000"
88`)
89
90 got, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
91 if err != nil {
92 t.Fatalf("Load() error = %v", err)
93 }
94
95 if got.HTTPAddr != "127.0.0.1:9000" {
96 t.Fatalf("HTTPAddr = %q, want TOML address", got.HTTPAddr)
97 }
98}
99
100// server.CONFIG.3 server.CONFIG.8-1
101func TestLoadACIDServerConfig3And8_1EnvironmentOverridesTOMLHTTPAddr(t *testing.T) {
102 t.Setenv("COOKED_MCP_HTTP_ADDR", "127.0.0.1:9001")
103
104 configPath := filepath.Join(t.TempDir(), "config.toml")
105 writeConfig(t, configPath, `[user]
106name = "toml-user"
107password = "toml-password"
108
109[mcp]
110http_addr = "127.0.0.1:9000"
111`)
112
113 got, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
114 if err != nil {
115 t.Fatalf("Load() error = %v", err)
116 }
117
118 if got.HTTPAddr != "127.0.0.1:9001" {
119 t.Fatalf("HTTPAddr = %q, want environment address", got.HTTPAddr)
120 }
121}
122
123// server.CONFIG.8
124func TestLoadACIDServerConfig8HTTPAddrOverridesEnvironmentAndTOML(t *testing.T) {
125 t.Setenv("COOKED_MCP_HTTP_ADDR", "127.0.0.1:9001")
126
127 configPath := filepath.Join(t.TempDir(), "config.toml")
128 writeConfig(t, configPath, `[user]
129name = "toml-user"
130password = "toml-password"
131
132[mcp]
133http_addr = "127.0.0.1:9000"
134`)
135
136 got, err := Load(
137 context.Background(),
138 configPath,
139 configvalue.NewResolver(),
140 LoadOptions{HTTPAddr: "127.0.0.1:9002"},
141 )
142 if err != nil {
143 t.Fatalf("Load() error = %v", err)
144 }
145
146 if got.HTTPAddr != "127.0.0.1:9002" {
147 t.Fatalf("HTTPAddr = %q, want explicit address", got.HTTPAddr)
148 }
149}
150
151// server.SECURITY.4 server.SECURITY.9
152func TestLoadACIDServerSecurity4And9EnvironmentOverridesTOMLHTTPToken(t *testing.T) {
153 t.Setenv("COOKED_MCP_HTTP_TOKEN", "env-token")
154
155 configPath := filepath.Join(t.TempDir(), "config.toml")
156 writeConfig(t, configPath, `[user]
157name = "toml-user"
158password = "toml-password"
159
160[mcp]
161http_token = "toml-token"
162`)
163
164 got, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
165 if err != nil {
166 t.Fatalf("Load() error = %v", err)
167 }
168
169 if got.HTTPToken != "env-token" {
170 t.Fatalf("HTTPToken = %q, want environment token", got.HTTPToken)
171 }
172}
173
174// server.SECURITY.5 server.SECURITY.10 server.SECURITY.12 server.SECURITY.13
175func TestLoadACIDServerSecurity5And10ResolvesTOMLHTTPTokenConfigValue(t *testing.T) {
176 t.Setenv("TEST_HTTP_TOKEN", "shell-token")
177
178 configPath := filepath.Join(t.TempDir(), "config.toml")
179 writeConfig(t, configPath, `[user]
180name = "toml-user"
181password = "toml-password"
182
183[mcp]
184http_token = "$TEST_HTTP_TOKEN"
185`)
186
187 got, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
188 if err != nil {
189 t.Fatalf("Load() error = %v", err)
190 }
191
192 if got.HTTPToken != "shell-token" {
193 t.Fatalf("HTTPToken = %q, want resolved TOML token", got.HTTPToken)
194 }
195}
196
197// server.SECURITY.11
198func TestLoadACIDServerSecurity11RejectsEmptyEnvironmentHTTPToken(t *testing.T) {
199 t.Setenv("COOKED_MCP_HTTP_TOKEN", "")
200
201 configPath := filepath.Join(t.TempDir(), "config.toml")
202 writeConfig(t, configPath, `[user]
203name = "toml-user"
204password = "toml-password"
205`)
206
207 _, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
208 if err == nil {
209 t.Fatal("Load() error = nil, want empty HTTP token error")
210 }
211 if !strings.Contains(err.Error(), "MCP HTTP token must not be empty") {
212 t.Fatalf("Load() error = %v, want empty HTTP token error", err)
213 }
214}
215
216// server.SECURITY.11
217func TestLoadACIDServerSecurity11RejectsEmptyTOMLHTTPToken(t *testing.T) {
218 configPath := filepath.Join(t.TempDir(), "config.toml")
219 writeConfig(t, configPath, `[user]
220name = "toml-user"
221password = "toml-password"
222
223[mcp]
224http_token = ""
225`)
226
227 _, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
228 if err == nil {
229 t.Fatal("Load() error = nil, want empty HTTP token error")
230 }
231 if !strings.Contains(err.Error(), "MCP HTTP token must not be empty") {
232 t.Fatalf("Load() error = %v, want empty HTTP token error", err)
233 }
234}
235
236// server.SECURITY.1
237func TestLoadACIDServerSecurity1AllowsLoopbackHTTPAddrWithoutToken(t *testing.T) {
238 tests := []struct {
239 name string
240 addr string
241 }{
242 {name: "ipv4", addr: "127.0.0.1:8123"},
243 {name: "localhost", addr: "localhost:8123"},
244 {name: "ipv6", addr: "[::1]:8123"},
245 }
246
247 for _, tt := range tests {
248 t.Run(tt.name, func(t *testing.T) {
249 configPath := filepath.Join(t.TempDir(), "config.toml")
250 writeConfig(t, configPath, `[user]
251name = "toml-user"
252password = "toml-password"
253
254[mcp]
255http_addr = "`+tt.addr+`"
256`)
257
258 got, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
259 if err != nil {
260 t.Fatalf("Load() error = %v", err)
261 }
262 if got.HTTPAddr != tt.addr {
263 t.Fatalf("HTTPAddr = %q, want %q", got.HTTPAddr, tt.addr)
264 }
265 if got.HTTPToken != "" {
266 t.Fatalf("HTTPToken = %q, want empty", got.HTTPToken)
267 }
268 })
269 }
270}
271
272// server.SECURITY.1
273func TestLoadACIDServerSecurity1RejectsNonLoopbackHTTPAddrWithoutToken(t *testing.T) {
274 configPath := filepath.Join(t.TempDir(), "config.toml")
275 writeConfig(t, configPath, `[user]
276name = "toml-user"
277password = "toml-password"
278
279[mcp]
280http_addr = "0.0.0.0:8123"
281`)
282
283 _, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
284 if err == nil {
285 t.Fatal("Load() error = nil, want non-loopback HTTP token error")
286 }
287 if !strings.Contains(err.Error(), "MCP HTTP token is required") {
288 t.Fatalf("Load() error = %v, want HTTP token required error", err)
289 }
290}
291
292// server.SECURITY.1
293func TestLoadACIDServerSecurity1AllowsNonLoopbackHTTPAddrWithToken(t *testing.T) {
294 configPath := filepath.Join(t.TempDir(), "config.toml")
295 writeConfig(t, configPath, `[user]
296name = "toml-user"
297password = "toml-password"
298
299[mcp]
300http_addr = "0.0.0.0:8123"
301http_token = "secret-token"
302`)
303
304 got, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
305 if err != nil {
306 t.Fatalf("Load() error = %v", err)
307 }
308 if got.HTTPAddr != "0.0.0.0:8123" {
309 t.Fatalf("HTTPAddr = %q, want non-loopback address", got.HTTPAddr)
310 }
311 if got.HTTPToken != "secret-token" {
312 t.Fatal("HTTPToken was not resolved")
313 }
314}
315
316// server.SECURITY.1
317func TestLoadACIDServerSecurity1RejectsNonLoopbackHTTPAddrOverrideWithoutToken(t *testing.T) {
318 configPath := filepath.Join(t.TempDir(), "config.toml")
319 writeConfig(t, configPath, `[user]
320name = "toml-user"
321password = "toml-password"
322`)
323
324 _, err := Load(
325 context.Background(),
326 configPath,
327 configvalue.NewResolver(),
328 LoadOptions{HTTPAddr: "0.0.0.0:8123"},
329 )
330 if err == nil {
331 t.Fatal("Load() error = nil, want non-loopback HTTP token error")
332 }
333 if !strings.Contains(err.Error(), "MCP HTTP token is required") {
334 t.Fatalf("Load() error = %v, want HTTP token required error", err)
335 }
336}
337
338func TestLoadRejectsMissingExplicitConfigFile(t *testing.T) {
339 _, err := Load(
340 context.Background(),
341 filepath.Join(t.TempDir(), "missing.toml"),
342 configvalue.NewResolver(),
343 LoadOptions{},
344 )
345 if err == nil {
346 t.Fatal("Load() error = nil, want missing config error")
347 }
348}
349
350func TestLoadACIDServerConfig72RejectsMissingDefaultConfigHome(t *testing.T) {
351 t.Setenv("XDG_CONFIG_HOME", "")
352
353 originalUserHomeDir := userHomeDir
354 userHomeDir = func() (string, error) {
355 return "", os.ErrNotExist
356 }
357 t.Cleanup(func() {
358 userHomeDir = originalUserHomeDir
359 })
360
361 _, err := Load(context.Background(), "", configvalue.NewResolver(), LoadOptions{})
362 if err == nil {
363 t.Fatal("Load() error = nil, want default config path error")
364 }
365 if !strings.Contains(err.Error(), "resolve default config path") {
366 t.Fatalf("Load() error = %v, want default config path error", err)
367 }
368}
369
370func TestLoadRejectsNonLocalHTTPBaseURL(t *testing.T) {
371 configPath := filepath.Join(t.TempDir(), "config.toml")
372 writeConfig(t, configPath, `[user]
373name = "toml-user"
374password = "toml-password"
375
376[cooked]
377base_url = "http://example.com"
378`)
379
380 _, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
381 if err == nil {
382 t.Fatal("Load() error = nil, want non-local HTTP base URL error")
383 }
384 if !strings.Contains(err.Error(), "must use https") {
385 t.Fatalf("Load() error = %v, want https error", err)
386 }
387}
388
389func writeConfig(t *testing.T, path, content string) {
390 t.Helper()
391
392 if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
393 t.Fatalf("write config: %v", err)
394 }
395}