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())
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())
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())
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())
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())
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.SECURITY.4 server.SECURITY.9
124func TestLoadACIDServerSecurity4And9EnvironmentOverridesTOMLHTTPToken(t *testing.T) {
125 t.Setenv("COOKED_MCP_HTTP_TOKEN", "env-token")
126
127 configPath := filepath.Join(t.TempDir(), "config.toml")
128 writeConfig(t, configPath, `[user]
129name = "toml-user"
130password = "toml-password"
131
132[mcp]
133http_token = "toml-token"
134`)
135
136 got, err := Load(context.Background(), configPath, configvalue.NewResolver())
137 if err != nil {
138 t.Fatalf("Load() error = %v", err)
139 }
140
141 if got.HTTPToken != "env-token" {
142 t.Fatalf("HTTPToken = %q, want environment token", got.HTTPToken)
143 }
144}
145
146// server.SECURITY.5 server.SECURITY.10 server.SECURITY.12 server.SECURITY.13
147func TestLoadACIDServerSecurity5And10ResolvesTOMLHTTPTokenConfigValue(t *testing.T) {
148 t.Setenv("TEST_HTTP_TOKEN", "shell-token")
149
150 configPath := filepath.Join(t.TempDir(), "config.toml")
151 writeConfig(t, configPath, `[user]
152name = "toml-user"
153password = "toml-password"
154
155[mcp]
156http_token = "$TEST_HTTP_TOKEN"
157`)
158
159 got, err := Load(context.Background(), configPath, configvalue.NewResolver())
160 if err != nil {
161 t.Fatalf("Load() error = %v", err)
162 }
163
164 if got.HTTPToken != "shell-token" {
165 t.Fatalf("HTTPToken = %q, want resolved TOML token", got.HTTPToken)
166 }
167}
168
169// server.SECURITY.11
170func TestLoadACIDServerSecurity11RejectsEmptyEnvironmentHTTPToken(t *testing.T) {
171 t.Setenv("COOKED_MCP_HTTP_TOKEN", "")
172
173 configPath := filepath.Join(t.TempDir(), "config.toml")
174 writeConfig(t, configPath, `[user]
175name = "toml-user"
176password = "toml-password"
177`)
178
179 _, err := Load(context.Background(), configPath, configvalue.NewResolver())
180 if err == nil {
181 t.Fatal("Load() error = nil, want empty HTTP token error")
182 }
183 if !strings.Contains(err.Error(), "MCP HTTP token must not be empty") {
184 t.Fatalf("Load() error = %v, want empty HTTP token error", err)
185 }
186}
187
188// server.SECURITY.11
189func TestLoadACIDServerSecurity11RejectsEmptyTOMLHTTPToken(t *testing.T) {
190 configPath := filepath.Join(t.TempDir(), "config.toml")
191 writeConfig(t, configPath, `[user]
192name = "toml-user"
193password = "toml-password"
194
195[mcp]
196http_token = ""
197`)
198
199 _, err := Load(context.Background(), configPath, configvalue.NewResolver())
200 if err == nil {
201 t.Fatal("Load() error = nil, want empty HTTP token error")
202 }
203 if !strings.Contains(err.Error(), "MCP HTTP token must not be empty") {
204 t.Fatalf("Load() error = %v, want empty HTTP token error", err)
205 }
206}
207
208// server.SECURITY.1
209func TestLoadACIDServerSecurity1AllowsLoopbackHTTPAddrWithoutToken(t *testing.T) {
210 tests := []struct {
211 name string
212 addr string
213 }{
214 {name: "ipv4", addr: "127.0.0.1:8123"},
215 {name: "localhost", addr: "localhost:8123"},
216 {name: "ipv6", addr: "[::1]:8123"},
217 }
218
219 for _, tt := range tests {
220 t.Run(tt.name, func(t *testing.T) {
221 configPath := filepath.Join(t.TempDir(), "config.toml")
222 writeConfig(t, configPath, `[user]
223name = "toml-user"
224password = "toml-password"
225
226[mcp]
227http_addr = "`+tt.addr+`"
228`)
229
230 got, err := Load(context.Background(), configPath, configvalue.NewResolver())
231 if err != nil {
232 t.Fatalf("Load() error = %v", err)
233 }
234 if got.HTTPAddr != tt.addr {
235 t.Fatalf("HTTPAddr = %q, want %q", got.HTTPAddr, tt.addr)
236 }
237 if got.HTTPToken != "" {
238 t.Fatalf("HTTPToken = %q, want empty", got.HTTPToken)
239 }
240 })
241 }
242}
243
244// server.SECURITY.1
245func TestLoadACIDServerSecurity1RejectsNonLoopbackHTTPAddrWithoutToken(t *testing.T) {
246 configPath := filepath.Join(t.TempDir(), "config.toml")
247 writeConfig(t, configPath, `[user]
248name = "toml-user"
249password = "toml-password"
250
251[mcp]
252http_addr = "0.0.0.0:8123"
253`)
254
255 _, err := Load(context.Background(), configPath, configvalue.NewResolver())
256 if err == nil {
257 t.Fatal("Load() error = nil, want non-loopback HTTP token error")
258 }
259 if !strings.Contains(err.Error(), "MCP HTTP token is required") {
260 t.Fatalf("Load() error = %v, want HTTP token required error", err)
261 }
262}
263
264// server.SECURITY.1
265func TestLoadACIDServerSecurity1AllowsNonLoopbackHTTPAddrWithToken(t *testing.T) {
266 configPath := filepath.Join(t.TempDir(), "config.toml")
267 writeConfig(t, configPath, `[user]
268name = "toml-user"
269password = "toml-password"
270
271[mcp]
272http_addr = "0.0.0.0:8123"
273http_token = "secret-token"
274`)
275
276 got, err := Load(context.Background(), configPath, configvalue.NewResolver())
277 if err != nil {
278 t.Fatalf("Load() error = %v", err)
279 }
280 if got.HTTPAddr != "0.0.0.0:8123" {
281 t.Fatalf("HTTPAddr = %q, want non-loopback address", got.HTTPAddr)
282 }
283 if got.HTTPToken != "secret-token" {
284 t.Fatal("HTTPToken was not resolved")
285 }
286}
287
288func TestLoadRejectsMissingExplicitConfigFile(t *testing.T) {
289 _, err := Load(context.Background(), filepath.Join(t.TempDir(), "missing.toml"), configvalue.NewResolver())
290 if err == nil {
291 t.Fatal("Load() error = nil, want missing config error")
292 }
293}
294
295func TestLoadACIDServerConfig72RejectsMissingDefaultConfigHome(t *testing.T) {
296 t.Setenv("XDG_CONFIG_HOME", "")
297
298 originalUserHomeDir := userHomeDir
299 userHomeDir = func() (string, error) {
300 return "", os.ErrNotExist
301 }
302 t.Cleanup(func() {
303 userHomeDir = originalUserHomeDir
304 })
305
306 _, err := Load(context.Background(), "", configvalue.NewResolver())
307 if err == nil {
308 t.Fatal("Load() error = nil, want default config path error")
309 }
310 if !strings.Contains(err.Error(), "resolve default config path") {
311 t.Fatalf("Load() error = %v, want default config path error", err)
312 }
313}
314
315func TestLoadRejectsNonLocalHTTPBaseURL(t *testing.T) {
316 configPath := filepath.Join(t.TempDir(), "config.toml")
317 writeConfig(t, configPath, `[user]
318name = "toml-user"
319password = "toml-password"
320
321[cooked]
322base_url = "http://example.com"
323`)
324
325 _, err := Load(context.Background(), configPath, configvalue.NewResolver())
326 if err == nil {
327 t.Fatal("Load() error = nil, want non-local HTTP base URL error")
328 }
329 if !strings.Contains(err.Error(), "must use https") {
330 t.Fatalf("Load() error = %v, want https error", err)
331 }
332}
333
334func writeConfig(t *testing.T, path, content string) {
335 t.Helper()
336
337 if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
338 t.Fatalf("write config: %v", err)
339 }
340}