Files
HomeAgent/internal/plugins/webui/settings_secret_test.go
JianFeeeee 4e70b8d5d8 refactor(module): 核心仓 Go module 路径改为 github.com/JianFeeeee/HologramAgent
批 1,不含 SDK 仓与目录改名。

- go.mod 第 1 行 module 路径
- 366 个 .go 文件的完整前缀 github.com/JianFeeeee/HomeAgent 机械替换
  (857 处,仅完整前缀;展示名/注释里的 HomeAgent 字样留给批 5)
- 未改:third_party 目录名、SDK 自身 go.mod module、go.mod replace 目标、
  homed/waiter 可执行名、.hmap 后缀
2026-10-21 12:17:19 +08:00

317 lines
12 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 webui
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
internalConfig "github.com/JianFeeeee/HologramAgent/internal/config"
"github.com/JianFeeeee/HologramAgent/internal/sdk"
"github.com/JianFeeeee/HologramAgent/internal/supervisor"
"github.com/JianFeeeee/HologramAgent/pkg/types"
)
// ★ E-3:设置列表默认不得返回任何敏感明文(包括其他插件的)。
func TestSettingsMasksSecretValues(t *testing.T) {
reg := internalConfig.NewConfigRegistry("")
reg.RegisterDef(internalConfig.ConfigDef{Key: "core.llm.api_key", Type: "string", Secret: true})
if err := reg.Set("core.llm.api_key", "CORE_SECRET"); err != nil {
t.Fatal(err)
}
qq := reg.PluginConfig("qq")
qq.RegisterDef(internalConfig.ConfigDef{Key: "access_token", Type: "string", Secret: true})
if err := qq.Set("access_token", "QQ_SECRET"); err != nil {
t.Fatal(err)
}
sup := supervisor.New(&types.Config{Daemon: types.DaemonConfig{CheckInterval: 1, HeartbeatInterval: 1}})
sup.Start()
defer sup.Shutdown()
h := NewHandler(testSDK(sdk.SDKConfig{Supervisor: supervisor.NewSDKAdapter(sup), Settings: sdk.NewSettings("webui", reg), Config: sdk.NewConfig(&types.Config{})}))
w := httptest.NewRecorder()
h.handleSettings(w, httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil))
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
body := w.Body.String()
if strings.Contains(body, "CORE_SECRET") || strings.Contains(body, "QQ_SECRET") {
t.Fatalf("★ settings 泄露敏感明文: %s", body)
}
if !strings.Contains(body, SecretPlaceholder) {
t.Fatal("敏感值应显示占位符")
}
}
// ★ E-3:单键显式揭示允许核心/本插件,拒绝其他插件。
func TestSettingsRevealScope(t *testing.T) {
reg := internalConfig.NewConfigRegistry("")
reg.RegisterDef(internalConfig.ConfigDef{Key: "core.llm.api_key", Type: "string", Secret: true})
reg.Set("core.llm.api_key", "CORE_SECRET")
web := reg.PluginConfig("webui")
web.RegisterDef(internalConfig.ConfigDef{Key: "api_key", Type: "string", Secret: true})
web.Set("api_key", "WEB_SECRET")
qq := reg.PluginConfig("qq")
qq.RegisterDef(internalConfig.ConfigDef{Key: "access_token", Type: "string", Secret: true})
qq.Set("access_token", "QQ_SECRET")
sup := supervisor.New(&types.Config{Daemon: types.DaemonConfig{CheckInterval: 1, HeartbeatInterval: 1}})
sup.Start()
defer sup.Shutdown()
h := NewHandler(testSDK(sdk.SDKConfig{Supervisor: supervisor.NewSDKAdapter(sup), Settings: sdk.NewSettings("webui", reg), Config: sdk.NewConfig(&types.Config{})}))
for _, tc := range []struct {
key, want string
status int
}{
{"core.llm.api_key", "CORE_SECRET", http.StatusOK},
{"plugin.webui.api_key", "WEB_SECRET", http.StatusOK},
{"plugin.qq.access_token", "不允许读取其他插件", http.StatusForbidden},
} {
w := httptest.NewRecorder()
path := "/api/v1/settings/reveal?key=" + tc.key
h.handleSettingsReveal(w, httptest.NewRequest(http.MethodGet, path, nil))
if w.Code != tc.status {
t.Errorf("%s status=%d want=%d body=%s", tc.key, w.Code, tc.status, w.Body.String())
continue
}
if !strings.Contains(w.Body.String(), tc.want) {
t.Errorf("%s body lacks %q: %s", tc.key, tc.want, w.Body.String())
}
if tc.status == http.StatusOK {
var got map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got["secret"] != true {
t.Errorf("%s should mark secret=true", tc.key)
}
}
}
}
// ★★★ 判据:**掩码占位符绝不能经 PUT 存回内核**。
//
// 真实故障(2026-10-16,生产实测):
//
// ① 设置页 GET 把 api_key 掩码成 `••••••••`(E-3 防泄漏,本身是对的)
// ② 前端「保存」按钮会**一并回传**该组全部字段(含刚被掩码的 api_key)——
// 用户往往只想改同组的 base_url/model
// ③ 写侧此前**完全不检查占位符** ⇒ `••••••••` 被当真实值存进
// `core.llm.sources.<n>.api_key`
// ④ commondev 的 pi 源导入器优先读 `sources.*` ⇒ 把掩码写进 pi 的 auth.json
// ⑤ pi 拿掩码调模型 ⇒ 401 invalid api key(表现为"插件装了但模型不回复")
//
// 铁证:内核 `core.llm.api_key`(主键)解密后是**真 key**,
// 而 `core.llm.sources.llmsproxy.api_key`(子键)解密后是 `••••••••`。
//
// ⇒ 占位符是"这里有个值但我不告诉你"的**标记**,不是值。
//
// 把它存回去等于用标记覆盖真值 —— 这不是输入校验问题,是语义错误。
func TestSettingsPutRejectsSecretPlaceholder(t *testing.T) {
reg := internalConfig.NewConfigRegistry("")
reg.RegisterDef(internalConfig.ConfigDef{Key: "core.llm.sources.x.api_key", Type: "string", Secret: true})
if err := reg.Set("core.llm.sources.x.api_key", "REAL_KEY_VALUE"); err != nil {
t.Fatal(err)
}
sup := supervisor.New(&types.Config{Daemon: types.DaemonConfig{CheckInterval: 1, HeartbeatInterval: 1}})
sup.Start()
defer sup.Shutdown()
h := NewHandler(testSDK(sdk.SDKConfig{Supervisor: supervisor.NewSDKAdapter(sup), Settings: sdk.NewSettings("webui", reg), Config: sdk.NewConfig(&types.Config{})}))
put := func(v interface{}) *httptest.ResponseRecorder {
body, _ := json.Marshal(map[string]interface{}{
"key": "core.llm.sources.x.api_key", "value": v,
})
req := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(string(body)))
w := httptest.NewRecorder()
h.handleSettings(w, req)
return w
}
// ① 掩码必须被拒绝(这是缺陷本体)
w := put(SecretPlaceholder)
if w.Code == http.StatusOK {
t.Fatalf("★ 掩码占位符被接受了(接口返回 200)—— 真值已被覆盖: %s", w.Body.String())
}
// ② 拒绝之后真值**必须还在**(拒绝而不是写坏)
got, _ := reg.Get("core.llm.sources.x.api_key")
if s, _ := got.(string); s != "REAL_KEY_VALUE" {
t.Fatalf("★ 拒绝后真值被改写: %q(拒绝必须是原子的:不写)", s)
}
// ③ 错误信息必须告诉用户**怎么办**(否则他不知道如何保存同组其他字段)
msg := w.Body.String()
for _, want := range []string{"掩码", "不要保存"} {
if !strings.Contains(msg, want) {
t.Errorf("错误应含 %q 以指导操作: %s", want, msg)
}
}
// ④ 真值仍然可以正常保存(不能误伤)
if w := put("NEW_REAL_KEY"); w.Code != http.StatusOK {
t.Fatalf("真值应可保存: %d %s", w.Code, w.Body.String())
}
got, _ = reg.Get("core.llm.sources.x.api_key")
if s, _ := got.(string); s != "NEW_REAL_KEY" {
t.Fatalf("真值保存失败: %q", s)
}
// ⑤ 相似但不全等的值不得被误杀(真 key 可能含任意字符)
for _, v := range []string{"•••", "••••••••X", "x••••••••", "••••••••••••••••"} {
if w := put(v); w.Code != http.StatusOK {
t.Errorf("值 %q 不该被当作占位符拒绝: %d %s", v, w.Code, w.Body.String())
}
}
}
// ★★★ **行为判据**:前端保存时**真的不会**把掩码回传。
//
// 为什么不能只看源码字符串(本会话反复踩的坑):初版断言
// "saveSetting 里出现 isSecretPlaceholderValue(inp.value)",
// 把条件改成 `if (false && isSecretPlaceholderValue(...))` 就绕过了 ——
// 判据全绿而缺陷照样存在。这里把 dashboard.js 里的两个函数**抠出来真跑**
// (用 node + 假 DOM/假 api),断言"发出去的请求体"里没有掩码。
func TestDashboardSkipsPlaceholderOnSave(t *testing.T) {
node, err := exec.LookPath("node")
if err != nil {
t.Skip("本机无 node,跳过行为判据")
}
b, err := os.ReadFile("dashboard.js")
if err != nil {
t.Skipf("读不到 dashboard.js: %v", err)
}
src := string(b)
// 抠出需要的两个函数(按名字定位到下一个顶层 function/async function)
extract := func(name string) string {
// ★ 先试 async 版本:否则会把 `async function saveSetting(` 从
// "function saveSetting(" 处切开,丢掉 async 前缀 ⇒ await 语法错误。
i := strings.Index(src, "async function "+name+"(")
if i < 0 {
i = strings.Index(src, "function "+name+"(")
}
if i < 0 {
return ""
}
rest := src[i:]
// 找下一个顶层声明作为边界
best := -1
for _, marker := range []string{"\nasync function ", "\nfunction "} {
if j := strings.Index(rest[1:], marker); j >= 0 {
e := j + 1
if best < 0 || e < best {
best = e
}
}
}
if best > 0 {
return rest[:best]
}
return rest
}
phFn := extract("isSecretPlaceholderValue")
if phFn == "" {
t.Fatal("★ 缺少 isSecretPlaceholderValue(前端会把掩码当真值回传)")
}
saveFn := extract("saveSetting")
if saveFn == "" {
t.Fatal("找不到 saveSetting")
}
harness := `
// ---- 假环境:只提供被抠出的两个函数需要的东西 ----
var state = { meta: {}, settings: {} };
var sent = [];
function api(path, opts) { sent.push(JSON.parse(opts.body)); return Promise.resolve({status:"ok"}); }
function toast() {}
function __(a) { return a; }
var INPUTS = {};
var document = { getElementById: function(id) { return INPUTS[id] || null; } };
` + phFn + "\n" + saveFn + `
// ---- 用例 ----
(async function () {
var out = [];
function mkInput(v) { return { value: v, style: {} }; }
// ① 掩码:必须**不发请求**
INPUTS["inp-core_llm_sources_x_api_key"] = mkInput("\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022");
sent.length = 0;
await saveSetting("core.llm.sources.x.api_key");
out.push(["mask", sent.length]);
// ② 真值:必须**发请求**且发的是真值
INPUTS["inp-core_llm_sources_x_api_key"] = mkInput("sk-real-value");
sent.length = 0;
await saveSetting("core.llm.sources.x.api_key");
out.push(["real", sent.length, sent[0] && sent[0].value]);
// ③ 含圆点的真值不得被误杀
INPUTS["inp-some_key"] = mkInput("a\u2022b");
sent.length = 0;
await saveSetting("some.key");
out.push(["dot", sent.length]);
// ④ 空值/普通值正常保存
INPUTS["inp-plain"] = mkInput("hello");
sent.length = 0;
await saveSetting("plain");
out.push(["plain", sent.length]);
console.log(JSON.stringify(out));
})();
`
tmp := filepath.Join(t.TempDir(), "h.js")
if err := os.WriteFile(tmp, []byte(harness), 0o644); err != nil {
t.Fatal(err)
}
out, err := exec.Command(node, tmp).CombinedOutput()
if err != nil {
t.Fatalf("harness 执行失败: %v\n%s", err, out)
}
var got []interface{}
if err := json.Unmarshal(bytesTrim(out), &got); err != nil {
t.Fatalf("harness 输出无法解析: %v\n%s", err, out)
}
if len(got) != 4 {
t.Fatalf("harness 未跑完: %s", out)
}
get := func(i int) []interface{} { return got[i].([]interface{}) }
// ① 掩码:0 次请求
if n := get(0)[1].(float64); n != 0 {
t.Errorf("★ 掩码被回传了(发了 %v 次请求)—— 真值会被掩码覆盖", n)
}
// ② 真值:发 1 次且值正确
r := get(1)
if len(r) < 3 || r[1].(float64) != 1 {
t.Fatalf("★ 真值应正常保存,实际: %v", r)
}
if r[2].(string) != "sk-real-value" {
t.Errorf("★ 保存的值不对: %v", r[2])
}
// ③ 含圆点的真值不得误杀
if n := get(2)[1].(float64); n != 1 {
t.Errorf("★ 含圆点的真值被误杀(%v 次请求)", n)
}
// ④ 普通值正常
if n := get(3)[1].(float64); n != 1 {
t.Errorf("普通值未保存: %v", n)
}
}
func bytesTrim(b []byte) []byte {
i := strings.IndexByte(string(b), '{')
if i < 0 {
return b
}
j := strings.LastIndexByte(string(b), '}')
if j < i {
return b
}
return b[i : j+1]
}