Files
HomeAgent/internal/plugins/webui/test/secret_meta_test.go
JianFeeeee b3ae99e32f 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
2026-11-04 21:34:17 +08:00

167 lines
6.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package test
// 判据:**Secret 标志必须活着到达 WebUI 的 meta**。
//
// 由来:SDK 的 ConfigDef 一直有 Secret 字段,但内核侧三处转换
// (settingsImpl.RegisterDef / Defs / mapDefs)全部漏掉了它 ——
// 插件声明了 secret,前端拿到的 meta 里却没有这个标记,
// 于是敏感项被渲染成普通文本框明文回显。
//
// 这条判据钉住「声明 → meta」这一段,防止再次被漏掉。
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"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"
)
func TestSecretFlagReachesSettingsMeta(t *testing.T) {
dir := t.TempDir()
reg := internalConfig.NewConfigRegistry(dir + "/config.db")
reg.SetCodec(internalConfig.NewTestCodec(t))
defer reg.Close()
seedWebUIConfig(reg)
// 插件用**两种写法**各声明一个敏感项
sett := internalSDK.NewSettings("secplug", reg)
sett.RegisterDef(internalSDK.ConfigDef{
Key: "by_flag", Type: "string", DisplayName: "标志式", Secret: true,
})
sett.RegisterDef(internalSDK.ConfigDef{
Key: "by_type", Type: "password", DisplayName: "类型式",
})
// 走**真实 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()
serveRoutes(t, h, w, req)
if w.Code != http.StatusOK {
t.Fatalf("状态码 %d: %s", w.Code, w.Body.String())
}
var body struct {
Meta map[string]map[string]interface{} `json:"meta"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
byFlag, ok := body.Meta["plugin.secplug.by_flag"]
if !ok {
t.Fatalf("meta 里没有 by_flag;实际键: %v", keysOfMeta(body.Meta))
}
if byFlag["secret"] != true {
t.Errorf("Secret:true 未传递到 meta(前端会明文回显): %v", byFlag)
}
byType, ok := body.Meta["plugin.secplug.by_type"]
if !ok {
t.Fatalf("meta 里没有 by_type;实际键: %v", keysOfMeta(body.Meta))
}
if byType["type"] != "password" {
t.Errorf("password 类型应保持: %v", byType)
}
}
func keysOfMeta(m map[string]map[string]interface{}) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// 判据:敏感项在 WebUI 列表里的默认值**必须屏蔽**(E-3),
// 单键 reveal 才能看到明文;磁盘上仍是密文。
func TestSecretPlainInUIButCipherOnDisk(t *testing.T) {
dir := t.TempDir()
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"})
if err := sett.Set("apikey", "ui-visible-secret-123456"); err != nil {
t.Fatal(err)
}
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()
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(), webui.SecretPlaceholder) {
t.Fatal("设置列表应显示敏感值占位符")
}
// 显式揭示仍可核对(单键、当前 WebUI 插件范围)。
reveal := httptest.NewRecorder()
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())
}
var raw string
if err := reg.DB().QueryRow(
`SELECT value FROM config_webui WHERE key='apikey'`).Scan(&raw); err != nil {
t.Fatal(err)
}
if strings.Contains(raw, "ui-visible-secret") {
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")
}