package hooks import ( "encoding/json" "os" "path/filepath" "strings" "testing" ) func TestCommitGuard_NotConfigured(t *testing.T) { cfg := newCfg(t, "") if _, err := EnableCommitGuard(cfg); err != ErrCommitGuardNotConfigured { t.Errorf("enable err = %v, want ErrCommitGuardNotConfigured", err) } if _, err := DisableCommitGuard(cfg); err != ErrCommitGuardNotConfigured { t.Errorf("disable err = %v, want ErrCommitGuardNotConfigured", err) } if _, err := RefreshCommitGuard(cfg); err != ErrCommitGuardNotConfigured { t.Errorf("refresh err = %v, want ErrCommitGuardNotConfigured", err) } } func TestCommitGuard_EnableWritesPreToolUseGroup(t *testing.T) { dir := t.TempDir() sp := filepath.Join(dir, "settings.json") cfg := newCfg(t, sp) if _, err := EnableCommitGuard(cfg); err != nil { t.Fatalf("EnableCommitGuard: %v", err) } b, err := os.ReadFile(sp) if err != nil { t.Fatal(err) } var root map[string]any if err := json.Unmarshal(b, &root); err != nil { t.Fatalf("settings not valid JSON: %v", err) } if !commitGuardInstalled(root) { t.Errorf("commit-guard group not present:\n%s", b) } // The group carries the Bash matcher and the hidden runner command. groups := preToolGroups(root) if len(groups) != 1 { t.Fatalf("want 1 PreToolUse group, got %d", len(groups)) } gm := groups[0].(map[string]any) if gm["matcher"] != "Bash" { t.Errorf("matcher = %v, want Bash", gm["matcher"]) } cmd := gm["hooks"].([]any)[0].(map[string]any)["command"].(string) if !strings.Contains(cmd, commitGuardToken) { t.Errorf("command missing token: %q", cmd) } // Idempotent. msg, err := EnableCommitGuard(cfg) if err != nil || !strings.Contains(msg, "already enabled") { t.Errorf("re-enable: msg=%q err=%v", msg, err) } } func TestCommitGuard_DisablePreservesForeignGroupsAndKeys(t *testing.T) { dir := t.TempDir() sp := filepath.Join(dir, "settings.json") cfg := newCfg(t, sp) original := `{ "model": "x", "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "other-tool guard" } ] } ], "SessionStart": [ { "hooks": [ { "type": "command", "command": "keep-session" } ] } ] } }` if err := os.WriteFile(sp, []byte(original), 0o644); err != nil { t.Fatal(err) } if _, err := EnableCommitGuard(cfg); err != nil { t.Fatalf("EnableCommitGuard: %v", err) } // Two PreToolUse groups now (foreign + eeco). var root map[string]any b, _ := os.ReadFile(sp) if err := json.Unmarshal(b, &root); err != nil { t.Fatal(err) } if len(preToolGroups(root)) != 2 { t.Fatalf("want 2 PreToolUse groups after enable, got %d", len(preToolGroups(root))) } if _, err := DisableCommitGuard(cfg); err != nil { t.Fatalf("DisableCommitGuard: %v", err) } b, _ = os.ReadFile(sp) if err := json.Unmarshal(b, &root); err != nil { t.Fatal(err) } if commitGuardInstalled(root) { t.Error("eeco group still present after disable") } if root["model"] != "x" { t.Errorf("foreign top-level key lost: %v", root["model"]) } groups := preToolGroups(root) if len(groups) != 1 { t.Fatalf("foreign PreToolUse group not preserved, groups=%d", len(groups)) } gm := groups[0].(map[string]any) cmd := gm["hooks"].([]any)[0].(map[string]any)["command"].(string) if cmd != "other-tool guard" { t.Errorf("wrong PreToolUse group survived: %q", cmd) } // The SessionStart channel is untouched. if len(sessionGroups(root)) != 1 { t.Errorf("SessionStart group disturbed by commit-guard disable") } } func TestCommitGuard_EnableRefusesMalformedJSON(t *testing.T) { dir := t.TempDir() sp := filepath.Join(dir, "settings.json") cfg := newCfg(t, sp) bad := "{ not valid json" if err := os.WriteFile(sp, []byte(bad), 0o644); err != nil { t.Fatal(err) } if _, err := EnableCommitGuard(cfg); err == nil { t.Fatal("expected refusal on malformed settings") } if b, _ := os.ReadFile(sp); string(b) != bad { t.Errorf("malformed settings file was modified:\n%s", b) } } func TestCommitGuard_DisableNoOpWhenAbsent(t *testing.T) { dir := t.TempDir() sp := filepath.Join(dir, "settings.json") cfg := newCfg(t, sp) original := `{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"keep-me"}]}]}}` if err := os.WriteFile(sp, []byte(original), 0o644); err != nil { t.Fatal(err) } msg, err := DisableCommitGuard(cfg) if err != nil { t.Fatalf("DisableCommitGuard: %v", err) } if !strings.Contains(msg, "not enabled") { t.Errorf("msg = %q, want 'not enabled'", msg) } if b, _ := os.ReadFile(sp); string(b) != original { t.Errorf("settings modified despite no eeco group:\n%s", b) } } func TestCommitGuard_RefreshRewritesStalePath(t *testing.T) { dir := t.TempDir() sp := filepath.Join(dir, "settings.json") cfg := newCfg(t, sp) // Install a group carrying the token but a stale binary path. stale := `{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"\"/old/path/eeco\" ` + commitGuardToken + `"}]}]}}` if err := os.WriteFile(sp, []byte(stale), 0o644); err != nil { t.Fatal(err) } msg, err := RefreshCommitGuard(cfg) if err != nil { t.Fatalf("RefreshCommitGuard: %v", err) } if !strings.Contains(msg, "refreshed") { t.Errorf("msg = %q, want 'refreshed'", msg) } b, _ := os.ReadFile(sp) if strings.Contains(string(b), "/old/path/eeco") { t.Errorf("stale path not rewritten:\n%s", b) } // Second refresh is a no-op. msg, err = RefreshCommitGuard(cfg) if err != nil || !strings.Contains(msg, "already current") { t.Errorf("second refresh: msg=%q err=%v, want 'already current'", msg, err) } }