mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
## 插件管理页(用户要求:权限不要只放 config.yaml) 插件页新增两列(可见性、端点)和一块访问控制编辑器,可直接改: - 每个插件的整体可见性(跟随插件 / 仅 admin / 仅 user / admin+user / 所有人) - 每个注册端点的可访问角色与密钥(admin, user, @homeagent, public) 原先这些能力后端齐全但 WebUI 零入口,唯一改法是手改 config.yaml。 ## 修掉的三个真缺陷 1. endpointAccess 读错了 override 的键 SetEndpointAccess 按 plugin#path 存,endpointAccess 却读 plugin 级。 后果:改了权限后 UI 仍显示插件声明的角色,再保存一次就把限制悄悄撤销。 鉴权一直是对的,只有显示和往返是坏的 —— 所以之前没被发现。 2. override 只在内存里 内存 map 让"billing 仅 admin"看着生效,直到下次重启限制凭空消失, 日志里没有任何线索。现在落盘到 plugin_dir/.access.json, 插件升级覆盖 .state.json 也不会连带放宽访问。删掉最后一个 override 就删文件, "没有覆盖"用文件不存在来诚实表达。 3. 可见性列表泄漏内部键 端点 override 与插件可见性共用一个 map,UI 因此会看到 "probe#hello" 并可能把它当 audience 存回去。现在按已注册端点集合过滤。 另外把整体可见性接到同一条 /access 通路(path 用 *),并修了可见性下拉的 集合比较:服务端原样存 "admin, user",而选项是 "user,admin",不相等就会 显示"跟随插件",用户一保存就把访问范围放宽回插件声明 —— 比显示错更糟。 ## 活跃请求:三个视图显示 0 active 是实时仪表,没有日历周期可过滤,但周期响应压根没带这个字段。 前端 `st.active || 0` 于是把"字段缺失"变成了一句自信的"0 活跃", 而 all 视图(响应里有该字段)显示真值 —— 三个视图 0、总视图正常。 现在周期响应带上 active,前端去掉 || 0 回退:缺失显示 — 而不是假装是 0。 判据:四个 period 都断言 active 存在且为数字;变异去掉字段即变红。 生产实测 day/week/month/all 均为 4。 Co-Authored-By: ModelRouter <noreply@modelrouter.dev> (cherry picked from commit120ba9e957) (cherry picked from commit3b28fde22e)
264 lines
9.6 KiB
Go
264 lines
9.6 KiB
Go
package gateway
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"llmsproxy/internal/config"
|
|
"llmsproxy/internal/core"
|
|
"llmsproxy/internal/lua"
|
|
)
|
|
|
|
// gatewayWithBilling boots a gateway with the bundled billing plugin loaded, so
|
|
// the UI-injection endpoint is exercised against a real plugin rather than a
|
|
// hand-written stub. The other plugin tests in this package assert on the
|
|
// WebUI source; this one asserts on the HTTP contract the browser consumes.
|
|
func gatewayWithBilling(t *testing.T) *Gateway {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.yaml")
|
|
body := "listen: :0\n" +
|
|
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
|
|
"plugin_dir: " + filepath.Join(dir, "plugins") + "\n" +
|
|
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
|
|
"gateway_keys:\n - sk-test\n"
|
|
if err := os.WriteFile(cfgPath, []byte(body), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c, err := core.NewFromConfig(cfg)
|
|
if err != nil {
|
|
t.Fatalf("core: %v", err)
|
|
}
|
|
t.Cleanup(c.Close)
|
|
// Load the shipped plugin explicitly: seeding only runs for a directory that
|
|
// does not exist yet, and this test wants a known plugin regardless.
|
|
src, err := lua.ReadBundledPlugin("billing")
|
|
if err != nil {
|
|
t.Fatalf("read bundled billing: %v", err)
|
|
}
|
|
if err := c.Plugins().LoadSource("billing", src); err != nil {
|
|
t.Fatalf("load billing: %v", err)
|
|
}
|
|
g, err := New(c)
|
|
if err != nil {
|
|
t.Fatalf("gateway: %v", err)
|
|
}
|
|
return g
|
|
}
|
|
|
|
// TestUIInjectServesPluginUI: GET /api/ui-inject is the single call the WebUI
|
|
// makes at boot, and it must carry BOTH a contributed page and contributed
|
|
// elements — the browser builds the sidebar from the page and mounts the
|
|
// elements into existing panes from the same payload, so a partial response
|
|
// would produce a page with no body or a missing tile.
|
|
func TestUIInjectServesPluginUI(t *testing.T) {
|
|
g := gatewayWithBilling(t)
|
|
rr := doReq(t, g, http.MethodGet, "/api/ui-inject", "")
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var view struct {
|
|
// Decoded into the real types so the test cannot drift from the wire
|
|
// contract. An inline copy missed the `pages` field when the payload
|
|
// shape changed and failed to compile, which is at least loud — but the
|
|
// same copy also went on asserting the OLD single-page shape for a
|
|
// release, silently.
|
|
UI lua.UIExtension `json:"ui"`
|
|
Stages []string `json:"stages"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &view); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
// The payload carries every page in one list; ui.page is no longer a
|
|
// separate slot (it was, and a second plugin contributing a page overwrote
|
|
// whatever was there).
|
|
var billing *lua.UIPage
|
|
for i, pg := range view.UI.Pages {
|
|
if pg != nil && pg.PageID == "billing" {
|
|
billing = view.UI.Pages[i]
|
|
}
|
|
}
|
|
if billing == nil {
|
|
t.Fatalf("no billing page in the inject payload; pages=%d", len(view.UI.Pages))
|
|
}
|
|
if !strings.Contains(billing.Mount, "billing-root") {
|
|
t.Error("the page mount came back empty")
|
|
}
|
|
if len(view.UI.Elements) == 0 {
|
|
t.Error("billing contributes an element to the status page but it is missing")
|
|
}
|
|
for _, e := range view.UI.Elements {
|
|
if e.Target != "status" {
|
|
t.Errorf("element target = %q, want \"status\"", e.Target)
|
|
}
|
|
}
|
|
// Derived from AllStages, not hardcoded: the previous assertion of "3"
|
|
// is exactly what let chain_step go missing from this payload unnoticed.
|
|
if len(view.Stages) != len(lua.AllStages) {
|
|
t.Errorf("stages = %v, want %d (one per AllStages entry)", view.Stages, len(lua.AllStages))
|
|
}
|
|
for i, st := range lua.AllStages {
|
|
if i >= len(view.Stages) || view.Stages[i] != string(st) {
|
|
t.Errorf("stages[%d] = %v, want %q", i, view.Stages, string(st))
|
|
}
|
|
}
|
|
if !containsStr(view.Stages, string(lua.StageChainStep)) {
|
|
t.Error("the discovery payload does not advertise chain_step; a plugin " +
|
|
"author would conclude the stage does not exist")
|
|
}
|
|
}
|
|
|
|
// TestUIInjectIsEmptyWithoutPlugins: a gateway with no plugins must still answer
|
|
// 200 with an empty (not missing, not null) payload. The WebUI calls this
|
|
// unconditionally at boot, so a 404 or a null `ui` would break every dashboard.
|
|
func TestUIInjectIsEmptyWithoutPlugins(t *testing.T) {
|
|
g := newTestGateway(t)
|
|
rr := doReq(t, g, http.MethodGet, "/api/ui-inject", "")
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if !strings.Contains(rr.Body.String(), `"ui"`) {
|
|
t.Error("no ui key in the response; the WebUI would have nothing to read")
|
|
}
|
|
}
|
|
|
|
// containsStr reports whether list has s.
|
|
func containsStr(list []string, s string) bool {
|
|
for _, x := range list {
|
|
if x == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// newRecorderFor pushes a request through the full handler chain.
|
|
func newRecorderFor(t *testing.T, g *Gateway, req *http.Request) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
rr := httptest.NewRecorder()
|
|
g.Handler().ServeHTTP(rr, req)
|
|
return rr
|
|
}
|
|
|
|
// TestPluginsListAndStateAPI covers the management surface the plugin docs
|
|
// promise: listing, and reading a plugin's own published state.
|
|
func TestPluginsListAndStateAPI(t *testing.T) {
|
|
g := gatewayWithBilling(t)
|
|
rr := doReq(t, g, http.MethodGet, "/api/plugins", "")
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("GET /api/plugins = %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
for _, want := range []string{"billing", "hook_errors", "plugin_dir", "request_end"} {
|
|
if !strings.Contains(rr.Body.String(), want) {
|
|
t.Errorf("/api/plugins response lacks %q", want)
|
|
}
|
|
}
|
|
// state read: the plugin published its (empty) state, so the key exists.
|
|
rr = doReq(t, g, http.MethodGet, "/api/plugins/billing/state", "")
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("GET state = %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
if !strings.Contains(rr.Body.String(), `"total"`) {
|
|
t.Errorf("billing state lacks the total bucket: %s", rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestPluginStatePUTIsAdminOnly: only the WRITE side is gated. A user key must
|
|
// be able to READ its own billing widget's data, but must not be able to
|
|
// rewrite the price table.
|
|
func TestPluginStatePUTIsAdminOnly(t *testing.T) {
|
|
g := gatewayWithBilling(t)
|
|
|
|
// Build a user-role key and remember its secret.
|
|
rec, err := g.core.CreateKey("viewer", "user", nil, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
userKey := rec.Key
|
|
|
|
// A user key may read the state.
|
|
req, _ := http.NewRequest(http.MethodGet, "/api/plugins/billing/state", nil)
|
|
req.Header.Set("Authorization", "Bearer "+userKey)
|
|
rr := newRecorderFor(t, g, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("user GET state = %d, want 200 (the billing widget must render for users)", rr.Code)
|
|
}
|
|
|
|
// A user key may NOT write it.
|
|
put, _ := http.NewRequest(http.MethodPut, "/api/plugins/billing/state",
|
|
strings.NewReader(`{"prices":{"default":{"prompt":0}}}`))
|
|
put.Header.Set("Authorization", "Bearer "+userKey)
|
|
put.Header.Set("Content-Type", "application/json")
|
|
prr := newRecorderFor(t, g, put)
|
|
if prr.Code != http.StatusForbidden {
|
|
t.Errorf("user PUT state = %d, want 403 (a user must not rewrite the price table)", prr.Code)
|
|
}
|
|
|
|
// An admin key may.
|
|
adm := doReq(t, g, http.MethodPut, "/api/plugins/billing/state",
|
|
`{"prices":{"default":{"prompt":1e-6}}}`)
|
|
if adm.Code != http.StatusOK {
|
|
t.Errorf("admin PUT state = %d: %s", adm.Code, adm.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestPluginsListCarriesAccessTables: the plugin page renders visibility
|
|
// overrides and the endpoint access table from /api/plugins. Without them in the
|
|
// response the operator has to open a .lua or config.yaml to learn who can see
|
|
// what, which is exactly what the UI is meant to avoid.
|
|
func TestPluginsListCarriesAccessTables(t *testing.T) {
|
|
g := gatewayWithCallPlugin(t)
|
|
rr := doReq(t, g, http.MethodGet, "/api/plugins", "")
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("GET /api/plugins = %d", rr.Code)
|
|
}
|
|
var out struct {
|
|
Visibility map[string]string `json:"visibility"`
|
|
HTTPAccess []map[string]interface{} `json:"http_access"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
|
t.Fatalf("decode: %v (body=%s)", err, rr.Body.String())
|
|
}
|
|
// The calltest plugin registers no endpoints, so the table is present but
|
|
// empty — the KEY is that the field exists at all. A missing field decodes to
|
|
// nil and the UI silently renders nothing, which looks identical to "this
|
|
// plugin has no endpoints".
|
|
if !strings.Contains(rr.Body.String(), "http_access") {
|
|
t.Error("/api/plugins response lacks the http_access field")
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "visibility") {
|
|
t.Error("/api/plugins response lacks the visibility field")
|
|
}
|
|
}
|
|
|
|
// TestPluginWholeVisibilityViaAccessAPI covers the "*" path: the plugin page
|
|
// edits a plugin's overall audience through the same route it edits endpoints
|
|
// with. Before this existed the UI had a visibility selector whose save 404'd,
|
|
// because SetEndpointAccess validates that the endpoint exists and "*" does not.
|
|
func TestPluginWholeVisibilityViaAccessAPI(t *testing.T) {
|
|
g := gatewayWithCallPlugin(t)
|
|
rr := doReq(t, g, http.MethodPut, "/api/plugins/calltest/http/*/access",
|
|
`{"access":"admin, user"}`)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("PUT */access = %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
if !strings.Contains(rr.Body.String(), `"ok":true`) {
|
|
t.Errorf("unexpected body: %s", rr.Body.String())
|
|
}
|
|
// A user role is a legitimate audience; a nonsense one is not.
|
|
bad := doReq(t, g, http.MethodPut, "/api/plugins/calltest/http/*/access",
|
|
`{"access":"../../etc"}`)
|
|
if bad.Code != http.StatusBadRequest {
|
|
t.Errorf("traversal-shaped audience = %d, want 400: %s", bad.Code, bad.Body.String())
|
|
}
|
|
}
|