1package hooks
2
3import (
4 "context"
5 "strings"
6 "testing"
7 "time"
8
9 "github.com/charmbracelet/crush/internal/config"
10 "github.com/stretchr/testify/require"
11)
12
13func TestAggregation(t *testing.T) {
14 t.Parallel()
15
16 t.Run("empty results", func(t *testing.T) {
17 t.Parallel()
18 agg := aggregate(nil, "{}")
19 require.Equal(t, DecisionNone, agg.Decision)
20 require.Empty(t, agg.Reason)
21 require.Empty(t, agg.Context)
22 require.False(t, agg.Halt)
23 })
24
25 t.Run("single allow", func(t *testing.T) {
26 t.Parallel()
27 agg := aggregate([]HookResult{
28 {Decision: DecisionAllow},
29 }, "{}")
30 require.Equal(t, DecisionAllow, agg.Decision)
31 })
32
33 t.Run("deny wins over allow", func(t *testing.T) {
34 t.Parallel()
35 agg := aggregate([]HookResult{
36 {Decision: DecisionAllow, Context: "ctx1"},
37 {Decision: DecisionDeny, Reason: "blocked"},
38 }, "{}")
39 require.Equal(t, DecisionDeny, agg.Decision)
40 require.Equal(t, "blocked", agg.Reason)
41 require.Equal(t, "ctx1", agg.Context)
42 })
43
44 t.Run("multiple deny reasons concatenated", func(t *testing.T) {
45 t.Parallel()
46 agg := aggregate([]HookResult{
47 {Decision: DecisionDeny, Reason: "reason1"},
48 {Decision: DecisionDeny, Reason: "reason2"},
49 }, "{}")
50 require.Equal(t, DecisionDeny, agg.Decision)
51 require.Equal(t, "reason1\nreason2", agg.Reason)
52 })
53
54 t.Run("context concatenated from all hooks", func(t *testing.T) {
55 t.Parallel()
56 agg := aggregate([]HookResult{
57 {Decision: DecisionAllow, Context: "ctx-a"},
58 {Decision: DecisionNone, Context: "ctx-b"},
59 }, "{}")
60 require.Equal(t, DecisionAllow, agg.Decision)
61 require.Equal(t, "ctx-a\nctx-b", agg.Context)
62 })
63
64 t.Run("allow wins over none", func(t *testing.T) {
65 t.Parallel()
66 agg := aggregate([]HookResult{
67 {Decision: DecisionNone},
68 {Decision: DecisionAllow},
69 }, "{}")
70 require.Equal(t, DecisionAllow, agg.Decision)
71 })
72
73 t.Run("halt is sticky across results", func(t *testing.T) {
74 t.Parallel()
75 agg := aggregate([]HookResult{
76 {Decision: DecisionAllow},
77 {Halt: true, Reason: "stop now"},
78 }, "{}")
79 require.True(t, agg.Halt)
80 require.Contains(t, agg.Reason, "stop now")
81 })
82
83 t.Run("halt with deny only records reason once", func(t *testing.T) {
84 t.Parallel()
85 agg := aggregate([]HookResult{
86 {Decision: DecisionDeny, Halt: true, Reason: "stop"},
87 }, "{}")
88 require.True(t, agg.Halt)
89 require.Equal(t, DecisionDeny, agg.Decision)
90 require.Equal(t, "stop", agg.Reason)
91 })
92}
93
94func TestParseStdout(t *testing.T) {
95 t.Parallel()
96
97 t.Run("empty stdout", func(t *testing.T) {
98 t.Parallel()
99 r := parseStdout("")
100 require.Equal(t, DecisionNone, r.Decision)
101 })
102
103 t.Run("valid allow", func(t *testing.T) {
104 t.Parallel()
105 r := parseStdout(`{"decision":"allow","context":"some context"}`)
106 require.Equal(t, DecisionAllow, r.Decision)
107 require.Equal(t, "some context", r.Context)
108 })
109
110 t.Run("valid deny", func(t *testing.T) {
111 t.Parallel()
112 r := parseStdout(`{"decision":"deny","reason":"not allowed"}`)
113 require.Equal(t, DecisionDeny, r.Decision)
114 require.Equal(t, "not allowed", r.Reason)
115 })
116
117 t.Run("malformed JSON", func(t *testing.T) {
118 t.Parallel()
119 r := parseStdout(`{bad json}`)
120 require.Equal(t, DecisionNone, r.Decision)
121 })
122
123 t.Run("unknown decision", func(t *testing.T) {
124 t.Parallel()
125 r := parseStdout(`{"decision":"maybe"}`)
126 require.Equal(t, DecisionNone, r.Decision)
127 })
128
129 t.Run("version 1 accepted", func(t *testing.T) {
130 t.Parallel()
131 r := parseStdout(`{"version":1,"decision":"allow"}`)
132 require.Equal(t, DecisionAllow, r.Decision)
133 })
134
135 t.Run("unknown higher version still parses", func(t *testing.T) {
136 t.Parallel()
137 r := parseStdout(`{"version":99,"decision":"deny","reason":"future"}`)
138 require.Equal(t, DecisionDeny, r.Decision)
139 require.Equal(t, "future", r.Reason)
140 })
141
142 t.Run("halt true without decision", func(t *testing.T) {
143 t.Parallel()
144 r := parseStdout(`{"halt":true,"reason":"turn over"}`)
145 require.True(t, r.Halt)
146 require.Equal(t, "turn over", r.Reason)
147 require.Equal(t, DecisionNone, r.Decision)
148 })
149
150 t.Run("context string form", func(t *testing.T) {
151 t.Parallel()
152 r := parseStdout(`{"decision":"allow","context":"one note"}`)
153 require.Equal(t, "one note", r.Context)
154 })
155
156 t.Run("context array form", func(t *testing.T) {
157 t.Parallel()
158 r := parseStdout(`{"decision":"allow","context":["first","second"]}`)
159 require.Equal(t, "first\nsecond", r.Context)
160 })
161
162 t.Run("context array drops empty entries", func(t *testing.T) {
163 t.Parallel()
164 r := parseStdout(`{"decision":"allow","context":["","keep",""]}`)
165 require.Equal(t, "keep", r.Context)
166 })
167
168 t.Run("context null becomes empty", func(t *testing.T) {
169 t.Parallel()
170 r := parseStdout(`{"decision":"allow","context":null}`)
171 require.Empty(t, r.Context)
172 })
173}
174
175func TestBuildEnv(t *testing.T) {
176 t.Parallel()
177
178 env := BuildEnv(EventPreToolUse, "bash", "sess-1", "/work", "/project", `{"command":"ls","file_path":"/tmp/f.txt"}`)
179
180 envMap := make(map[string]string)
181 for _, e := range env {
182 parts := splitFirst(e, "=")
183 if len(parts) == 2 {
184 envMap[parts[0]] = parts[1]
185 }
186 }
187
188 require.Equal(t, EventPreToolUse, envMap["CRUSH_EVENT"])
189 require.Equal(t, "bash", envMap["CRUSH_TOOL_NAME"])
190 require.Equal(t, "sess-1", envMap["CRUSH_SESSION_ID"])
191 require.Equal(t, "/work", envMap["CRUSH_CWD"])
192 require.Equal(t, "/project", envMap["CRUSH_PROJECT_DIR"])
193 require.Equal(t, "ls", envMap["CRUSH_TOOL_INPUT_COMMAND"])
194 require.Equal(t, "/tmp/f.txt", envMap["CRUSH_TOOL_INPUT_FILE_PATH"])
195}
196
197func splitFirst(s, sep string) []string {
198 before, after, found := strings.Cut(s, sep)
199 if !found {
200 return []string{s}
201 }
202 return []string{before, after}
203}
204
205func TestBuildPayload(t *testing.T) {
206 t.Parallel()
207 payload := BuildPayload(EventPreToolUse, "sess-1", "/work", "bash", `{"command":"ls"}`)
208 s := string(payload)
209 require.Contains(t, s, `"event":"`+EventPreToolUse+`"`)
210 require.Contains(t, s, `"tool_name":"bash"`)
211 // tool_input should be an object, not a string.
212 require.Contains(t, s, `"tool_input":{"command":"ls"}`)
213}
214
215func TestRunnerExitCode0Allow(t *testing.T) {
216 t.Parallel()
217 hookCfg := config.HookConfig{
218 Command: `echo '{"decision":"allow","context":"ok"}'`,
219 }
220 r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
221 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
222 require.NoError(t, err)
223 require.Equal(t, DecisionAllow, result.Decision)
224 require.Equal(t, "ok", result.Context)
225}
226
227func TestRunnerExitCode2Deny(t *testing.T) {
228 t.Parallel()
229 hookCfg := config.HookConfig{
230 Command: `echo "forbidden" >&2; exit 2`,
231 }
232 r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
233 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
234 require.NoError(t, err)
235 require.Equal(t, DecisionDeny, result.Decision)
236 require.False(t, result.Halt)
237 require.Equal(t, "forbidden", result.Reason)
238}
239
240func TestRunnerExitCode49Halt(t *testing.T) {
241 t.Parallel()
242 hookCfg := config.HookConfig{
243 Command: `echo "stop the turn" >&2; exit 49`,
244 }
245 r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
246 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
247 require.NoError(t, err)
248 require.True(t, result.Halt)
249 require.Equal(t, DecisionDeny, result.Decision)
250 require.Equal(t, "stop the turn", result.Reason)
251}
252
253func TestRunnerHaltViaJSON(t *testing.T) {
254 t.Parallel()
255 hookCfg := config.HookConfig{
256 Command: `echo '{"halt":true,"reason":"via json"}'`,
257 }
258 r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
259 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
260 require.NoError(t, err)
261 require.True(t, result.Halt)
262 require.Equal(t, "via json", result.Reason)
263}
264
265func TestRunnerExitCodeOtherNonBlocking(t *testing.T) {
266 t.Parallel()
267 hookCfg := config.HookConfig{
268 Command: `exit 1`,
269 }
270 r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
271 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
272 require.NoError(t, err)
273 require.Equal(t, DecisionNone, result.Decision)
274}
275
276func TestRunnerTimeout(t *testing.T) {
277 t.Parallel()
278 hookCfg := config.HookConfig{
279 Command: `sleep 10`,
280 Timeout: 1,
281 }
282 r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
283 start := time.Now()
284 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
285 elapsed := time.Since(start)
286 require.NoError(t, err)
287 require.Equal(t, DecisionNone, result.Decision)
288 require.Less(t, elapsed, 5*time.Second)
289}
290
291func TestRunnerDeduplication(t *testing.T) {
292 t.Parallel()
293 // Two hooks with the same command should only run once.
294 hookCfg := config.HookConfig{
295 Command: `echo '{"decision":"allow"}'`,
296 }
297 r := NewRunner([]config.HookConfig{hookCfg, hookCfg}, t.TempDir(), t.TempDir())
298 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
299 require.NoError(t, err)
300 require.Equal(t, DecisionAllow, result.Decision)
301}
302
303func TestRunnerNoMatchingHooks(t *testing.T) {
304 t.Parallel()
305 // Hooks are empty.
306 r := NewRunner(nil, t.TempDir(), t.TempDir())
307 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
308 require.NoError(t, err)
309 require.Equal(t, DecisionNone, result.Decision)
310}
311
312// validatedHooks builds hook configs and runs ValidateHooks to compile
313// matcher regexes, mirroring the real config-load path.
314func validatedHooks(t *testing.T, hooks []config.HookConfig) []config.HookConfig {
315 t.Helper()
316 cfg := &config.Config{
317 Hooks: map[string][]config.HookConfig{
318 EventPreToolUse: hooks,
319 },
320 }
321 require.NoError(t, cfg.ValidateHooks())
322 return cfg.Hooks[EventPreToolUse]
323}
324
325func TestRunnerMatcherFiltering(t *testing.T) {
326 t.Parallel()
327
328 t.Run("compiled regex matches", func(t *testing.T) {
329 t.Parallel()
330 hooks := validatedHooks(t, []config.HookConfig{
331 {Command: `echo '{"decision":"deny","reason":"blocked"}'`, Matcher: "^bash$"},
332 })
333 r := NewRunner(hooks, t.TempDir(), t.TempDir())
334 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
335 require.NoError(t, err)
336 require.Equal(t, DecisionDeny, result.Decision)
337 })
338
339 t.Run("compiled regex does not match", func(t *testing.T) {
340 t.Parallel()
341 hooks := validatedHooks(t, []config.HookConfig{
342 {Command: `echo '{"decision":"deny","reason":"blocked"}'`, Matcher: "^edit$"},
343 })
344 r := NewRunner(hooks, t.TempDir(), t.TempDir())
345 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
346 require.NoError(t, err)
347 require.Equal(t, DecisionNone, result.Decision)
348 })
349
350 t.Run("no matcher matches everything", func(t *testing.T) {
351 t.Parallel()
352 hooks := validatedHooks(t, []config.HookConfig{
353 {Command: `echo '{"decision":"allow"}'`},
354 })
355 r := NewRunner(hooks, t.TempDir(), t.TempDir())
356 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
357 require.NoError(t, err)
358 require.Equal(t, DecisionAllow, result.Decision)
359 })
360
361 t.Run("partial regex match", func(t *testing.T) {
362 t.Parallel()
363 hooks := validatedHooks(t, []config.HookConfig{
364 {Command: `echo '{"decision":"deny","reason":"mcp blocked"}'`, Matcher: "^mcp_"},
365 })
366 r := NewRunner(hooks, t.TempDir(), t.TempDir())
367
368 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "mcp_github_get_me", `{}`)
369 require.NoError(t, err)
370 require.Equal(t, DecisionDeny, result.Decision)
371
372 result, err = r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
373 require.NoError(t, err)
374 require.Equal(t, DecisionNone, result.Decision)
375 })
376}
377
378func TestValidateHooksInvalidRegex(t *testing.T) {
379 t.Parallel()
380 cfg := &config.Config{
381 Hooks: map[string][]config.HookConfig{
382 EventPreToolUse: {
383 {Command: "true", Matcher: "[invalid"},
384 },
385 },
386 }
387 err := cfg.ValidateHooks()
388 require.Error(t, err)
389 require.Contains(t, err.Error(), "invalid matcher regex")
390}
391
392func TestValidateHooksEmptyCommand(t *testing.T) {
393 t.Parallel()
394 cfg := &config.Config{
395 Hooks: map[string][]config.HookConfig{
396 EventPreToolUse: {
397 {Command: ""},
398 },
399 },
400 }
401 err := cfg.ValidateHooks()
402 require.Error(t, err)
403 require.Contains(t, err.Error(), "command is required")
404}
405
406func TestValidateHooksNormalizesEventNames(t *testing.T) {
407 t.Parallel()
408
409 tests := []struct {
410 name string
411 input string
412 }{
413 {"canonical", "PreToolUse"},
414 {"lowercase", "pretooluse"},
415 {"snake_case", "pre_tool_use"},
416 {"upper_snake", "PRE_TOOL_USE"},
417 {"mixed_case", "preToolUse"},
418 }
419 for _, tt := range tests {
420 t.Run(tt.name, func(t *testing.T) {
421 t.Parallel()
422 cfg := &config.Config{
423 Hooks: map[string][]config.HookConfig{
424 tt.input: {
425 {Command: "true"},
426 },
427 },
428 }
429 require.NoError(t, cfg.ValidateHooks())
430 require.Len(t, cfg.Hooks[EventPreToolUse], 1)
431 })
432 }
433}
434
435func TestRunnerParallelExecution(t *testing.T) {
436 t.Parallel()
437 // Two hooks: one allows, one denies. Deny should win.
438 hooks := []config.HookConfig{
439 {Command: `echo '{"decision":"allow","context":"hook1"}'`},
440 {Command: `echo '{"decision":"deny","reason":"nope"}' ; exit 0`},
441 }
442 r := NewRunner(hooks, t.TempDir(), t.TempDir())
443 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
444 require.NoError(t, err)
445 require.Equal(t, DecisionDeny, result.Decision)
446 require.Equal(t, "nope", result.Reason)
447}
448
449func TestRunnerEnvVarsPropagated(t *testing.T) {
450 t.Parallel()
451 hookCfg := config.HookConfig{
452 Command: `printf '{"decision":"allow","context":"%s"}' "$CRUSH_TOOL_NAME"`,
453 }
454 r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
455 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
456 require.NoError(t, err)
457 require.Equal(t, DecisionAllow, result.Decision)
458 require.Equal(t, "bash", result.Context)
459}
460
461func TestParseStdoutUpdatedInput(t *testing.T) {
462 t.Parallel()
463
464 t.Run("nested object", func(t *testing.T) {
465 t.Parallel()
466 r := parseStdout(`{"decision":"allow","updated_input":{"command":"rtk cat foo.go"}}`)
467 require.Equal(t, DecisionAllow, r.Decision)
468 require.Equal(t, `{"command":"rtk cat foo.go"}`, r.UpdatedInput)
469 })
470
471 t.Run("stringified backward compat", func(t *testing.T) {
472 t.Parallel()
473 r := parseStdout(`{"decision":"allow","updated_input":"{\"command\":\"rtk cat foo.go\"}"}`)
474 require.Equal(t, DecisionAllow, r.Decision)
475 require.Equal(t, `{"command":"rtk cat foo.go"}`, r.UpdatedInput)
476 })
477
478 t.Run("no updated_input", func(t *testing.T) {
479 t.Parallel()
480 r := parseStdout(`{"decision":"allow"}`)
481 require.Empty(t, r.UpdatedInput)
482 })
483}
484
485func TestAggregationUpdatedInput(t *testing.T) {
486 t.Parallel()
487
488 t.Run("patches merge in config order with later overriding", func(t *testing.T) {
489 t.Parallel()
490 agg := aggregate([]HookResult{
491 {Decision: DecisionAllow, UpdatedInput: `{"command":"first","keep":"me"}`},
492 {Decision: DecisionAllow, UpdatedInput: `{"command":"second"}`},
493 }, `{"command":"orig","timeout":60}`)
494 require.Equal(t, DecisionAllow, agg.Decision)
495 // command overridden by second patch; keep preserved from first
496 // patch; timeout preserved from original input.
497 require.JSONEq(t,
498 `{"command":"second","keep":"me","timeout":60}`,
499 agg.UpdatedInput,
500 )
501 })
502
503 t.Run("shallow: nested objects are replaced wholesale", func(t *testing.T) {
504 t.Parallel()
505 agg := aggregate([]HookResult{
506 {Decision: DecisionAllow, UpdatedInput: `{"env":{"FOO":"bar"}}`},
507 }, `{"env":{"BAZ":"qux"},"command":"ls"}`)
508 // "env" is replaced entirely; "command" preserved.
509 require.JSONEq(t,
510 `{"env":{"FOO":"bar"},"command":"ls"}`,
511 agg.UpdatedInput,
512 )
513 })
514
515 t.Run("deny still reports merged input (caller ignores it)", func(t *testing.T) {
516 t.Parallel()
517 agg := aggregate([]HookResult{
518 {Decision: DecisionAllow, UpdatedInput: `{"command":"rewritten"}`},
519 {Decision: DecisionDeny, Reason: "blocked"},
520 }, `{"command":"orig"}`)
521 require.Equal(t, DecisionDeny, agg.Decision)
522 })
523
524 t.Run("no patches leaves updated_input empty", func(t *testing.T) {
525 t.Parallel()
526 agg := aggregate([]HookResult{
527 {Decision: DecisionAllow},
528 {Decision: DecisionNone},
529 }, `{"command":"orig"}`)
530 require.Empty(t, agg.UpdatedInput)
531 })
532
533 t.Run("invalid patch is ignored", func(t *testing.T) {
534 t.Parallel()
535 agg := aggregate([]HookResult{
536 {Decision: DecisionAllow, UpdatedInput: `"not-an-object"`},
537 {Decision: DecisionAllow, UpdatedInput: `{"command":"good"}`},
538 }, `{"command":"orig"}`)
539 require.JSONEq(t, `{"command":"good"}`, agg.UpdatedInput)
540 })
541
542 t.Run("malformed patch JSON is ignored and merge continues", func(t *testing.T) {
543 t.Parallel()
544 agg := aggregate([]HookResult{
545 {Decision: DecisionAllow, UpdatedInput: `{broken json`},
546 {Decision: DecisionAllow, UpdatedInput: `{"command":"good"}`},
547 }, `{"command":"orig"}`)
548 require.JSONEq(t, `{"command":"good"}`, agg.UpdatedInput)
549 })
550
551 t.Run("non-object tool_input rejects all patches", func(t *testing.T) {
552 t.Parallel()
553 agg := aggregate([]HookResult{
554 {Decision: DecisionAllow, UpdatedInput: `{"command":"rewrite"}`},
555 }, `"just-a-string"`)
556 require.Empty(t, agg.UpdatedInput)
557 })
558
559 t.Run("null updated_input is a no-op", func(t *testing.T) {
560 t.Parallel()
561 // parseStdout converts null updated_input to "", so aggregate
562 // never sees a patch — the merged input is empty and the
563 // original tool_input is used unchanged.
564 r := parseStdout(`{"decision":"allow","updated_input":null}`)
565 require.Empty(t, r.UpdatedInput)
566 agg := aggregate([]HookResult{r}, `{"command":"orig"}`)
567 require.Empty(t, agg.UpdatedInput)
568 })
569}
570
571func TestRunnerUpdatedInput(t *testing.T) {
572 t.Parallel()
573 hookCfg := config.HookConfig{
574 Command: `echo '{"decision":"allow","updated_input":{"command":"echo rewritten"}}'`,
575 }
576 r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
577 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{"command":"echo original","timeout":60}`)
578 require.NoError(t, err)
579 require.Equal(t, DecisionAllow, result.Decision)
580 require.JSONEq(t,
581 `{"command":"echo rewritten","timeout":60}`,
582 result.UpdatedInput,
583 )
584}
585
586func TestParseStdoutClaudeCodeFormat(t *testing.T) {
587 t.Parallel()
588
589 t.Run("allow with reason", func(t *testing.T) {
590 t.Parallel()
591 r := parseStdout(`{"hookSpecificOutput":{"permissionDecision":"allow","permissionDecisionReason":"RTK auto-rewrite"}}`)
592 require.Equal(t, DecisionAllow, r.Decision)
593 require.Equal(t, "RTK auto-rewrite", r.Reason)
594 })
595
596 t.Run("allow with updatedInput", func(t *testing.T) {
597 t.Parallel()
598 r := parseStdout(`{"hookSpecificOutput":{"permissionDecision":"allow","updatedInput":{"command":"rtk cat foo.go"}}}`)
599 require.Equal(t, DecisionAllow, r.Decision)
600 require.Equal(t, `{"command":"rtk cat foo.go"}`, r.UpdatedInput)
601 })
602
603 t.Run("deny", func(t *testing.T) {
604 t.Parallel()
605 r := parseStdout(`{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"not allowed"}}`)
606 require.Equal(t, DecisionDeny, r.Decision)
607 require.Equal(t, "not allowed", r.Reason)
608 })
609
610 t.Run("no permissionDecision", func(t *testing.T) {
611 t.Parallel()
612 r := parseStdout(`{"hookSpecificOutput":{}}`)
613 require.Equal(t, DecisionNone, r.Decision)
614 })
615
616 t.Run("crush format still works", func(t *testing.T) {
617 t.Parallel()
618 r := parseStdout(`{"decision":"allow","context":"hello"}`)
619 require.Equal(t, DecisionAllow, r.Decision)
620 require.Equal(t, "hello", r.Context)
621 })
622}