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-1
237func TestLoadACIDServerSecurity1_1AllowsLoopbackHTTPAddrWithoutToken(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-1
273func TestLoadACIDServerSecurity1_1AllowsHTTPAddrWithoutToken(t *testing.T) {
274 tests := []struct {
275 name string
276 addr string
277 }{
278 {name: "all interfaces", addr: "0.0.0.0:8123"},
279 {name: "LAN", addr: "192.168.1.10:8123"},
280 {name: "Tailscale", addr: "100.64.0.1:8123"},
281 {name: "hostname-like LAN", addr: "192.168.1.blah:8123"},
282 }
283
284 for _, tt := range tests {
285 t.Run(tt.name, func(t *testing.T) {
286 configPath := filepath.Join(t.TempDir(), "config.toml")
287 writeConfig(t, configPath, `[user]
288name = "toml-user"
289password = "toml-password"
290
291[mcp]
292http_addr = "`+tt.addr+`"
293`)
294
295 got, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
296 if err != nil {
297 t.Fatalf("Load() error = %v", err)
298 }
299 if got.HTTPAddr != tt.addr {
300 t.Fatalf("HTTPAddr = %q, want %q", got.HTTPAddr, tt.addr)
301 }
302 if got.HTTPToken != "" {
303 t.Fatalf("HTTPToken = %q, want empty", got.HTTPToken)
304 }
305 })
306 }
307}
308
309// server.SECURITY.1-1
310func TestLoadACIDServerSecurity1_1AllowsHTTPAddrOverrideWithoutToken(t *testing.T) {
311 configPath := filepath.Join(t.TempDir(), "config.toml")
312 writeConfig(t, configPath, `[user]
313name = "toml-user"
314password = "toml-password"
315`)
316
317 got, err := Load(
318 context.Background(),
319 configPath,
320 configvalue.NewResolver(),
321 LoadOptions{HTTPAddr: "0.0.0.0:8123"},
322 )
323 if err != nil {
324 t.Fatalf("Load() error = %v", err)
325 }
326 if got.HTTPAddr != "0.0.0.0:8123" {
327 t.Fatalf("HTTPAddr = %q, want override", got.HTTPAddr)
328 }
329 if got.HTTPToken != "" {
330 t.Fatalf("HTTPToken = %q, want empty", got.HTTPToken)
331 }
332}
333
334func TestLoadRejectsMissingExplicitConfigFile(t *testing.T) {
335 _, err := Load(
336 context.Background(),
337 filepath.Join(t.TempDir(), "missing.toml"),
338 configvalue.NewResolver(),
339 LoadOptions{},
340 )
341 if err == nil {
342 t.Fatal("Load() error = nil, want missing config error")
343 }
344}
345
346func TestLoadACIDServerConfig72RejectsMissingDefaultConfigHome(t *testing.T) {
347 t.Setenv("XDG_CONFIG_HOME", "")
348
349 originalUserHomeDir := userHomeDir
350 userHomeDir = func() (string, error) {
351 return "", os.ErrNotExist
352 }
353 t.Cleanup(func() {
354 userHomeDir = originalUserHomeDir
355 })
356
357 _, err := Load(context.Background(), "", configvalue.NewResolver(), LoadOptions{})
358 if err == nil {
359 t.Fatal("Load() error = nil, want default config path error")
360 }
361 if !strings.Contains(err.Error(), "resolve default config path") {
362 t.Fatalf("Load() error = %v, want default config path error", err)
363 }
364}
365
366func TestLoadRejectsNonLocalHTTPBaseURL(t *testing.T) {
367 configPath := filepath.Join(t.TempDir(), "config.toml")
368 writeConfig(t, configPath, `[user]
369name = "toml-user"
370password = "toml-password"
371
372[cooked]
373base_url = "http://example.com"
374`)
375
376 _, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
377 if err == nil {
378 t.Fatal("Load() error = nil, want non-local HTTP base URL error")
379 }
380 if !strings.Contains(err.Error(), "must use https") {
381 t.Fatalf("Load() error = %v, want https error", err)
382 }
383}
384
385func writeConfig(t *testing.T, path, content string) {
386 t.Helper()
387
388 if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
389 t.Fatalf("write config: %v", err)
390 }
391}