| 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 |