refactor(webui): secret meta 判据迁到 test/ —— 走导出路由而非未导出 handler

## 迁移

    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
This commit is contained in:
JianFeeeee
2026-11-04 21:34:17 +08:00
parent c01f4d6350
commit b3ae99e32f

View File

@ -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")
}