mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
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:
166
internal/plugins/webui/test/secret_meta_test.go
Normal file
166
internal/plugins/webui/test/secret_meta_test.go
Normal file
@ -0,0 +1,166 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user