Files
HomeAgent/internal/plugins/webui/handler_plugin_test.go
JianFeeeee ba5785036a feat: 设备鉴权迁移至客户端 + 插件卸载保护
安全修复(客户端鉴权):
- remotedevice 服务端移除授权状态存储(authorized map/SetAuthorized/handleDeviceAuth)
- DeviceMeta.Authorized 改为设备 hello 自报,服务端仅透传展示
- device_ctl_* 工具移除服务端授权检查,无条件转发,设备端自行决定是否执行
- 共享设备桥库 Bridge 新增本地 authorized 状态,未授权收到 cmd 直接拒绝
- waiter: --device-authorized / device_authorized 配置控制本地授权
- GUI: 授权存 gui-prefs 本地文件;设备页仅本机可切换开关
- webui /device/auth 旧路径返回 410 Gone
- 根因:agent 可经 config_set 篡改服务端授权配置自行授权设备

插件管理强化:
- 内置插件禁止卸载(IsBuiltinPlugin + 409),外部插件卸载即时生效
- 卸载不存在插件返回 404;移除误导性 reload_required 提示
- webui 插件路由:名称白名单校验防路径穿越、保留字路径保护
2026-08-24 19:26:11 +08:00

196 lines
5.7 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 (
"net/http"
"net/http/httptest"
"testing"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// mockPluginMgr 是 sdk.PluginManager 的最小实现,用于 handler 路由层测试。
type mockPluginMgr struct {
builtins map[string]bool
disabled []sdk.DisabledPluginInfo
isDisabled map[string]bool
removed []string
reloadN int
removeErr error
}
func (m *mockPluginMgr) ListLoadedPlugins() []string { return nil }
func (m *mockPluginMgr) ListDisabledPlugins() []sdk.DisabledPluginInfo {
return m.disabled
}
func (m *mockPluginMgr) IsPluginDisabled(name string) bool {
return m.isDisabled[name]
}
func (m *mockPluginMgr) IsBuiltinPlugin(name string) bool {
return m.builtins[name]
}
func (m *mockPluginMgr) DisablePlugin(name, by string) error {
if m.isDisabled == nil {
m.isDisabled = map[string]bool{}
}
m.isDisabled[name] = true
return nil
}
func (m *mockPluginMgr) EnablePlugin(name string) error {
delete(m.isDisabled, name)
return nil
}
func (m *mockPluginMgr) RemovePlugin(name string) error {
if m.removeErr != nil {
return m.removeErr
}
m.removed = append(m.removed, name)
return nil
}
func (m *mockPluginMgr) ReloadPlugins() (string, error) {
m.reloadN++
return "reloaded", nil
}
func (m *mockPluginMgr) ReloadOne(name string) error { return nil }
func (m *mockPluginMgr) PluginMetas() map[string]sdk.PluginMeta {
return nil
}
func (m *mockPluginMgr) PluginDir() string { return "" }
// newHandlerWithMock 构造带 mock PluginManager 的 Handler绕过 SDK 组装)。
func newHandlerWithMock(m *mockPluginMgr) *Handler {
h := NewHandler(nil)
h.pluginMgr = m
return h
}
func TestHandlePluginByID_DisabledList(t *testing.T) {
m := &mockPluginMgr{
disabled: []sdk.DisabledPluginInfo{{Name: "foo"}},
}
h := newHandlerWithMock(m)
req := httptest.NewRequest(http.MethodGet, "/api/v1/plugins/disabled", nil)
w := httptest.NewRecorder()
h.handlePluginByID(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if body := w.Body.String(); len(body) == 0 || !contains(body, "foo") {
t.Fatalf("expected disabled list with foo, got %s", body)
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (func() bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
})()
}
func TestHandlePluginByID_ReloadPost(t *testing.T) {
m := &mockPluginMgr{}
h := newHandlerWithMock(m)
req := httptest.NewRequest(http.MethodPost, "/api/v1/plugins/reload", nil)
w := httptest.NewRecorder()
h.handlePluginByID(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if m.reloadN != 1 {
t.Fatalf("expected ReloadPlugins called once, got %d", m.reloadN)
}
}
func TestHandlePluginByID_ReloadDeleteRejected(t *testing.T) {
// DELETE /plugins/reload 不允许把保留字当插件名反代成卸载
m := &mockPluginMgr{}
h := newHandlerWithMock(m)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/plugins/reload", nil)
w := httptest.NewRecorder()
h.handlePluginByID(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected 405, got %d", w.Code)
}
if len(m.removed) != 0 {
t.Fatalf("expected no plugin removed, got %v", m.removed)
}
}
func TestHandlePluginByID_PathTraversalRejected(t *testing.T) {
cases := []string{"../evil", "a/b", "a..b-ok-but-dots-only-check", "..%2Fetc"}
for _, name := range cases {
req := httptest.NewRequest(http.MethodDelete, "/api/v1/plugins/"+name, nil)
w := httptest.NewRecorder()
h := newHandlerWithMock(&mockPluginMgr{})
h.handlePluginByID(w, req)
// 含路径分隔符或以 .. 开头的名称必须被拒绝400/405绝不能反代到 pluginmgr
if w.Code != http.StatusBadRequest && w.Code != http.StatusMethodNotAllowed && w.Code != http.StatusNotFound {
t.Errorf("name %q: expected 4xx rejection, got %d", name, w.Code)
}
}
}
func TestHandlePluginByID_InvalidNamesRejected(t *testing.T) {
cases := []string{"has%20space", "has%3Acolon", "back%5Cslash"}
for _, name := range cases {
req := httptest.NewRequest(http.MethodGet, "/api/v1/plugins/"+name, nil)
w := httptest.NewRecorder()
h := newHandlerWithMock(&mockPluginMgr{})
h.handlePluginByID(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("name %q: expected 400, got %d", name, w.Code)
}
}
}
func TestHandlePluginByID_ValidNamePassesValidation(t *testing.T) {
// 合法插件名(含点/横线/下划线)不应被名称校验拦截;
// 这里 pluginmgr 未运行会得到 502 Bad Gateway但绝不应该是 400。
m := &mockPluginMgr{}
h := newHandlerWithMock(m)
for _, name := range []string{"my-plugin", "plugin_v2", "weather.so"} {
req := httptest.NewRequest(http.MethodGet, "/api/v1/plugins/"+name, nil)
w := httptest.NewRecorder()
h.handlePluginByID(w, req)
if w.Code == http.StatusBadRequest {
t.Errorf("valid name %q should pass validation, got 400", name)
}
}
}
func TestHandlePluginByID_DisableEnable(t *testing.T) {
m := &mockPluginMgr{builtins: map[string]bool{"webui": true}}
h := newHandlerWithMock(m)
req := httptest.NewRequest(http.MethodPost, "/api/v1/plugins/webui/disable", nil)
w := httptest.NewRecorder()
h.handlePluginByID(w, req)
if w.Code != http.StatusOK {
t.Fatalf("disable: expected 200, got %d: %s", w.Code, w.Body.String())
}
if !m.isDisabled["webui"] {
t.Fatal("webui should be disabled in mock")
}
req = httptest.NewRequest(http.MethodPost, "/api/v1/plugins/webui/enable", nil)
w = httptest.NewRecorder()
h.handlePluginByID(w, req)
if w.Code != http.StatusOK {
t.Fatalf("enable: expected 200, got %d: %s", w.Code, w.Body.String())
}
if m.isDisabled["webui"] {
t.Fatal("webui should be re-enabled")
}
}