config_test.go

  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}