返回 DeepSeek-Reasonix
hook_machine_test.go
根目录 / internal / cli / hook_machine_test.go
1 package cli
2
3 import (
4 "bytes"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "reasonix/internal/hook"
12 )
13
14 func TestHookMachineListRedactsCommandsAndEnablesProjectHooks(t *testing.T) {
15 root := t.TempDir()
16 home := t.TempDir()
17 projectSettings := filepath.Join(root, ".reasonix", "settings.json")
18 if err := os.MkdirAll(filepath.Dir(projectSettings), 0o755); err != nil {
19 t.Fatal(err)
20 }
21 if err := os.WriteFile(projectSettings, []byte(`{"hooks":{"PreToolUse":[{"match":"bash","command":"printf PRIVATE_COMMAND"}]}}`), 0o644); err != nil {
22 t.Fatal(err)
23 }
24 globalSettings := filepath.Join(home, ".reasonix", "settings.json")
25 if err := os.MkdirAll(filepath.Dir(globalSettings), 0o755); err != nil {
26 t.Fatal(err)
27 }
28 if err := os.WriteFile(globalSettings, []byte(`{"hooks":{"Stop":[{"command":"PRIVATE_GLOBAL_COMMAND"}]}}`), 0o644); err != nil {
29 t.Fatal(err)
30 }
31
32 var out bytes.Buffer
33 if code := runHookCommand([]string{"list", "--json", "--project-root", root, "--home-dir", home}, &out); code != 0 {
34 t.Fatalf("hook list exit code = %d, output = %s", code, out.String())
35 }
36 var response machineHookList
37 if err := json.Unmarshal(out.Bytes(), &response); err != nil {
38 t.Fatalf("decode hook list: %v", err)
39 }
40 if len(response.Hooks) != 2 {
41 t.Fatalf("hooks = %+v", response.Hooks)
42 }
43 if response.Hooks[0].Event != "PreToolUse" || response.Hooks[0].Status != "active" {
44 t.Errorf("project hook = %+v", response.Hooks[0])
45 }
46 if response.Hooks[1].Event != "Stop" || response.Hooks[1].Status != "active" {
47 t.Errorf("global hook = %+v", response.Hooks[1])
48 }
49 if strings.Contains(out.String(), "PRIVATE") || strings.Contains(out.String(), root) || strings.Contains(out.String(), home) {
50 t.Fatalf("hook output leaked private data: %s", out.String())
51 }
52 }
53
54 func TestHookMachineListReportsExecutability(t *testing.T) {
55 root := t.TempDir()
56 home := t.TempDir()
57 globalSettings := filepath.Join(home, ".reasonix", "settings.json")
58 if err := os.MkdirAll(filepath.Dir(globalSettings), 0o755); err != nil {
59 t.Fatal(err)
60 }
61 body := `{"hooks":{` +
62 `"PreToolUse":[{"match":"bash","command":""},{"match":"[","command":"PRIVATE_INVALID_MATCHER"}],` +
63 `"Stop":[{"match":"[","command":"PRIVATE_NON_TOOL"}],` +
64 `"UnknownEvent":[{"command":"PRIVATE_UNKNOWN_EVENT"}]` +
65 `}}`
66 if err := os.WriteFile(globalSettings, []byte(body), 0o644); err != nil {
67 t.Fatal(err)
68 }
69
70 var out bytes.Buffer
71 if code := runHookCommand([]string{"list", "--json", "--project-root", root, "--home-dir", home}, &out); code != 0 {
72 t.Fatalf("hook list exit code = %d, output = %s", code, out.String())
73 }
74 var response machineHookList
75 if err := json.Unmarshal(out.Bytes(), &response); err != nil {
76 t.Fatalf("decode hook list: %v", err)
77 }
78 statuses := map[string]string{}
79 for _, item := range response.Hooks {
80 statuses[item.Event+"|"+item.Match] = item.Status
81 }
82 want := map[string]string{
83 "PreToolUse|bash": "invalid",
84 "PreToolUse|[": "invalid",
85 "Stop|[": "active",
86 "UnknownEvent|*": "invalid",
87 }
88 if len(statuses) != len(want) {
89 t.Fatalf("statuses = %+v, want %+v", statuses, want)
90 }
91 for key, wantStatus := range want {
92 if got := statuses[key]; got != wantStatus {
93 t.Errorf("status[%q] = %q, want %q", key, got, wantStatus)
94 }
95 }
96 if strings.Contains(out.String(), "PRIVATE") {
97 t.Fatalf("hook output leaked command content: %s", out.String())
98 }
99 }
100
101 func TestHookMachineEntryStatusRejectsNonRegularContextFile(t *testing.T) {
102 contextDir := t.TempDir()
103 entry := hook.Entry{
104 Event: hook.SessionStart,
105 Scope: hook.ScopePlugin,
106 ContextFile: contextDir,
107 }
108 if got := machineHookEntryStatus(entry); got != "invalid" {
109 t.Fatalf("directory context status = %q, want invalid", got)
110 }
111
112 contextFile := filepath.Join(contextDir, "context.md")
113 if err := os.WriteFile(contextFile, []byte("plugin context"), 0o600); err != nil {
114 t.Fatal(err)
115 }
116 entry.ContextFile = contextFile
117 if got := machineHookEntryStatus(entry); got != "active" {
118 t.Fatalf("readable regular context status = %q, want active", got)
119 }
120 }
121
122 func TestHookMachineStatusHasStableRedactedSources(t *testing.T) {
123 root := t.TempDir()
124 home := t.TempDir()
125 var out bytes.Buffer
126 if code := runHookCommand([]string{"status", "--json", "--project-root", root, "--home-dir", home}, &out); code != 0 {
127 t.Fatalf("hook status exit code = %d, output = %s", code, out.String())
128 }
129 var response machineHookStatus
130 if err := json.Unmarshal(out.Bytes(), &response); err != nil {
131 t.Fatalf("decode hook status: %v", err)
132 }
133 if response.SchemaVersion != machineSchemaVersion || response.Command != "hook.status" || !response.TrustedProject {
134 t.Fatalf("status = %+v", response)
135 }
136 if len(response.Sources) != 2 {
137 t.Fatalf("sources = %+v", response.Sources)
138 }
139 if response.Sources[0].Scope != "global" || response.Sources[1].Scope != "project" {
140 t.Fatalf("sources are not stable: %+v", response.Sources)
141 }
142 }
143
144 // TestHookMachineListFindsGlobalHooksWithoutHomeDir: with no --home-dir, the
145 // hook machine resolves global hooks from the platform Reasonix home
146 // (config.ReasonixHomeDir → settings.json), not a doubled .reasonix segment
147 // (#7420). REASONIX_HOME is set explicitly so the resolved path is
148 // deterministic regardless of the OS user-config lookup.
149 func TestHookMachineListFindsGlobalHooksWithoutHomeDir(t *testing.T) {
150 home := t.TempDir()
151 t.Setenv("REASONIX_HOME", home)
152 settingsPath := filepath.Join(home, "settings.json")
153 if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil {
154 t.Fatal(err)
155 }
156 if err := os.WriteFile(settingsPath, []byte(`{"hooks":{"Stop":[{"command":"PRIVATE_GLOBAL"}]}}`), 0o644); err != nil {
157 t.Fatal(err)
158 }
159
160 var out bytes.Buffer
161 if code := runHookCommand([]string{"list", "--json", "--project-root", t.TempDir()}, &out); code != 0 {
162 t.Fatalf("hook list exit code = %d, output = %s", code, out.String())
163 }
164 var response machineHookList
165 if err := json.Unmarshal(out.Bytes(), &response); err != nil {
166 t.Fatalf("decode hook list: %v", err)
167 }
168 for _, h := range response.Hooks {
169 if h.Event == "Stop" && h.Status == "active" {
170 return
171 }
172 }
173 t.Fatalf("global Stop hook not found active: %+v", response.Hooks)
174 }
175
175 lines GO