package ccsettings import ( "os" "os/exec" "path/filepath" "reflect" "strings" "testing" "github.com/johannSo/codey/internal/config" ) // write puts a file under dir, creating its parents. Every test here builds // its own home and root under t.TempDir: nothing may read the real ~/.claude*. func write(t *testing.T, dir, rel, body string) string { t.Helper() path := filepath.Join(dir, rel) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } return path } func dirs(t *testing.T) (home, root string) { t.Helper() base := t.TempDir() home = filepath.Join(base, "home") root = filepath.Join(home, "src", "proj") if err := os.MkdirAll(root, 0o755); err != nil { t.Fatal(err) } return home, root } func TestLoadPermissionsReadsTheThreeFiles(t *testing.T) { home, root := dirs(t) write(t, home, UserSettings, `{"permissions":{"allow":["Bash(go test:*)"],"defaultMode":"acceptEdits"},"model":"opus","env":{"X":"1"}}`) write(t, root, ProjectSettings, `{"permissions":{"allow":["Bash(make:*)"],"deny":["Read(.env)"]}}`) write(t, root, LocalSettings, `{"permissions":{"ask":["Bash(git push:*)"]}}`) got, errs := LoadPermissions(home, root) if len(errs) != 0 { t.Fatalf("errors: %v", errs) } want := []Permissions{ {Scope: ScopeUser, Shown: "~/.claude/settings.json", Allow: []string{"Bash(go test:*)"}, DefaultMode: "acceptEdits"}, {Scope: ScopeProject, Shown: ".claude/settings.json", Allow: []string{"Bash(make:*)"}, Deny: []string{"Read(.env)"}}, {Scope: ScopeLocal, Shown: ".claude/settings.local.json", Ask: []string{"Bash(git push:*)"}}, } for i := range got { got[i].Path = "" } if !reflect.DeepEqual(got, want) { t.Errorf("got %+v\nwant %+v", got, want) } } func TestLoadPermissionsShapes(t *testing.T) { tests := []struct { name string body string wantFiles int wantErr bool }{ {"no file", "", 0, false}, {"empty object", `{}`, 1, false}, {"no permissions block", `{"hooks":{"PreToolUse":[]}}`, 1, false}, {"broken JSON", `{"permissions":`, 0, true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { home, root := dirs(t) if tc.body != "" { write(t, root, ProjectSettings, tc.body) } got, errs := LoadPermissions(home, root) if len(got) != tc.wantFiles || (len(errs) > 0) != tc.wantErr { t.Errorf("files %d errs %v, want %d files, error %v", len(got), errs, tc.wantFiles, tc.wantErr) } }) } } // Started in the home directory, the user's settings are the user's, not a // project's: reading them a second time as shared project settings would hold // their allow rules hostage to a trust prompt about themselves. func TestLoadPermissionsRootIsHome(t *testing.T) { home, _ := dirs(t) write(t, home, UserSettings, `{"permissions":{"allow":["Edit"]}}`) got, _ := LoadPermissions(home, home) if len(got) != 1 || got[0].Scope != ScopeUser { t.Errorf("got %+v, want only the user file", got) } } // A settings.local.json is supposed to be kept out of git. One that git is // tracking arrived with the clone, and gets no more trust than the shared // file. func TestTrackedLocalSettingsAreProjectScope(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("no git") } home, root := dirs(t) write(t, root, LocalSettings, `{"permissions":{"allow":["Bash"]}}`) for _, args := range [][]string{{"init", "-q"}, {"add", "-f", LocalSettings}} { cmd := exec.Command("git", args...) cmd.Dir = root cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git %v: %v\n%s", args, err, out) } } got, _ := LoadPermissions(home, root) if len(got) != 1 || got[0].Scope != ScopeProject || !got[0].Tracked { t.Fatalf("got %+v, want a tracked, project-scope file", got) } rs, _ := NewRuleset(home, root, got, false) if v := rs.Check("bash", []byte(`{"command":"ls"}`)); v.Verdict != 0 { t.Errorf("an untrusted, committed local file's allow rule was honoured: %+v", v) } } func TestLoadMCPShapes(t *testing.T) { home, root := dirs(t) write(t, root, ProjectMCP, `{"mcpServers":{ "plain": {"command":"plain-mcp","args":["--x"]}, "typed": {"type":"stdio","command":"typed-mcp","env":{"K":"v"}}, "web": {"type":"http","url":"https://example.com/mcp"}, "old": {"type":"sse","url":"https://example.com/sse"}, "bare": {"url":"https://example.com/u"} }}`) write(t, home, ClaudeJSON, `{ "numStartups": 12, "mcpServers": {"mine": {"command":"mine-mcp"}}, "projects": { "`+root+`": {"mcpServers": {"here": {"command":"here-mcp"}}, "allowedTools": []}, "/elsewhere": {"mcpServers": {"there": {"command":"there-mcp"}}} } }`) files, errs := LoadMCP(home, root) if len(errs) != 3 { t.Errorf("want one error per remote server, got %v", errs) } for _, err := range errs { if !strings.Contains(err.Error(), "stdio") { t.Errorf("error %q should say Codey speaks only stdio", err) } } var got []string for _, f := range files { got = append(got, string(f.Scope)+":"+strings.Join(sortedNames(f.Servers), ",")) } want := []string{"local:here", "project:plain,typed", "user:mine"} if !reflect.DeepEqual(got, want) { t.Errorf("files %v, want %v (most specific first)", got, want) } } // Claude Code keys a project by the path it was started in; a root reached // through a symlink is still that project. func TestLoadMCPFindsProjectThroughSymlink(t *testing.T) { home, root := dirs(t) link := filepath.Join(filepath.Dir(home), "link") if err := os.Symlink(root, link); err != nil { t.Skip(err) } write(t, home, ClaudeJSON, `{"projects":{"`+root+`":{"mcpServers":{"here":{"command":"x"}}}}}`) files, _ := LoadMCP(home, link) if len(files) != 1 || files[0].Scope != ScopeLocal { t.Errorf("got %+v", files) } } func TestLoadMCPBrokenFileIsReported(t *testing.T) { home, root := dirs(t) write(t, root, ProjectMCP, `{"mcpServers": [`) files, errs := LoadMCP(home, root) if len(files) != 0 || len(errs) != 1 { t.Errorf("files %v errs %v", files, errs) } } func env(m map[string]string) func(string) (string, bool) { return func(k string) (string, bool) { v, ok := m[k]; return v, ok } } func TestMergeMCPPrecedence(t *testing.T) { files := []MCPFile{ {Scope: ScopeLocal, Shown: "~/.claude.json (this project)", Servers: map[string]ServerDef{ "db": {Command: "local-db"}, "only-local": {Command: "l"}}}, {Scope: ScopeProject, Shown: ".mcp.json", Servers: map[string]ServerDef{ "db": {Command: "project-db"}, "fs": {Command: "project-fs"}, "lint": {Command: "project-lint"}}}, {Scope: ScopeUser, Shown: "~/.claude.json", Servers: map[string]ServerDef{ "fs": {Command: "user-fs"}, "lint": {Command: "user-lint"}, "only-user": {Command: "u"}}}, } own := map[string]config.MCPServer{"lint": {Command: "codey-lint"}} tests := []struct { name string trusted bool want map[string]string wantNotes int }{ { name: "trusted", trusted: true, want: map[string]string{ "lint": "codey-lint", "db": "local-db", "fs": "project-fs", "only-local": "l", "only-user": "u", }, // db: local over project; lint: codey over project and user; // fs: project over user. wantNotes: 4, }, { name: "untrusted project servers give way to the user's own", trusted: false, want: map[string]string{ "lint": "codey-lint", "db": "local-db", "fs": "user-fs", "only-local": "l", "only-user": "u", }, wantNotes: 1, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got, notes := MergeMCP(own, files, tc.trusted, env(nil)) commands := map[string]string{} for n, s := range got { commands[n] = s.Command } if !reflect.DeepEqual(commands, tc.want) { t.Errorf("got %v, want %v", commands, tc.want) } if len(notes) != tc.wantNotes { t.Errorf("notes %q, want %d", notes, tc.wantNotes) } for _, n := range notes { if !strings.Contains(n, "using the one in") { t.Errorf("clash note %q should name the winner", n) } } }) } } func TestExpand(t *testing.T) { vars := env(map[string]string{"HOME": "/h", "EMPTY": "", "PORT": "5432"}) tests := []struct { in, want string wantErr bool }{ {"plain", "plain", false}, {"${HOME}/bin/tool", "/h/bin/tool", false}, {"--port=${PORT}", "--port=5432", false}, {"${MISSING:-fallback}", "fallback", false}, {"${EMPTY:-fallback}", "fallback", false}, {"${HOME:-unused}", "/h", false}, {"${MISSING:-}", "", false}, {"${A:-x}${B:-y}", "xy", false}, {"$HOME stays", "$HOME stays", false}, {"${MISSING}", "", true}, } for _, tc := range tests { got, err := expand(tc.in, vars) if (err != nil) != tc.wantErr || got != tc.want { t.Errorf("expand(%q) = %q, %v; want %q, error %v", tc.in, got, err, tc.want, tc.wantErr) } } } func TestMergeMCPExpandsEverywhere(t *testing.T) { files := []MCPFile{{Scope: ScopeUser, Shown: "~/.claude.json", Servers: map[string]ServerDef{ "db": {Command: "${BIN:-/usr/bin}/db", Args: []string{"--url", "${DB_URL}"}, Env: map[string]string{"TOKEN": "${TOKEN}"}}, "broken": {Command: "x", Args: []string{"${NOPE}"}}, }}} got, notes := MergeMCP(nil, files, false, env(map[string]string{"DB_URL": "pg://x", "TOKEN": "t"})) want := config.MCPServer{Command: "/usr/bin/db", Args: []string{"--url", "pg://x"}, Env: map[string]string{"TOKEN": "t"}} if !reflect.DeepEqual(got["db"], want) { t.Errorf("db = %+v, want %+v", got["db"], want) } if _, ok := got["broken"]; ok { t.Error("a server with an unset variable and no default should not start") } if len(notes) != 1 || !strings.Contains(notes[0], "NOPE") { t.Errorf("notes %q should name the missing variable", notes) } }