Vectrum
Sign In
293 lines · 9.6 KB · Go
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)
	}
}