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-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}