返回 DeepSeek-Reasonix
project_approval_test.go
根目录 / internal / hook / project_approval_test.go
1 package hook
2
3 import (
4 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "runtime"
9 "slices"
10 "testing"
11
12 "reasonix/internal/config"
13 )
14
15 // A checkout's hooks run on the first event of the first session, so they wait
16 // for the user and wait again whenever what they would run changes.
17 func TestProjectHooksWaitForApprovalOfTheirCurrentContent(t *testing.T) {
18 home := t.TempDir()
19 proj := t.TempDir()
20 opts := LoadOptions{ProjectRoot: proj, HomeDir: home}
21 script := filepath.Join(proj, "scripts", "start.sh")
22 if err := os.MkdirAll(filepath.Dir(script), 0o700); err != nil {
23 t.Fatal(err)
24 }
25 if err := os.WriteFile(script, []byte("echo one\n"), 0o700); err != nil {
26 t.Fatal(err)
27 }
28 writeSettings(t, proj, `{"hooks":{"SessionStart":[{"command":"sh scripts/start.sh"}]}}`)
29
30 if got := Load(opts); len(got) != 0 {
31 t.Fatalf("unapproved project hooks loaded: %+v", got)
32 }
33 if _, pending := PendingProjectHooks(opts); !pending {
34 t.Fatal("unapproved project hooks not reported as pending")
35 }
36 insp := Inspect(opts)
37 if len(insp.Entries) != 1 || !slices.Contains(insp.Entries[0].Issues, IssueAwaitingApproval) {
38 t.Fatalf("inspection = %+v, want the hook marked awaiting approval", insp.Entries)
39 }
40
41 approveProjectHooks(t, opts)
42 if got := Load(opts); len(got) != 1 {
43 t.Fatalf("approved project hooks = %+v, want one", got)
44 }
45
46 if err := os.WriteFile(script, []byte("echo two\n"), 0o700); err != nil {
47 t.Fatal(err)
48 }
49 if got := Load(opts); len(got) != 0 {
50 t.Fatalf("hooks ran after the script they name changed: %+v", got)
51 }
52 approveProjectHooks(t, opts)
53 writeSettings(t, proj, `{"hooks":{"SessionStart":[{"command":"sh scripts/start.sh --more"}]}}`)
54 if got := Load(opts); len(got) != 0 {
55 t.Fatalf("hooks ran after their declaration changed: %+v", got)
56 }
57 }
58
59 // An approval belongs to one workspace; the same file elsewhere waits again.
60 func TestProjectHooksApprovalIsPerWorkspace(t *testing.T) {
61 home := t.TempDir()
62 a, b := t.TempDir(), t.TempDir()
63 for _, proj := range []string{a, b} {
64 writeSettings(t, proj, sampleSettings)
65 }
66 approveProjectHooks(t, LoadOptions{ProjectRoot: a, HomeDir: home})
67 if got := Load(LoadOptions{ProjectRoot: b, HomeDir: home}); len(got) != 0 {
68 t.Fatalf("approval leaked to another workspace: %+v", got)
69 }
70 }
71
72 func approveProjectHooks(t *testing.T, opts LoadOptions) {
73 t.Helper()
74 if err := ApproveProjectHooks(opts); err != nil {
75 t.Fatal(err)
76 }
77 }
78
79 func TestLoadProjectHooksOnceApproved(t *testing.T) {
80 home := t.TempDir()
81 proj := t.TempDir()
82 writeSettings(t, proj, sampleSettings)
83 writeSettings(t, home, `{"hooks":{"PostToolUse":[{"command":"echo g"}]}}`)
84
85 if got := Load(LoadOptions{ProjectRoot: proj, HomeDir: home}); len(got) != 1 || got[0].Scope != ScopeGlobal {
86 t.Fatalf("unapproved project hooks loaded: %+v", got)
87 }
88 approveProjectHooks(t, LoadOptions{ProjectRoot: proj, HomeDir: home})
89 got := Load(LoadOptions{ProjectRoot: proj, HomeDir: home})
90 if len(got) != 3 {
91 t.Fatalf("default load should include project + global, got %d", len(got))
92 }
93 if got[0].Scope != ScopeProject {
94 t.Errorf("project hooks should sort first, got %s", got[0].Scope)
95 }
96 }
97
98 // An approval is checked again when the hook fires: a script edited after the
99 // session loaded is refused, with the reason typed, and never spawned.
100 func TestApprovedProjectHookIsRefusedAfterItsScriptChanges(t *testing.T) {
101 home := t.TempDir()
102 proj := t.TempDir()
103 opts := LoadOptions{ProjectRoot: proj, HomeDir: home}
104 script := filepath.Join(proj, "scripts", "check.sh")
105 if err := os.MkdirAll(filepath.Dir(script), 0o700); err != nil {
106 t.Fatal(err)
107 }
108 if err := os.WriteFile(script, []byte("echo one\n"), 0o700); err != nil {
109 t.Fatal(err)
110 }
111 writeSettings(t, proj, `{"hooks":{"Stop":[{"command":"sh scripts/check.sh"}]}}`)
112 approveProjectHooks(t, opts)
113 hooks := Load(opts)
114 if len(hooks) != 1 {
115 t.Fatalf("approved hooks = %+v", hooks)
116 }
117 spawned := 0
118 spawner := func(context.Context, SpawnInput) SpawnResult { spawned++; return SpawnResult{} }
119 if rep := Run(context.Background(), Payload{Event: Stop, Cwd: proj}, hooks, spawner); spawned != 1 || rep.Outcomes[0].Refusal != nil {
120 t.Fatalf("unchanged hook: spawned %d, report %+v", spawned, rep)
121 }
122 if err := os.WriteFile(script, []byte("echo two\n"), 0o700); err != nil {
123 t.Fatal(err)
124 }
125 rep := Run(context.Background(), Payload{Event: Stop, Cwd: proj}, hooks, spawner)
126 if spawned != 1 || len(rep.Outcomes) != 1 || !errors.Is(rep.Outcomes[0].Refusal, config.ErrProjectProgramChanged) {
127 t.Fatalf("changed hook: spawned %d, report %+v", spawned, rep)
128 }
129 }
130
131 // A script missing when the hooks were approved is still covered: creating it
132 // afterwards is a change, not an approved file.
133 func TestProjectHookNamingAMissingScriptIsRefusedOnceItAppears(t *testing.T) {
134 home := t.TempDir()
135 proj := t.TempDir()
136 opts := LoadOptions{ProjectRoot: proj, HomeDir: home}
137 writeSettings(t, proj, `{"hooks":{"Stop":[{"command":"sh scripts/later.sh"}]}}`)
138 approveProjectHooks(t, opts)
139 hooks := Load(opts)
140 script := filepath.Join(proj, "scripts", "later.sh")
141 if err := os.MkdirAll(filepath.Dir(script), 0o700); err != nil {
142 t.Fatal(err)
143 }
144 if err := os.WriteFile(script, []byte("echo planted\n"), 0o700); err != nil {
145 t.Fatal(err)
146 }
147 spawned := 0
148 rep := Run(context.Background(), Payload{Event: Stop, Cwd: proj}, hooks, func(context.Context, SpawnInput) SpawnResult { spawned++; return SpawnResult{} })
149 if spawned != 0 || len(rep.Outcomes) != 1 || !errors.Is(rep.Outcomes[0].Refusal, config.ErrProjectProgramChanged) {
150 t.Fatalf("hook ran a script created after approval: spawned %d, report %+v", spawned, rep)
151 }
152 }
153
154 // A path that climbs out through a link is covered as the OS resolves it, not
155 // as its spelling cleans up to.
156 func TestProjectHookThroughALinkAndDotDotIsCovered(t *testing.T) {
157 if runtime.GOOS == "windows" {
158 t.Skip("symlink creation needs privileges on Windows")
159 }
160 home := t.TempDir()
161 proj := t.TempDir()
162 outside := filepath.Join(t.TempDir(), "sub")
163 if err := os.MkdirAll(outside, 0o700); err != nil {
164 t.Fatal(err)
165 }
166 if err := os.Symlink(outside, filepath.Join(proj, "ln")); err != nil {
167 t.Fatal(err)
168 }
169 target := filepath.Join(filepath.Dir(outside), "f.sh")
170 if err := os.WriteFile(target, []byte("echo one\n"), 0o700); err != nil {
171 t.Fatal(err)
172 }
173 opts := LoadOptions{ProjectRoot: proj, HomeDir: home}
174 writeSettings(t, proj, `{"hooks":{"Stop":[{"command":"sh ln/../f.sh"}]}}`)
175 approveProjectHooks(t, opts)
176 hooks := Load(opts)
177 if err := os.WriteFile(target, []byte("echo two\n"), 0o700); err != nil {
178 t.Fatal(err)
179 }
180 spawned := 0
181 rep := Run(context.Background(), Payload{Event: Stop, Cwd: proj}, hooks, func(context.Context, SpawnInput) SpawnResult { spawned++; return SpawnResult{} })
182 if spawned != 0 || !errors.Is(rep.Outcomes[0].Refusal, config.ErrProjectProgramChanged) {
183 t.Fatalf("a changed file reached through ln/.. ran: spawned %d, report %+v", spawned, rep)
184 }
185 }
186
186 lines GO