// plugin_hooks_test.go -- 插件级 Hook 注册的单元测试(模块 9.3). // // 覆盖场景: // - HookDef.Source 向后兼容(空字符串默认全局,不影响现有代码) // - UnregisterBySource 只移除指定来源,不影响全局和其他插件 // - UnregisterAllBySource 一次清除某插件在所有类型的 hooks // - 注册顺序决定执行顺序(全局 hooks 先于插件 hooks) // - 空 source 的边界情况(UnregisterBySource("") 移除全局 hooks) // - 幂等性(重复 Unregister 不 panic) package hooks import ( "context" "sync" "testing" "git.flytoex.net/yuanwei/flyto-agent/pkg/execenv" ) // TestHookDef_SourceBackwardCompatibility 验证 Source 字段向后兼容. // 现有不设 Source 的代码零值为 "",行为与之前完全一致. func TestHookDef_SourceBackwardCompatibility(t *testing.T) { def := HookDef{Command: "echo hello", Timeout: 10} if def.Source != "" { t.Errorf("Source 零值应为空字符串,实际 %q", def.Source) } // 注册不带 Source 的 hook 不应报错 m := NewManager(nil, execenv.DefaultExecutor{}) if err := m.Register(HookPreToolUse, def); err != nil { t.Fatalf("注册无 Source 的 hook 失败: %v", err) } if m.Count(HookPreToolUse) != 1 { t.Errorf("count 应为 1,实际 %d", m.Count(HookPreToolUse)) } } // TestUnregisterBySource_OnlyRemovesTargetSource 验证只移除目标来源. // // 注册全局 + 插件 A + 插件 B 的 hooks, // UnregisterBySource 插件 A 后,全局和插件 B 应保留. func TestUnregisterBySource_OnlyRemovesTargetSource(t *testing.T) { m := NewManager(nil, execenv.DefaultExecutor{}) // 注册三种来源 globalDef := HookDef{Command: "echo global", Source: ""} pluginADef := HookDef{Command: "echo plugin-a", Source: "plugin-a"} pluginBDef := HookDef{Command: "echo plugin-b", Source: "plugin-b"} _ = m.Register(HookPreToolUse, globalDef) _ = m.Register(HookPreToolUse, pluginADef) _ = m.Register(HookPreToolUse, pluginBDef) if m.Count(HookPreToolUse) != 3 { t.Fatalf("注册后应有 3 个 hook,实际 %d", m.Count(HookPreToolUse)) } // 只移除 plugin-a m.UnregisterBySource(HookPreToolUse, "plugin-a") if m.Count(HookPreToolUse) != 2 { t.Errorf("移除后应剩 2 个 hook,实际 %d", m.Count(HookPreToolUse)) } // 验证剩余 hooks 的来源 m.mu.RLock() remaining := m.hooks[HookPreToolUse] m.mu.RUnlock() for _, def := range remaining { if def.Source == "plugin-a" { t.Error("plugin-a 的 hook 应已被移除") } } sources := map[string]bool{} for _, def := range remaining { sources[def.Source] = true } if !sources[""] { t.Error("全局 hook(Source='')应保留") } if !sources["plugin-b"] { t.Error("plugin-b 的 hook 应保留") } } // TestUnregisterAllBySource_RemovesFromAllTypes 验证全类型批量移除. // // 插件 A 在多个 hook 类型注册了 hooks, // UnregisterAllBySource 后全部消失,其他插件不受影响. func TestUnregisterAllBySource_RemovesFromAllTypes(t *testing.T) { m := NewManager(nil, execenv.DefaultExecutor{}) // plugin-a 在三个类型注册 for _, ht := range []HookType{HookPreToolUse, HookSessionStart, HookStop} { _ = m.Register(ht, HookDef{Command: "echo a", Source: "plugin-a"}) } // plugin-b 也在其中一个类型注册(不应被移除) _ = m.Register(HookPreToolUse, HookDef{Command: "echo b", Source: "plugin-b"}) // 全局 hook _ = m.Register(HookPreToolUse, HookDef{Command: "echo global", Source: ""}) // 批量移除 plugin-a 的所有 hooks m.UnregisterAllBySource("plugin-a") // plugin-a 的三个类型全部消失 for _, ht := range []HookType{HookSessionStart, HookStop} { if m.Count(ht) != 0 { t.Errorf("hook type %s 应无 plugin-a 的 hook,实际 count=%d", ht, m.Count(ht)) } } // HookPreToolUse 还剩 plugin-b + 全局 = 2 个 if m.Count(HookPreToolUse) != 2 { t.Errorf("HookPreToolUse 应剩 2 个,实际 %d", m.Count(HookPreToolUse)) } } // TestUnregisterBySource_ExecutionOrder 验证全局 hooks 先于插件 hooks 执行. // // 这是模块 9.3 的核心安全属性:用户的安全拦截脚本先于插件 hooks 运行. // 通过注册顺序(全局先注册)自然保证,无需额外优先级字段. func TestUnregisterBySource_ExecutionOrder(t *testing.T) { m := NewManager(nil, execenv.DefaultExecutor{}) var mu sync.Mutex var order []string // 先注册全局 hook(模拟 Engine.New() 时加载 HooksConfig) _ = m.Register(HookPreToolUse, HookDef{ Source: "", Handler: HookHandlerFunc(func(ctx context.Context, ht HookType, env map[string]string) *HookResult { mu.Lock() order = append(order, "global") mu.Unlock() return &HookResult{ExitCode: 0} }), }) // 后注册插件 hook(模拟 syncPluginHooks()) _ = m.Register(HookPreToolUse, HookDef{ Source: "my-plugin", Handler: HookHandlerFunc(func(ctx context.Context, ht HookType, env map[string]string) *HookResult { mu.Lock() order = append(order, "plugin") mu.Unlock() return &HookResult{ExitCode: 0} }), }) _, _ = m.Execute(context.Background(), HookPreToolUse, map[string]string{}) mu.Lock() defer mu.Unlock() if len(order) != 2 { t.Fatalf("应执行 2 个 hook,实际 %d", len(order)) } if order[0] != "global" || order[1] != "plugin" { t.Errorf("执行顺序应为 [global, plugin],实际 %v", order) } } // TestUnregisterBySource_Idempotent 验证重复 Unregister 不 panic,幂等. func TestUnregisterBySource_Idempotent(t *testing.T) { m := NewManager(nil, execenv.DefaultExecutor{}) _ = m.Register(HookPreToolUse, HookDef{Command: "echo", Source: "plugin-a"}) // 第一次移除 m.UnregisterBySource(HookPreToolUse, "plugin-a") // 第二次移除--幂等,不应 panic m.UnregisterBySource(HookPreToolUse, "plugin-a") // 空类型--不应 panic m.UnregisterBySource(HookSessionStart, "plugin-a") // 不存在的来源--不应 panic m.UnregisterBySource(HookPreToolUse, "nonexistent") if m.Count(HookPreToolUse) != 0 { t.Errorf("移除后 count 应为 0,实际 %d", m.Count(HookPreToolUse)) } } // TestUnregisterAllBySource_Idempotent 验证 UnregisterAllBySource 幂等. func TestUnregisterAllBySource_Idempotent(t *testing.T) { m := NewManager(nil, execenv.DefaultExecutor{}) _ = m.Register(HookPreToolUse, HookDef{Command: "echo", Source: "p"}) m.UnregisterAllBySource("p") m.UnregisterAllBySource("p") // 幂等 m.UnregisterAllBySource("nobody") // 不存在的来源 } // TestUnregisterBySource_GlobalHooks 验证 source="" 可以移除全局 hooks. // // 这是 UnregisterBySource 对全局 hooks 的支持, // 行为和原来的 Unregister(hookType) 相同(但更精确:只移除该类型的全局). func TestUnregisterBySource_GlobalHooks(t *testing.T) { m := NewManager(nil, execenv.DefaultExecutor{}) _ = m.Register(HookPreToolUse, HookDef{Command: "echo global", Source: ""}) _ = m.Register(HookPreToolUse, HookDef{Command: "echo plugin", Source: "p"}) // 只移除全局 m.UnregisterBySource(HookPreToolUse, "") if m.Count(HookPreToolUse) != 1 { t.Errorf("应剩 1 个 hook(插件),实际 %d", m.Count(HookPreToolUse)) } m.mu.RLock() remaining := m.hooks[HookPreToolUse] m.mu.RUnlock() if remaining[0].Source != "p" { t.Errorf("剩余 hook 应是插件来源,实际 %q", remaining[0].Source) } } // TestUnregisterBySource_MapCleanup 验证移除后空 hook 类型从 map 中彻底删除. // // 确保 HasHooks() 对已清空的类型返回 false, // 且不在 map 中留下 len=0 的空 slice 浪费内存. func TestUnregisterBySource_MapCleanup(t *testing.T) { m := NewManager(nil, execenv.DefaultExecutor{}) _ = m.Register(HookSessionEnd, HookDef{Command: "echo", Source: "plugin-x"}) if !m.HasHooks(HookSessionEnd) { t.Fatal("注册后 HasHooks 应返回 true") } m.UnregisterBySource(HookSessionEnd, "plugin-x") if m.HasHooks(HookSessionEnd) { t.Error("清空后 HasHooks 应返回 false") } // 确认 map entry 已彻底删除(不只是 len=0 的空 slice) m.mu.RLock() _, exists := m.hooks[HookSessionEnd] m.mu.RUnlock() if exists { t.Error("map 中不应保留空 slice 的 key") } }