Why: the user ruled out self-registration (2026-09-30): accounts are created only by admins who set an initial password; first login (and first login after any admin reset) must force a password change. Evidence: - migration v10 auth_accounts (users password/must_change/is_admin + sessions table; upgrade test walks v1<->v10 both directions). - argon2id hash/verify with PHC encoding; store tests cover hash roundtrip, duplicate-owner conflict, must-change lifecycle, session keep/except semantics. - e2e TestAdminAccountFlow: static login page reachable without auth, wrong password 401, login sets HttpOnly cookie + mustChangePassword, admin creates user (409 on duplicate), non-admin create 403, forced change clears flag and old password dies, admin reset revokes sessions and re-arms forced change, API-key channel untouched, 5-fail backoff 429, logout kills session. Full go test ./... green. Impact: middleware now accepts Bearer key OR session cookie and exempts static + /api/auth/login (also fixes the latent case where the login page itself could 401). Embedded no-auth mode unchanged. Refs: proposal 2026-09-30-ui-redesign-and-auth.md section 5 (admin-issued variant, user decision 2026-09-30)
158 lines
6.0 KiB
Go
158 lines
6.0 KiB
Go
package plugins
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"agent-system-backend/internal/settings"
|
|
"agent-system-backend/internal/store"
|
|
"agent-system-backend/internal/tools"
|
|
core "agent-system-core"
|
|
)
|
|
|
|
func newHost(t *testing.T) (*Host, *tools.Registry, string) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
st, err := store.OpenSQLite(filepath.Join(dir, "test.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { st.Close() })
|
|
reg := tools.NewRegistry()
|
|
reg.RegisterBuiltin()
|
|
setSvc := settings.NewService(st)
|
|
pluginDir := filepath.Join(dir, "plugins")
|
|
os.MkdirAll(pluginDir, 0o755)
|
|
return New(st, reg, setSvc, pluginDir), reg, pluginDir
|
|
}
|
|
|
|
// writeExamplePlugin 写一个覆盖五类能力的最小示例插件目录。
|
|
func writeExamplePlugin(t *testing.T, dir, id string) {
|
|
t.Helper()
|
|
root := filepath.Join(dir, id)
|
|
for _, sub := range []string{"tools", "panels", "cards", "intents", "prompts"} {
|
|
os.MkdirAll(filepath.Join(root, sub), 0o755)
|
|
}
|
|
writeFile := func(rel, content string) {
|
|
if err := os.WriteFile(filepath.Join(root, rel), []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
writeFile("plugin.json", `{"id":"`+id+`","name":"示例插件","version":"0.1.0","apiVersion":"`+core.PluginAPIVersion+`",
|
|
"requires":{"network":["api.example.com"]},
|
|
"provides":{"tools":["http_echo"],"intents":["demo"],"panels":["demo"],"cards":["note"],"prompts":["assistant"],"settings":["greeting"]}}`)
|
|
writeFile("tools/http_echo.json", `{"name":"http_echo","description":"回显输入到示例端点","source":"plugin",
|
|
"parameters":{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]},
|
|
"http":{"method":"POST","url":"https://api.example.com/echo","bodyTemplate":"{\"q\":\"{{param.text}}\"}","resultPath":"echo"}}`)
|
|
writeFile("panels/demo.json", `{"id":"demo","title":"示例面板","body":[{"type":"markdown","markdown":"# 示例\n插件面板。"}]}`)
|
|
writeFile("cards/note.json", `{"kind":"note","data":[{"type":"markdown","markdown":"插件卡片模板"}]}`)
|
|
writeFile("intents/demo.json", `[{"id":"demo.show","domain":"demo","includes_any":["示例"]}]`)
|
|
writeFile("prompts/assistant.md", `你是示例插件附带的助手模板。`)
|
|
writeFile("settings.json", `[{"key":"greeting","group":"plugin:`+id+`","label":"问候语","type":"string","default":"你好","effect":"hot"}]`)
|
|
}
|
|
|
|
func TestLoadAndRegister(t *testing.T) {
|
|
host, reg, dir := newHost(t)
|
|
writeExamplePlugin(t, dir, "example")
|
|
|
|
results := host.LoadDir()
|
|
if err := results["example"]; err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
// 工具注册(source=plugin,声明式 HTTP 绑定执行器)。
|
|
desc, exec, ok := reg.Get("http_echo")
|
|
if !ok || desc.Source != core.SourcePlugin || desc.PluginID != "example" || exec == nil {
|
|
t.Fatalf("tool not registered: %+v", desc)
|
|
}
|
|
// 面板/卡片/意图/提示词/设置声明。
|
|
lds := host.List()
|
|
if len(lds) != 1 || len(lds[0].Panels) != 1 || lds[0].Cards["note"] == nil || len(lds[0].Intents) != 1 || lds[0].Prompts["assistant"] == "" {
|
|
t.Fatalf("loaded state wrong: %+v", lds)
|
|
}
|
|
if len(host.SettingsSchema()) != 1 || host.SettingsSchema()[0].Group != "plugin:example" {
|
|
t.Errorf("plugin settings schema: %+v", host.SettingsSchema())
|
|
}
|
|
}
|
|
|
|
func TestUnloadRemovesRegistrations(t *testing.T) {
|
|
host, reg, dir := newHost(t)
|
|
writeExamplePlugin(t, dir, "example")
|
|
host.LoadDir()
|
|
if _, _, ok := reg.Get("http_echo"); !ok {
|
|
t.Fatal("prerequisite: tool registered")
|
|
}
|
|
// 目录中删除插件后重载 → 卸载语义。
|
|
os.RemoveAll(filepath.Join(dir, "example"))
|
|
host.LoadDir()
|
|
if _, _, ok := reg.Get("http_echo"); ok {
|
|
t.Error("tool should be unregistered after plugin removal")
|
|
}
|
|
if len(host.List()) != 0 {
|
|
t.Error("plugin should be unloaded")
|
|
}
|
|
// 内置工具不受影响(核心零回归)。
|
|
if _, _, ok := reg.Get("knowledge_search"); !ok {
|
|
t.Error("builtin tool must survive plugin unload")
|
|
}
|
|
}
|
|
|
|
// fail-closed:坏资产整批拒绝,不部分注册。
|
|
func TestFailClosedOnBadAsset(t *testing.T) {
|
|
host, reg, dir := newHost(t)
|
|
writeExamplePlugin(t, dir, "example")
|
|
// 破坏工具资产(非法 JSON)。
|
|
os.WriteFile(filepath.Join(dir, "example", "tools", "http_echo.json"), []byte(`{broken`), 0o644)
|
|
|
|
results := host.LoadDir()
|
|
if err := results["example"]; err == nil {
|
|
t.Fatal("bad asset should fail load")
|
|
}
|
|
if _, _, ok := reg.Get("http_echo"); ok {
|
|
t.Error("fail-closed violated: tool registered from broken plugin")
|
|
}
|
|
if len(host.List()) != 0 {
|
|
t.Error("broken plugin must not be in loaded list")
|
|
}
|
|
}
|
|
|
|
func TestAPIVersionGuard(t *testing.T) {
|
|
host, _, dir := newHost(t)
|
|
writeExamplePlugin(t, dir, "oldapi")
|
|
os.WriteFile(filepath.Join(dir, "oldapi", "plugin.json"), []byte(`{"id":"oldapi","name":"旧版","version":"1.0.0","apiVersion":"0.9","provides":{}}`), 0o644)
|
|
results := host.LoadDir()
|
|
if err := results["oldapi"]; err == nil {
|
|
t.Fatal("incompatible apiVersion should be rejected")
|
|
}
|
|
}
|
|
|
|
func TestWorkspacesOverlay(t *testing.T) {
|
|
dir := t.TempDir()
|
|
st, err := store.OpenSQLite(filepath.Join(dir, "test.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { st.Close() })
|
|
reg := tools.NewRegistry()
|
|
reg.RegisterBuiltin()
|
|
pluginDir := filepath.Join(dir, "plugins")
|
|
os.MkdirAll(filepath.Join(pluginDir, "wsdemo", "workspaces"), 0o755)
|
|
os.WriteFile(filepath.Join(pluginDir, "wsdemo", "plugin.json"), []byte(`{"id":"wsdemo","name":"WS","version":"1.0.0","apiVersion":"1.0","provides":{"workspaces":["main"]}}`), 0o644)
|
|
os.WriteFile(filepath.Join(pluginDir, "wsdemo", "workspaces", "main.json"), []byte(`[
|
|
{"id":"demo.governance","title":"治理总览","icon":"🛡","shortcut":"5","body":[{"type":"markdown","markdown":"声明式工作区"}]}
|
|
]`), 0o644)
|
|
setSvc := settings.NewService(st)
|
|
h := New(st, reg, setSvc, pluginDir)
|
|
if errs := h.LoadDir(); len(errs) > 0 {
|
|
for _, err := range errs {
|
|
if err != nil {
|
|
t.Fatalf("load errs: %v", errs)
|
|
}
|
|
}
|
|
}
|
|
ws := h.ListWorkspaces()
|
|
if len(ws) != 1 || ws[0].ID != "demo.governance" || ws[0].Shortcut != "5" || len(ws[0].Body) != 1 {
|
|
t.Fatalf("workspaces: %+v", ws)
|
|
}
|
|
}
|