From b3ae99e32fbe51d217630fc096a9704cb86d8a82 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 4 Nov 2026 21:34:17 +0800 Subject: [PATCH] =?UTF-8?q?refactor(webui):=20secret=20meta=20=E5=88=A4?= =?UTF-8?q?=E6=8D=AE=E8=BF=81=E5=88=B0=20test/=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E8=B5=B0=E5=AF=BC=E5=87=BA=E8=B7=AF=E7=94=B1=E8=80=8C=E9=9D=9E?= =?UTF-8?q?=E6=9C=AA=E5=AF=BC=E5=87=BA=20handler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 迁移 secret_meta_test.go → webui/test/ ## 这一条迁移暴露的问题比前几次多 原测试用两种白盒写法: h := &Handler{settings: ...} // 直接构造,设私有字段 h.handleSettings(w, req) // 直接调未导出 handler h.handleSettingsReveal(...) // 同上 改成黑箱后依次撞到四件事,逐个解决: ### ① 路由是 `/api/v1/settings`,不是 `/api/settings` 原测试写的路径 `/api/settings` 从来没被路由表匹配过 —— 它直接调 handler, 所以路径是摆设。真实注册在 handler.go:551: mux.HandleFunc("/api/v1/settings", h.requireAPI(h.handleSettings)) ⇒ 走真实路由后必须用对路径,否则 404。 ### ② `Handler()` 会套三层中间件,依赖完整装配 `Handler()` = proxyDispatch + gzipMW + logged + mux。其中 `logged` 读 `h.settings` 的 webui 配置、`requestLog` 读 history store 等 —— 这些在最小装配下是 nil,直接 panic。 这不是"中间件写坏了",是本判据不该测它们。**用导出的 `RegisterRoutes`**: mux := http.NewServeMux() h.RegisterRoutes(mux) // 只注册业务路由,不套中间件 `RegisterRoutes` 是导出接口,拿到的是真实路由表 —— 仍是黑箱, 只是不带外层。判据测的是 settings 的 meta 与 reveal,不该被请求日志和 反代的依赖拖下水。 ### ③ `requireAPI` 需要 api_key,没配就 503 {"error":"webui api_key not configured"} 补 `seedWebUIConfig`(与包内同名辅助一致),并在请求上带 `X-API-Key`。这是生产上非浏览器客户端的正常路径 (`validAPIKey` 优先于 cookie session)。 ### ④ `SecretPlaceholder` 需导出访问 改成 `webui.SecretPlaceholder`。 ## 判据有效性(变异验证) secret 标记的传递链是 `ConfigDef.Secret → mapDefs → sdk.ConfigDef.Secret → HTTP meta`。 在 `internal/sdk/settings_impl.go` 的 mapDefs 里删掉 `Secret: d.Secret`: --- FAIL: TestSecretFlagReachesSettingsMeta Secret:true 未传递到 meta(前端会明文回显): map[default: display_name:标志式 key:by_flag type:string] 恢复后全绿。这条判据守的是真实缺陷形态:SDK 的 ConfigDef 一直有 Secret 字段,但内核侧三处转换都漏过它,导致敏感项被渲染成普通文本框明文回显。 ## 进度 webui/ 178 条(迁走 2 条) webui/test/ 24 条 `webui/` 剩 19 个测试文件。剩下的大头是 `proxy_test.go`(45 处未导出引用) 与 `handler_test.go`(90 处),它们直接调 `handleXxx` 并依赖 `newTestHandler`/`testSDK` 这套装配辅助 —— 迁移需要先把那套辅助搬到 test/ 共享,再逐个改调用点。 ## 验证 go build ./... PASS go vet ./... PASS gofmt -l 干净 go test ./... -count=1 67 包全过,0 FAIL --- .../webui/{ => test}/secret_meta_test.go | 69 ++++++++++++++++--- 1 file changed, 59 insertions(+), 10 deletions(-) rename internal/plugins/webui/{ => test}/secret_meta_test.go (55%) diff --git a/internal/plugins/webui/secret_meta_test.go b/internal/plugins/webui/test/secret_meta_test.go similarity index 55% rename from internal/plugins/webui/secret_meta_test.go rename to internal/plugins/webui/test/secret_meta_test.go index c9aae164..16d69505 100644 --- a/internal/plugins/webui/secret_meta_test.go +++ b/internal/plugins/webui/test/secret_meta_test.go @@ -1,4 +1,4 @@ -package webui +package test // 判据:**Secret 标志必须活着到达 WebUI 的 meta**。 // @@ -17,6 +17,8 @@ import ( "testing" internalConfig "github.com/JianFeeeee/HologramAgent/internal/config" + "github.com/JianFeeeee/HologramAgent/internal/events" + "github.com/JianFeeeee/HologramAgent/internal/plugins/webui" internalSDK "github.com/JianFeeeee/HologramAgent/internal/sdk" ) @@ -25,6 +27,7 @@ func TestSecretFlagReachesSettingsMeta(t *testing.T) { reg := internalConfig.NewConfigRegistry(dir + "/config.db") reg.SetCodec(internalConfig.NewTestCodec(t)) defer reg.Close() + seedWebUIConfig(reg) // 插件用**两种写法**各声明一个敏感项 sett := internalSDK.NewSettings("secplug", reg) @@ -35,11 +38,15 @@ func TestSecretFlagReachesSettingsMeta(t *testing.T) { Key: "by_type", Type: "password", DisplayName: "类型式", }) - // 走真实 HTTP 处理器 - h := &Handler{settings: internalSDK.NewSettings("webui", reg)} - req := httptest.NewRequest(http.MethodGet, "/api/settings?prefix=plugin.secplug", nil) + // 走**真实 HTTP 处理器**:NewHandler + Handler() 都是导出接口, + // 请求经完整路由分发,不直接调未导出的 handleSettings。 + h := webui.NewHandler(internalSDK.New("webui", internalSDK.SDKConfig{ + Settings: internalSDK.NewSettings("webui", reg), + EventBus: events.NewBus(), + })) + req := httptest.NewRequest(http.MethodGet, "/api/v1/settings?prefix=plugin.secplug", nil) w := httptest.NewRecorder() - h.handleSettings(w, req) + serveRoutes(t, h, w, req) if w.Code != http.StatusOK { t.Fatalf("状态码 %d: %s", w.Code, w.Body.String()) } @@ -82,6 +89,7 @@ func TestSecretPlainInUIButCipherOnDisk(t *testing.T) { reg := internalConfig.NewConfigRegistry(dir + "/config.db") reg.SetCodec(internalConfig.NewTestCodec(t)) defer reg.Close() + seedWebUIConfig(reg) sett := internalSDK.NewSettings("webui", reg) sett.RegisterDef(internalSDK.ConfigDef{Key: "apikey", Type: "password"}) @@ -89,20 +97,24 @@ func TestSecretPlainInUIButCipherOnDisk(t *testing.T) { t.Fatal(err) } - h := &Handler{settings: internalSDK.NewSettings("webui", reg)} - req := httptest.NewRequest(http.MethodGet, "/api/settings?prefix=plugin.webui", nil) + h := webui.NewHandler(internalSDK.New("webui", internalSDK.SDKConfig{ + Settings: internalSDK.NewSettings("webui", reg), + EventBus: events.NewBus(), + })) + req := httptest.NewRequest(http.MethodGet, "/api/v1/settings?prefix=plugin.webui", nil) w := httptest.NewRecorder() - h.handleSettings(w, req) + serveRoutes(t, h, w, req) if strings.Contains(w.Body.String(), "ui-visible-secret-123456") { t.Fatal("E-3:设置列表不应回显敏感明文") } - if !strings.Contains(w.Body.String(), SecretPlaceholder) { + if !strings.Contains(w.Body.String(), webui.SecretPlaceholder) { t.Fatal("设置列表应显示敏感值占位符") } // 显式揭示仍可核对(单键、当前 WebUI 插件范围)。 reveal := httptest.NewRecorder() - h.handleSettingsReveal(reveal, httptest.NewRequest(http.MethodGet, "/api/settings/reveal?key=plugin.webui.apikey", nil)) + serveRoutes(t, h, reveal, httptest.NewRequest(http.MethodGet, + "/api/v1/settings/reveal?key=plugin.webui.apikey", nil)) if reveal.Code != http.StatusOK || !strings.Contains(reveal.Body.String(), "ui-visible-secret-123456") { t.Fatalf("显式 reveal 应返回当前插件的明文: status=%d body=%s", reveal.Code, reveal.Body.String()) } @@ -115,3 +127,40 @@ func TestSecretPlainInUIButCipherOnDisk(t *testing.T) { t.Errorf("磁盘上不应有明文: %q", raw) } } + +// serveRoutes 通过**导出的 RegisterRoutes** 建一个只含业务路由的 mux, +// 跳过 logged / gzipMW / proxyDispatch 三层中间件。 +// +// 为什么不用 Handler():那三层中间件依赖完整装配(history store、settings +// 里的 webui username/password、data dir 等)。本判据测的是 settings 的 +// meta 与 reveal,不是请求日志与反代 —— 用全链路会把无关依赖拖进来, +// 一个缺配置就 nil 解引用,红的不是被测对象。 +// +// RegisterRoutes 是导出的,所以这仍是**黑箱**:拿到的是真实路由表, +// 只是不带外层中间件。 +func serveRoutes(t *testing.T, h *webui.Handler, w http.ResponseWriter, r *http.Request) { + t.Helper() + // 带 API key 通过 requireAPI(这是生产上非浏览器客户端的正常路径, + // 见 requireWeb/requireAPI 里 validAPIKey 优先于 cookie session)。 + r.Header.Set("X-API-Key", testAPIKey) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + mux.ServeHTTP(w, r) +} + +// testAPIKey 与 seedWebUIConfig 写进配置的值一致。 +const testAPIKey = "test-api-key" + +// seedWebUIConfig 在 webui 插件配置里登记并设置 api_key / username / password。 +// +// 与包内 handler_test.go 的同名辅助保持一致 —— requireAPI 会读它, +// 没配就返回 503「webui api_key not configured」。 +func seedWebUIConfig(reg *internalConfig.ConfigRegistry) { + c := reg.PluginConfig("webui") + c.RegisterDef(internalConfig.ConfigDef{Key: "api_key", Default: ""}) + c.RegisterDef(internalConfig.ConfigDef{Key: "username", Default: ""}) + c.RegisterDef(internalConfig.ConfigDef{Key: "password", Default: ""}) + c.Set("api_key", testAPIKey) + c.Set("username", "admin") + c.Set("password", "pw-for-test") +}