// executor_test.go -- runShellHook 的单元测试. // // 覆盖场景: // - 命令执行成功 // - 命令执行失败(非零退出码) // - 超时处理 // - JSON 输出解析 // - 环境变量注入 // - Plugin / User hook 的 env 策略分流 (L511 衍生) // - tryParseJSON 解析 package hooks import ( "context" "strings" "testing" "time" "git.flytoex.net/yuanwei/flyto-agent/pkg/execenv" ) // testExecutor 是测试用 execenv.Executor 实例. 每个测试都走 DefaultExecutor{}, // 它是 os/exec 的零开销包装, 和 M1 之前的直连 *exec.Cmd 行为 bit-identical. var testExecutor execenv.Executor = execenv.DefaultExecutor{} // TestExecutor_Success 测试命令成功执行 func TestExecutor_Success(t *testing.T) { result := runShellHook(context.Background(), testExecutor, HookDef{Command: "echo hello"}, nil) if !result.Success() { t.Errorf("应成功: %v", result.Error) } if result.ExitCode != 0 { t.Errorf("退出码应为 0, 实际: %d", result.ExitCode) } if result.Stdout == "" { t.Error("stdout 不应为空") } } // TestExecutor_Failure 测试命令失败 func TestExecutor_Failure(t *testing.T) { result := runShellHook(context.Background(), testExecutor, HookDef{Command: "exit 42"}, nil) if result.Success() { t.Error("应失败") } if result.ExitCode != 42 { t.Errorf("退出码应为 42, 实际: %d", result.ExitCode) } } // TestExecutor_Timeout 测试超时 func TestExecutor_Timeout(t *testing.T) { result := runShellHook(context.Background(), testExecutor, HookDef{Command: "sleep 10", Timeout: 1}, nil) if result.Success() { t.Error("超时应失败") } if result.ExitCode != -1 { t.Errorf("超时退出码应为 -1, 实际: %d", result.ExitCode) } if result.Error == nil { t.Error("应有超时错误") } } // TestExecutor_JSONOutput 测试 JSON 输出解析 func TestExecutor_JSONOutput(t *testing.T) { result := runShellHook(context.Background(), testExecutor, HookDef{Command: `echo '{"decision":"allow","reason":"auto"}'`}, nil) if result.JSONOutput == nil { t.Fatal("应解析出 JSON 输出") } if result.JSONOutput["decision"] != "allow" { t.Errorf("decision 应为 'allow', 实际: %v", result.JSONOutput["decision"]) } } // TestExecutor_NonJSONOutput 测试非 JSON 输出 func TestExecutor_NonJSONOutput(t *testing.T) { result := runShellHook(context.Background(), testExecutor, HookDef{Command: "echo just text"}, nil) if result.JSONOutput != nil { t.Error("非 JSON 输出不应被解析") } } // TestExecutor_EnvInjection 测试环境变量注入 func TestExecutor_EnvInjection(t *testing.T) { env := map[string]string{ "HOOK_TEST_VAR": "test_value", } result := runShellHook(context.Background(), testExecutor, HookDef{Command: "echo $HOOK_TEST_VAR"}, env) if !result.Success() { t.Fatalf("应成功: %v", result.Error) } if result.Stdout == "" { t.Error("stdout 不应为空") } } // TestExecutor_PluginHookEnvIsolation 验证 plugin-owned hook (PluginDir 非空) // 不继承宿主进程的敏感 env. // // 这是 L511 衍生修复的核心回归测试: 防止第三方 plugin 作者的 hook 脚本读到 // Flyto 主进程的 ANTHROPIC_API_KEY 等秘密. 设计决策见 executor.go 的 env // 策略分流段落. func TestExecutor_PluginHookEnvIsolation(t *testing.T) { // 在父进程设置一个模拟敏感 env, 不在 execenv 白名单里. t.Setenv("FLYTO_TEST_SECRET", "leaked-api-key-12345") result := runShellHook(context.Background(), testExecutor, HookDef{ Command: "echo \"secret=[$FLYTO_TEST_SECRET]\"", PluginDir: "/tmp/fake-plugin-root", }, nil, ) if !result.Success() { t.Fatalf("hook 应成功: %v", result.Error) } // 子进程里这个变量应为空 (未继承), 输出应为 "secret=[]". if !strings.Contains(result.Stdout, "secret=[]") { t.Errorf("plugin hook 不应继承 FLYTO_TEST_SECRET, 实际 stdout: %q", result.Stdout) } } // TestExecutor_PluginHookReceivesPluginRoot 验证 plugin-owned hook 仍能读到 // 注入的 FLYTO_PLUGIN_ROOT, 证明白名单路径没有误伤 plugin 必需变量. func TestExecutor_PluginHookReceivesPluginRoot(t *testing.T) { result := runShellHook(context.Background(), testExecutor, HookDef{ Command: "echo \"root=[$FLYTO_PLUGIN_ROOT]\"", PluginDir: "/tmp/my-plugin", }, nil, ) if !result.Success() { t.Fatalf("hook 应成功: %v", result.Error) } if !strings.Contains(result.Stdout, "root=[/tmp/my-plugin]") { t.Errorf("FLYTO_PLUGIN_ROOT 应注入为 /tmp/my-plugin, 实际 stdout: %q", result.Stdout) } } // TestExecutor_UserHookInheritsEnv 验证 user-configured hook (PluginDir 空) // 保持全量 env 继承, 不被白名单误伤. // // 这是分流设计的另一半: 用户在 ~/.flyto/settings.json 写的 hook 必须能读到 // 自己的 GITHUB_TOKEN / SSH_AUTH_SOCK / 自定义 shell env. 如果未来有人"顺手" // 把所有 hook 都套白名单, 此测试会失败并挡下改动. func TestExecutor_UserHookInheritsEnv(t *testing.T) { t.Setenv("FLYTO_TEST_USER_VAR", "user-value-ok") result := runShellHook(context.Background(), testExecutor, HookDef{ Command: "echo \"user=[$FLYTO_TEST_USER_VAR]\"", // 注意: 不设 PluginDir, 走 user-configured 分支. }, nil, ) if !result.Success() { t.Fatalf("hook 应成功: %v", result.Error) } if !strings.Contains(result.Stdout, "user=[user-value-ok]") { t.Errorf("user hook 应继承 FLYTO_TEST_USER_VAR, 实际 stdout: %q", result.Stdout) } } // TestExecutor_Duration 测试耗时记录 func TestExecutor_Duration(t *testing.T) { result := runShellHook(context.Background(), testExecutor, HookDef{Command: "echo fast"}, nil) if result.Duration <= 0 { t.Error("耗时应 > 0") } } // TestTryParseJSON 测试 JSON 解析 func TestTryParseJSON(t *testing.T) { tests := []struct { name string input string wantNil bool }{ {"有效 JSON 对象", `{"key":"value"}`, false}, {"带空白", ` {"key":"value"} `, false}, {"非 JSON", "just text", true}, {"JSON 数组", `[1,2,3]`, true}, {"JSON 字符串", `"hello"`, true}, {"空字符串", "", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := tryParseJSON(tt.input) if tt.wantNil && result != nil { t.Errorf("期望 nil, 实际: %v", result) } if !tt.wantNil && result == nil { t.Error("不应为 nil") } }) } } // TestHookDef_EffectiveTimeout 测试有效超时 func TestHookDef_EffectiveTimeout(t *testing.T) { // 默认超时 def := HookDef{Command: "test"} if def.EffectiveTimeout() != 30*time.Second { t.Errorf("默认超时应为 30s, 实际: %v", def.EffectiveTimeout()) } // 自定义超时 def = HookDef{Command: "test", Timeout: 60} if def.EffectiveTimeout() != 60*time.Second { t.Errorf("自定义超时应为 60s, 实际: %v", def.EffectiveTimeout()) } }