Vectrum
Sign In
254 lines · 8.3 KB · Go
package main

import (
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/johannSo/codey/internal/approval"
	"github.com/johannSo/codey/internal/ccsettings"
)

// ccFixture is a home and a project under a temp directory — never the real
// ~/.claude* — with a shared allow rule, and MCP servers in both of Claude
// Code's places for them, which nothing here should read.
type ccFixture struct {
	home, root string
	store      ccsettings.TrustStore
	notices    []string
	asked      int
}

func newCCFixture(t *testing.T) *ccFixture {
	t.Helper()
	base := t.TempDir()
	f := &ccFixture{
		home:  filepath.Join(base, "home"),
		root:  filepath.Join(base, "home", "proj"),
		store: ccsettings.TrustStore{Path: filepath.Join(base, "config", "codey", ccsettings.TrustFilename)},
	}
	f.write(t, f.root, ".mcp.json", `{"mcpServers":{"repo-tool":{"command":"repo-mcp","args":["--root","."]}}}`)
	f.write(t, f.root, ".claude/settings.json", `{"permissions":{"allow":["Bash(make:*)"],"deny":["Bash(rm:*)"]}}`)
	f.write(t, f.home, ".claude.json", `{"mcpServers":{"mine":{"command":"my-mcp"}}}`)
	return f
}

func (f *ccFixture) write(t *testing.T, dir, rel, body string) {
	t.Helper()
	path := filepath.Join(dir, rel)
	os.MkdirAll(filepath.Dir(path), 0o755)
	if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
		t.Fatal(err)
	}
}

// opts builds the options; answer nil means nobody can be asked.
func (f *ccFixture) opts(answer *bool) ccOptions {
	o := ccOptions{
		Home:   f.home,
		Root:   f.root,
		Store:  f.store,
		Notice: func(format string, args ...any) { f.notices = append(f.notices, fmt.Sprintf(format, args...)) },
	}
	if answer != nil {
		o.Ask = func(string, []string) (bool, bool) { f.asked++; return *answer, true }
	}
	return o
}

func (f *ccFixture) said(s string) bool {
	for _, n := range f.notices {
		if strings.Contains(n, s) {
			return true
		}
	}
	return false
}

func allowed(rs approval.RuleSet, cmd string) approval.Verdict {
	if rs == nil {
		return approval.NoVerdict
	}
	return rs.Check("bash", []byte(fmt.Sprintf(`{"command":%q}`, cmd))).Verdict
}

func yes() *bool { v := true; return &v }
func no() *bool  { v := false; return &v }

// Headless — -p, agent stdio — nothing from the repository is used, the
// repository's deny rules still hold, and the notice names the flag that says
// otherwise.
func TestUntrustedHeadless(t *testing.T) {
	f := newCCFixture(t)
	got := loadClaudeCode(f.opts(nil))
	if v := allowed(got.Rules, "make build"); v != approval.NoVerdict {
		t.Errorf("an untrusted project allow rule was honoured: %v", v)
	}
	if v := allowed(got.Rules, "rm -rf x"); v != approval.VerdictDeny {
		t.Errorf("the project's deny rule should hold untrusted: %v", v)
	}
	if !f.said(trustFlag) || !f.said(".claude/settings.json") {
		t.Errorf("notices %q should name the file and %s", f.notices, trustFlag)
	}
	if f.said("repo-tool") || f.said("mcp") {
		t.Errorf("notices %q mention Claude Code's MCP servers, which are not read", f.notices)
	}
}

func TestTrustFlag(t *testing.T) {
	f := newCCFixture(t)
	o := f.opts(no())
	o.Trust = true
	got := loadClaudeCode(o)
	if f.asked != 0 {
		t.Errorf("-trust-project should not ask (asked %d)", f.asked)
	}
	if v := allowed(got.Rules, "make build"); v != approval.VerdictAllow {
		t.Errorf("-trust-project should honour the project's allow rules: %v", v)
	}
	// The flag is for one run: nothing is recorded.
	if decided, _, _ := f.store.Lookup(f.root, ccsettings.NewGrant(mustPerms(t, f)).Fingerprint()); decided {
		t.Error("the flag should not record a decision")
	}
}

func TestTrustIsAskedOnceAndRemembered(t *testing.T) {
	f := newCCFixture(t)
	got := loadClaudeCode(f.opts(yes()))
	if f.asked != 1 {
		t.Fatalf("asked %d times, want 1", f.asked)
	}
	if v := allowed(got.Rules, "make build"); v != approval.VerdictAllow {
		t.Errorf("a trusted project allow rule should be honoured: %v", v)
	}

	// The next start, interactive or not, does not ask again.
	got = loadClaudeCode(f.opts(yes()))
	if f.asked != 1 {
		t.Errorf("asked again for the same definitions")
	}
	got = loadClaudeCode(f.opts(nil))
	if v := allowed(got.Rules, "make build"); v != approval.VerdictAllow {
		t.Errorf("a recorded trust should hold for a headless run: %v", v)
	}

	// A .mcp.json is not part of what is trusted: changing it asks nothing.
	f.write(t, f.root, ".mcp.json", `{"mcpServers":{"repo-tool":{"command":"curl","args":["evil.example"]}}}`)
	loadClaudeCode(f.opts(yes()))
	if f.asked != 1 {
		t.Errorf("a changed .mcp.json asked again (asked %d)", f.asked)
	}

	// A changed allow list is a new question.
	f.write(t, f.root, ".claude/settings.json", `{"permissions":{"allow":["Bash(make:*)","Bash"]}}`)
	got = loadClaudeCode(f.opts(nil))
	if v := allowed(got.Rules, "make build"); v != approval.NoVerdict {
		t.Errorf("a changed allow list kept the old trust: %v", v)
	}
}

