package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/johannSo/codey/internal/approval"
"github.com/johannSo/codey/internal/ccsettings"
"github.com/johannSo/codey/internal/config"
)
// ccFixture is a home and a project under a temp directory — never the real
// ~/.claude* — with a project .mcp.json, a shared allow rule, and a server of
// the user's own.
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,
Own: map[string]config.MCPServer{"codey-own": {Command: "own"}},
Store: f.store,
Lookup: func(string) (string, bool) { return "", false },
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 user's
// own servers still start, 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 _, ok := got.MCP["repo-tool"]; ok {
t.Error("an untrusted project server would start")
}
for _, name := range []string{"mine", "codey-own"} {
if _, ok := got.MCP[name]; !ok {
t.Errorf("%s should start: it is the user's own", name)
}
}
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("repo-tool") {
t.Errorf("notices %q should name the server and %s", f.notices, trustFlag)
}
}
func TestTrustFlag(t *testing.T) {
f := newCCFixture(t)
o := f.opts(no())
o.Trust = true
got := loadClaudeCode(o)
if _, ok := got.MCP["repo-tool"]; !ok || f.asked != 0 {
t.Errorf("-trust-project should start the project's servers without asking (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(mustMCP(t, f), 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 _, ok := got.MCP["repo-tool"]; !ok {
t.Error("a trusted project server should start")
}
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 _, ok := got.MCP["repo-tool"]; !ok {
t.Error("a recorded trust should hold for a headless run")
}
// A changed .mcp.json is a new question.
f.write(t, f.root, ".mcp.json", `{"mcpServers":{"repo-tool":{"command":"curl","args":["evil.example"]}}}`)
got = loadClaudeCode(f.opts(nil))
if _, ok := got.MCP["repo-tool"]; ok {
t.Error("a changed server started on the old trust")
}
loadClaudeCode(f.opts(yes()))
if f.asked != 2 {
t.Errorf("a changed .mcp.json should ask again (asked %d)", f.asked)
}
// So is a changed allow list.
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 _, ok := got.MCP["repo-tool"]; ok {
t.Error("a declined project server would start")
}
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.
func TestNoProjectFilesNoQuestion(t *testing.T) {
f := newCCFixture(t)
os.Remove(filepath.Join(f.root, ".mcp.json"))
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) {}, Lookup: func(string) (string, bool) { return "", false }}
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(nil, []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{"start these MCP servers, from .mcp.json:", " db: db-mcp"})
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(), "db: db-mcp") || !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 mustMCP(t *testing.T, f *ccFixture) []ccsettings.MCPFile {
files, _ := ccsettings.LoadMCP(f.home, f.root)
return files
}
func mustPerms(t *testing.T, f *ccFixture) []ccsettings.Permissions {
p, _ := ccsettings.LoadPermissions(f.home, f.root)
return p
}