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