func TestDeclinedTrustIsRemembered(t *testing.T) {
	f := newCCFixture(t)
	got := loadClaudeCode(f.opts(no()))
	if v := allowed(got.Rules, "make build"); v != approval.NoVerdict {
		t.Errorf("a declined project allow rule was honoured: %v", v)
	}
	f.notices = nil
	loadClaudeCode(f.opts(yes()))
	if f.asked != 1 {
		t.Errorf("a refusal should not be asked again (asked %d)", f.asked)
	}
	if !f.said("declined") || !f.said(trustFlag) {
		t.Errorf("notices %q should say it was declined and name %s", f.notices, trustFlag)
	}
}

// Nothing to trust is nothing to ask — and a .mcp.json is nothing to trust,
// since its servers are never started.
func TestNoProjectFilesNoQuestion(t *testing.T) {
	f := newCCFixture(t)
	f.write(t, f.root, ".claude/settings.json", `{"permissions":{"deny":["Bash(rm:*)"]}}`)
	got := loadClaudeCode(f.opts(yes()))
	if f.asked != 0 {
		t.Error("asked about a project that grants nothing")
	}
	if v := allowed(got.Rules, "rm x"); v != approval.VerdictDeny {
		t.Errorf("deny = %v", v)
	}
}

// A machine without Claude Code leaves the policy exactly as it was.
func TestNoClaudeCodeNoRules(t *testing.T) {
	base := t.TempDir()
	o := ccOptions{Home: filepath.Join(base, "h"), Root: filepath.Join(base, "r"), Notice: func(string, ...any) {}}
	got := loadClaudeCode(o)
	if got.Rules != nil {
		t.Errorf("rules = %#v, want nil", got.Rules)
	}
}

// A root other than the startup one — an ACP thread in a worktree — is never
// asked about, and uses only what was already trusted.
func TestRulesForAnotherRoot(t *testing.T) {
	f := newCCFixture(t)
	start := loadClaudeCode(f.opts(nil))
	other := filepath.Join(f.home, "other")
	f.write(t, other, ".claude/settings.json", `{"permissions":{"allow":["Bash(make:*)"]}}`)
	o := f.opts(yes())
	lookup := rulesFor(o, f.root, start)
	if v := allowed(lookup(other), "make"); v != approval.NoVerdict || f.asked != 0 {
		t.Errorf("verdict %v, asked %d: another root must not be asked about", v, f.asked)
	}
	g := ccsettings.NewGrant([]ccsettings.Permissions{{Scope: ccsettings.ScopeProject, Allow: []string{"Bash(make:*)"}}})
	f.store.Record(other, g.Fingerprint(), true)
	lookup = rulesFor(o, f.root, start)
	if v := allowed(lookup(other), "make"); v != approval.VerdictAllow {
		t.Errorf("a recorded trust for the other root should count: %v", v)
	}
}

func TestAskTrustOnTerminal(t *testing.T) {
	tests := []struct {
		in                string
		trusted, answered bool
	}{
		{"y\n", true, true},
		{"YES\n", true, true},
		{"n\n", false, true},
		{"\n", false, true},
		{"", false, false},
	}
	for _, tc := range tests {
		var out strings.Builder
		ask := askTrustOnTerminal(strings.NewReader(tc.in), &out)
		trusted, answered := ask("/src/proj", []string{"run these without asking, from .claude/settings.json:", "  Bash(make:*)"})
		if trusted != tc.trusted || answered != tc.answered {
			t.Errorf("%q: got %v %v, want %v %v", tc.in, trusted, answered, tc.trusted, tc.answered)
		}
		if !strings.Contains(out.String(), "Bash(make:*)") || !strings.Contains(out.String(), "[y/N]") {
			t.Errorf("prompt did not show the definitions:\n%s", out.String())
		}
	}
}

// The answer is read without reading past it: whatever is typed next belongs
// to the TUI.
func TestReadLineStopsAtTheNewline(t *testing.T) {
	r := strings.NewReader("y\nnext")
	line, err := readLine(r)
	if line != "y" || err != nil {
		t.Fatalf("%q %v", line, err)
	}
	rest := make([]byte, 8)
	n, _ := r.Read(rest)
	if string(rest[:n]) != "next" {
		t.Errorf("rest = %q", rest[:n])
	}
}

func mustPerms(t *testing.T, f *ccFixture) []ccsettings.Permissions {
	p, _ := ccsettings.LoadPermissions(f.home, f.root)
	return p
}