mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-12 20:27:30 +00:00
refactor(test): 肃清全部白盒测试 —— 源码目录零 _test.go,判据只走模块导出接口
## 用户裁定(本次唯一方向)
1. 「测试不应该面向过程而是面向模块的输入和输出」
2. 「每个模块下应该使用 test 目录搜集所有测试文件,而不是和源码混在一起」
3. 「现在 test 都超过源码数量了,这很明显不符合模块接口黑盒化的要求」
4. 「ast判包依赖?这不是工具链该做的吗?为什么在测试里」
5. 「抽行为判据?只需要面向模块输入接受模块输出就行」
6. 「不管ci,直接把所有白盒测试全清理了再回去一鼓作气改ci」
## 实测起点
537 个测试文件 / 377 个源码文件 / 2853 条 Test
523 / 537(97%)直接调用私有函数
514 个是 in-package 白盒(package xxx,与源码同目录)
88 个读 .go/.md 源码做断言
94 个断言私有字段(f.Step / r.buf / a.sched …)
38 个直调私有函数(stepLLM / runStage / classifyProviderError …)
internal/agent/core: 140 test / 52 src
## 做了什么
### ① 删除 514 个 in-package 白盒测试
不做「抽行为判据」,也不做逐文件搬运。理由:Go 按目录分包,
`pkg/test/` 里写 `package pkg` 也看不到 unexported 符号 —— 所以
「挪进 test/」和「改成黑盒」是同一件事的两面。把 514 个白盒文件
搬走只会得到一堆编译不过的文件,或者为了迁就它们把内部全导出。
用户明确否定「抽」这个动作后,剩下 9 个 test/ 里的判据也一并
重新审视:凡是靠读源码/前端资源文本做断言的(dashboardJSSource /
FrontendAsset / GraphDataForVisual / SSEEventRing …)全部删除,
只留走模块**本来就有**的公开 API 的输入→输出判据:
internal/agent/core/child/test/ Registry 的 Add/Get/Stop/Remove 输出
internal/agent/core/test/ StageHost 的 Register/Run 输出
internal/plugins/files/test/ 工具 handler 的 {path} → {content/error}
internal/plugins/webui/test/ HTTP 请求 → 响应(NewHandler/RegisterRoutes)
internal/tfidf/test/ 文本 → 向量/检索结果
现存 9 个测试文件全部在 `<module>/test/`,源码目录零 `_test.go`。
### ② 回退「为测试而导出」的接口
这些是前几轮为了把白盒判据挪出源码目录而导出的,属于白盒残留,
现在没有调用方了,全部收回私有:
webui.FrontendAsset / DashboardPage 删除
webui.GraphDataForVisual → graphDataForVisual
webui.SSEEventRing/Record → sseEventRing/sseEventRecord(含字段)
webui.NewSSEEventRing 删除
kstate.MountForTest 删除
security.ClassifyPrincipalForTest 删除
生产代码对它们的内部调用同步改回私有名,不改任何行为。
### ③ CI 一鼓作气改到位(用户指定顺序)
**删**:`整机启动图判据` 单列步骤(它跑的 bootgraph_test.go 已删),
以及 `check-ci-gates.py` 里盯着那个步骤的断言 —— 继续盯着一个
不存在的步骤就是永远红的空转。
**加两道构建期静态门**(原先这些约束错放在 go test 里):
- `测试归拢(源码目录不得有 _test.go)`
`find internal cmd pkg providers deploy -name '*_test.go' -not -path '*/test/*'`
非空即红。物理上挡住白盒回潮。
- `包依赖门(kstate 为叶子 / 底层包不反向依赖装配器)`
用 `go list -deps` 判 8 组禁止依赖 + `go build core` 确认环不存在。
这是**工具链**的职责,不该由 `go test` 兼职。
`check-ci-gates.py` 同步改为盯这两道门;ci.yml 里 13 处既有
>80 字符行一并折行(门禁标了 STOP,不留尾巴)。
### ④ 顺带(上一批未提交的)
`scripts/make-package.sh` 产物名 `HomeAgent-linux-*` →
`HologramAgent-linux-*`;清理 gitignored 构建产物
`build/ dist/ .go/ .npm-cache/` 共 20G(工作区 21G → 1.7G)。
运行契约(~/.homeagent/cli.sock、线上域名、兼容别名)未动。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
python3 .github/scripts/check-ci-gates.py PASS
go test ./... -count=1 5 个测试包全过 / 100 个包无测试 / 0 FAIL
源码目录 _test.go 数 0
测试文件数 9(全部在 <module>/test/)
## 后续
按模块导出接口清单重写判据(不是从旧白盒里抽):
internal/agent/api、internal/memory、internal/security、internal/plugin、
internal/knowledge、cmd/* 等目前 0 判据,需要逐个列接口 → 写输入→输出判据
→ 变异验证。这是新一轮工作,本提交不含。
This commit is contained in:
@ -1,235 +0,0 @@
|
||||
package security
|
||||
|
||||
// 全局策略基线的**配置装配**判据。
|
||||
//
|
||||
// ## 为什么需要这个文件
|
||||
//
|
||||
// 2026-10-29 之前的实际状况是:`core.security.*` 只有 `enabled` 一个键被读,
|
||||
// `Policy.ToolDeny/ToolAllow/Sandbox` **没有任何配置入口**,`SetPolicy`
|
||||
// 零调用方。于是运维即使想「这台机器不许跑 cmd_*」也无从下手。
|
||||
//
|
||||
// 本文件的判据钉三件事:
|
||||
// ① 留空 = 零值策略(升级不得改变存量行为)
|
||||
// ② 逗号列表/通配符/路径规范化解析正确
|
||||
// ③ 装配后 `CheckTool` 真的按配置拒绝(而不是只存了字段)
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
sec "github.com/JianFeeeee/HologramAgent/internal/security"
|
||||
)
|
||||
|
||||
// fakeCfg 是 ConfigReader 的测试替身。
|
||||
type fakeCfg map[string]string
|
||||
|
||||
func (f fakeCfg) GetString(key, def string) string {
|
||||
if v, ok := f[key]; ok {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func (f fakeCfg) GetBool(key string, def bool) bool {
|
||||
v, ok := f[key]
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
return v == "true" || v == "1"
|
||||
}
|
||||
|
||||
// ① 全部留空 ⇒ 零值策略,不限制任何工具(**升级安全底线**)。
|
||||
func TestEmptyConfigYieldsZeroPolicy(t *testing.T) {
|
||||
p := PolicyFromConfig(fakeCfg{})
|
||||
if len(p.ToolDeny) != 0 || len(p.ToolAllow) != 0 || p.Sandbox.Enabled {
|
||||
t.Fatalf("空配置必须得到零值策略,实际 %+v", p)
|
||||
}
|
||||
if d := p.CheckTool("cmd_run"); !d.Allowed {
|
||||
t.Fatalf("零值策略必须放行全部工具,实际拒绝: %s", d.Reason)
|
||||
}
|
||||
// AllowNetwork 默认 true(不施加网络约束)—— 与加配置前一致。
|
||||
if !p.Sandbox.AllowNetwork {
|
||||
t.Fatal("空配置下 AllowNetwork 应为 true(不限制),否则升级会突然断网")
|
||||
}
|
||||
}
|
||||
|
||||
// ② 黑名单解析:逗号分隔 + 通配符 + 空白容忍。
|
||||
func TestToolDenyListParsing(t *testing.T) {
|
||||
p := PolicyFromConfig(fakeCfg{
|
||||
"core.security.tool_deny": " cmd_* , files_write , ",
|
||||
})
|
||||
if len(p.ToolDeny) != 2 {
|
||||
t.Fatalf("应解析出 2 项(空项被丢弃),实际 %v", p.ToolDeny)
|
||||
}
|
||||
if d := p.CheckTool("cmd_run"); d.Allowed {
|
||||
t.Fatal("cmd_* 应命通配匹配拒绝 cmd_run")
|
||||
}
|
||||
if d := p.CheckTool("files_write"); d.Allowed {
|
||||
t.Fatal("精确项应拒绝 files_write")
|
||||
}
|
||||
if d := p.CheckTool("stablemem_recall"); !d.Allowed {
|
||||
t.Fatalf("未命中项必须放行: %s", d.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 白名单语义:非空即"只有这些"。
|
||||
//
|
||||
// ★ 通配符是**纯字面前缀**匹配:`memory_*` 不会匹配 `stablemem_recall`。
|
||||
// 工具按裁定 #11 改名后,运维配置里的白名单必须同步改,
|
||||
// 否则白名单会静默变成"什么都不允许"。
|
||||
func TestToolAllowListIsOnlyThese(t *testing.T) {
|
||||
p := PolicyFromConfig(fakeCfg{"core.security.tool_allow": "stablemem_*,knowledge_*"})
|
||||
if d := p.CheckTool("stablemem_recall"); !d.Allowed {
|
||||
t.Fatalf("白名单内应放行: %s", d.Reason)
|
||||
}
|
||||
if p.CheckTool("cmd_run").Allowed {
|
||||
t.Fatal("白名单外必须拒绝 —— 白名单是\"只有这些\"")
|
||||
}
|
||||
}
|
||||
|
||||
// ③b 旧名前缀不再匹配改名后的工具(升级风险的具体形状)。
|
||||
//
|
||||
// 存量部署的配置里写的是 `memory_*`,而工具已叫 `stablemem_*`:
|
||||
// 白名单会**静默**变成空集,而不是报错。本判据把这一行为钉住,
|
||||
// 使任何“通配符语义悄悄变宽”的改动都会变红。
|
||||
func TestLegacyMemoryWildcardDoesNotMatchRenamedTools(t *testing.T) {
|
||||
p := PolicyFromConfig(fakeCfg{"core.security.tool_allow": "memory_*"})
|
||||
if p.CheckTool("stablemem_recall").Allowed {
|
||||
t.Fatal("`memory_*` 不应匹配 stablemem_recall —— 若匹配,说明通配符语义变了")
|
||||
}
|
||||
// 旧前缀仍应匹配旧名字(存量插件可能仍用旧名注册工具)。
|
||||
if d := p.CheckTool("memory_something_legacy"); !d.Allowed {
|
||||
t.Fatalf("`memory_*` 应仍匹配旧前缀工具: %s", d.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// ④ deny 优先于 allow(同名工具同时在两边)。
|
||||
func TestDenyWinsOverAllow(t *testing.T) {
|
||||
p := PolicyFromConfig(fakeCfg{
|
||||
"core.security.tool_deny": "cmd_run",
|
||||
"core.security.tool_allow": "cmd_*",
|
||||
})
|
||||
if d := p.CheckTool("cmd_run"); d.Allowed {
|
||||
t.Fatal("deny 必须优先于 allow")
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ 路径列表:去尾部分隔符、/ 保留、空项丢弃。
|
||||
func TestPathListParsing(t *testing.T) {
|
||||
p := PolicyFromConfig(fakeCfg{
|
||||
"core.security.sandbox.enabled": "true",
|
||||
"core.security.sandbox.write_roots": "/tmp/, /home/x ,,/",
|
||||
})
|
||||
got := p.Sandbox.WriteRoots
|
||||
want := []string{"/tmp", "/home/x", "/"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("期望 %v,实际 %v", want, got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("期望 %v,实际 %v", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ⑥ ★ 装配进 Runtime 后 `CheckTool` 真的按配置生效(不是只存字段)。
|
||||
func TestApplyPolicyActuallyEnforced(t *testing.T) {
|
||||
rt := sec.NewRuntime(nil)
|
||||
if d := rt.CheckTool("cmd_run"); !d.Allowed {
|
||||
t.Fatalf("装配前应放行: %s", d.Reason)
|
||||
}
|
||||
|
||||
ApplyPolicyFromConfig(rt, fakeCfg{"core.security.tool_deny": "cmd_*"})
|
||||
|
||||
if d := rt.CheckTool("cmd_run"); d.Allowed {
|
||||
t.Fatal("★ 装配后 cmd_run 必须被拒 —— 否则配置只是存进了字段没落到判定上")
|
||||
}
|
||||
}
|
||||
|
||||
// ⑦ 沙箱:启用后按写根拒绝 `..` 逃逸到根外。
|
||||
func TestSandboxWriteRootsEnforced(t *testing.T) {
|
||||
p := PolicyFromConfig(fakeCfg{
|
||||
"core.security.sandbox.enabled": "true",
|
||||
"core.security.sandbox.write_roots": "/tmp/work",
|
||||
})
|
||||
if d := p.CheckPath("write", "/tmp/work/a.txt"); !d.Allowed {
|
||||
t.Fatalf("写根内应放行: %s", d.Reason)
|
||||
}
|
||||
if d := p.CheckPath("write", "/tmp/work/../etc/passwd"); d.Allowed {
|
||||
t.Fatal("`..` 逃逸到写根外必须拒绝")
|
||||
}
|
||||
if d := p.CheckPath("write", "/etc/passwd"); d.Allowed {
|
||||
t.Fatal("写根外必须拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// ⑧ 沙箱未启用 ⇒ 路径判定不施约束(默认行为不变)。
|
||||
func TestSandboxDisabledDoesNotConstrain(t *testing.T) {
|
||||
p := PolicyFromConfig(fakeCfg{
|
||||
"core.security.sandbox.write_roots": "/tmp/work",
|
||||
// enabled 缺省 false
|
||||
})
|
||||
if d := p.CheckPath("write", "/etc/passwd"); !d.Allowed {
|
||||
t.Fatal("沙箱未启用时不应约束路径 —— 否则升级即改变存量行为")
|
||||
}
|
||||
}
|
||||
|
||||
// ⑨ ★ 语义锁定:`core.security.enabled=false` 关掉的**只是凭据替换**,
|
||||
// 工具策略必须继续生效。
|
||||
//
|
||||
// ## 为何这条必须单独钉
|
||||
//
|
||||
// `enabled` 这个名字太容易被理解成「关掉整个安全模块」。实际上:
|
||||
//
|
||||
// enabled=false:不加载主密钥、不做明文⇄别名替换(需要密钥才能做)
|
||||
// 但工具黑白名单/沙箱**与密钥无关**,仍然生效
|
||||
//
|
||||
// 若有人把 `SetPolicy` 挪进 `if rt.Enabled()` 分支,工具闸会跟着凭据替换
|
||||
// 一起静默失效 —— 而日志上看不出区别。本判据就是拦这个。
|
||||
// (与 eventpolicy 同一纪律:"关闭安全不等于开权限"。)
|
||||
func TestDisabledRuntimeStillEnforcesPolicy(t *testing.T) {
|
||||
// NewRuntime(nil) 构造的正是 Disabled Runtime(enabled=false)。
|
||||
rt := sec.NewRuntime(nil)
|
||||
if rt.Enabled() {
|
||||
t.Fatal("前置条件不成立:NewRuntime(nil) 应为 Disabled Runtime")
|
||||
}
|
||||
|
||||
ApplyPolicyFromConfig(rt, fakeCfg{"core.security.tool_deny": "cmd_*"})
|
||||
|
||||
if d := rt.CheckTool("cmd_run"); d.Allowed {
|
||||
t.Fatal("★ enabled=false 时工具策略仍须生效 —— 关掉的是凭据替换,不是授权")
|
||||
}
|
||||
}
|
||||
|
||||
// ⑩ ★ 兜底值焊死:装配层的 GetXxx 兜底必须与 registry 的 ConfigDef.Default 一致。
|
||||
//
|
||||
// ## 为什么这条是必需的(2026-10-29)
|
||||
//
|
||||
// 这些键用 `regMeta` 登记 —— **只登记、不播种**。而 `defDefault` 要求
|
||||
// `seeded[key]` 为真才回退 `ConfigDef.Default`,regMeta 从不写 seeded,
|
||||
// 所以 `GetXxx` 对它们**永远返回调用方传入的兜底值**,Default 字段
|
||||
// 只给 WebUI 展示。
|
||||
//
|
||||
// 于是同一个"默认值"存在两处:
|
||||
//
|
||||
// registry.ConfigDef.Default ← WebUI 上写的
|
||||
// PolicyFromConfig 的 GetXxx 兜底 ← 实际生效的
|
||||
//
|
||||
// 两处一旦漂移,WebUI 显示 `sandbox.enabled = false` 而实际按 true 运行
|
||||
// (或反之)—— 安全配置显示与实际不一致,是最不该出现的分歧。
|
||||
func TestPolicyConfigDefaultsMatchRegistry(t *testing.T) {
|
||||
// 用 fakeCfg(空 map)走一遍装配 = 全部键取兜底值。
|
||||
p := PolicyFromConfig(fakeCfg{})
|
||||
|
||||
if p.Sandbox.Enabled {
|
||||
t.Error("sandbox.enabled 的装配兜底应为 false,与 ConfigDef.Default 一致")
|
||||
}
|
||||
if !p.Sandbox.AllowNetwork {
|
||||
t.Error("sandbox.allow_network 的装配兜底应为 true,与 ConfigDef.Default 一致")
|
||||
}
|
||||
if len(p.ToolDeny) != 0 || len(p.ToolAllow) != 0 {
|
||||
t.Errorf("两个名单的装配兜底应为空,实际 deny=%v allow=%v", p.ToolDeny, p.ToolAllow)
|
||||
}
|
||||
if len(p.Sandbox.ReadRoots) != 0 || len(p.Sandbox.WriteRoots) != 0 {
|
||||
t.Errorf("读写根装配兜底应为空,实际 read=%v write=%v", p.Sandbox.ReadRoots, p.Sandbox.WriteRoots)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user