Files
ModelRouter/internal/lua/plugin_visibility_test.go
JianFeeeee 93eeba3762 feat(types): 统一格式落地为内核实际使用的类型 —— 私有字段泄漏归零
Phase 2 收尾(入向)。ChatRequest 从"客户端发什么就是什么"改为
UnifiedRequest(最大集合)的别名,内核真正用上了那个超集类型。

改掉的具体缺陷:

1. 私有字段泄漏归零(v1 的核心问题)
   v1 的 disable_thinking 是内核私有开关,却被序列化进交给适配器的 body,
   迫使 10 个适配器各自写 req.disable_thinking = nil 来清理。
   现在它是中立的 thinking 字段({enabled, budget_tokens}),客户端与
   适配器都认这个词:
   - gateway/chat.go:thinkingFromClient() 把 disable_thinking 翻译成
     thinking.enabled=false;冲突时中立字段优先(它能带预算,布尔不能)
   - deepseek.lua:thinking.enabled=false → extra_body.thinking.type=disabled
   - anthropic.lua:thinking.enabled=true + budget → 原生 {type,budget_tokens}
     (enabled=false 时不传该字段——Anthropic 没有 type=disabled 这个写法)
   - 其余 7 个适配器:把"清理内核私有字段"改成"清理中立字段"

2. Tools/ToolChoice 改 json.RawMessage
   原为 []interface{}(OpenAI 原始形状 = 内核在假设某个协议)。
   内核从不解释工具 schema——它原样交给适配器,所以原始形状不损失任何
   内核侧统一性。ToolDef 结构化类型保留在包内供新适配器选用。

3. 删除 MarshalJSON 后门
   v1 手工 splice ExtraBody 进 OpenAI 形状的 body;现在 ExtraBody 是
   UnifiedRequest 上一个正正经经声明的字段。

渐进迁移:ChatRequest 保留为别名(不是新类型),49 处引用零改动编译,
语义变更不被机械改名淹没。

测试:新增 thinking_test.go(翻译链 4 例 + 泄漏回归 + tools 透传);
vm_test.go 两处迁移到中立语义。golden 字节级未漂移。
2026-10-11 09:35:32 +08:00

194 lines
5.9 KiB
Go

package lua
// Plugin visibility: a plugin page is not automatically public data.
//
// The billing page renders GLOBAL cost across every source and the quota page
// renders upstream balances. Both were served to any authenticated key because
// /api/ui-inject applied no role filter — measured, not theorised: a user-role
// key pulled the 18KB billing state and read the source names and the $8.86
// total straight out of it.
//
// These tests pin the DENIALS. A visibility feature whose tests only assert that
// an admin still sees everything passes while the leak is wide open.
import (
"testing"
)
func TestAudienceAllows(t *testing.T) {
cases := []struct {
want, role string
ok bool
why string
}{
{"", "user", true, "unset means everyone"},
{"all", "user", true, "explicit all"},
{"admin", "admin", true, "exact match"},
{"admin", "user", false, "admin-only excludes user"},
{"user", "user", true, "user-only admits user"},
{"user", "admin", false, "user-only excludes admin"},
{"admin,user", "user", true, "list admits a listed role"},
{" admin , user ", "user", true, "whitespace is not significant"},
{"ADMIN", "user", false, "case is normalised"},
{"*", "user", false, `"*" must NOT be a synonym for all`},
}
for _, c := range cases {
if got := audienceAllows(c.want, c.role); got != c.ok {
t.Errorf("audienceAllows(%q, %q) = %v, want %v (%s)",
c.want, c.role, got, c.ok, c.why)
}
}
}
func TestPluginVisibilityHidesPageFromUserRole(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
// One admin-only plugin, one open plugin: the filter must drop exactly one
// and leave the other intact, or a plugin author has no way to ship a mixed
// set.
const adminOnly = `
local P = {}
P.name = "adminonly"
P.version = "1.0.0"
P.exports = {}
P.ui = { visibility = "admin", page = { page_id = "secret", title = "Secret", mount = "<div>secret</div>" } }
return P
`
const open = `
local P = {}
P.name = "openpage"
P.version = "1.0.0"
P.exports = {}
P.ui = { page = { page_id = "open", title = "Open", mount = "<div>open</div>" } }
return P
`
for name, src := range map[string]string{"adminonly": adminOnly, "openpage": open} {
if err := ps.LoadSource(name, src); err != nil {
t.Fatalf("load %s: %v", name, err)
}
}
admin := ps.UIForRole("admin")
if len(admin.Pages) != 2 {
t.Errorf("admin should see both pages, got %d", len(admin.Pages))
}
user := ps.UIForRole("user")
for _, pg := range user.Pages {
if pg.PageID == "secret" {
t.Fatal("a user key can see the admin-only page — this is the leak")
}
}
if len(user.Pages) != 1 || user.Pages[0].PageID != "open" {
t.Fatalf("user should see exactly the open page, got %v", pageIDs(user.Pages))
}
}
// The operator must be able to impose visibility the plugin did not choose —
// that is the whole point of the override, since a bundled .lua cannot be edited
// without being overwritten on the next upgrade.
func TestPluginVisibilityOverrideBeatsDeclaration(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
const open = `
local P = {}
P.name = "openpage"
P.version = "1.0.0"
P.exports = {}
P.ui = { page = { page_id = "open", title = "Open", mount = "<div>open</div>" } }
return P
`
if err := ps.LoadSource("openpage", open); err != nil {
t.Fatal(err)
}
if len(ps.UIForRole("user").Pages) != 1 {
t.Fatal("precondition: the page should start visible to user")
}
ps.SetPluginVisibility("openpage", "admin")
if got := ps.UIForRole("user").Pages; len(got) != 0 {
t.Fatalf("the operator override was ignored: user still sees %v", pageIDs(got))
}
if ps.VisibilityOverride("openpage") != "admin" {
t.Error("the override should be reportable so /api/plugins can show it")
}
// Clearing restores the plugin's own declaration.
ps.SetPluginVisibility("openpage", "")
if got := ps.UIForRole("user").Pages; len(got) != 1 {
t.Fatalf("clearing the override should restore visibility, got %v", pageIDs(got))
}
}
// A page may narrow visibility below the plugin default, so one plugin can ship
// a public summary beside an admin-only detail page.
func TestPluginPageCanNarrowBelowPluginDefault(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
const mixed = `
local P = {}
P.name = "mixed"
P.version = "1.0.0"
P.exports = {}
P.ui = {
pages = {
{ page_id = "summary", title = "Summary", mount = "<div>s</div>" },
{ page_id = "details", title = "Details", mount = "<div>d</div>", audience = "admin" },
},
}
return P
`
if err := ps.LoadSource("mixed", mixed); err != nil {
t.Fatal(err)
}
user := ps.UIForRole("user")
if len(user.Pages) != 1 || user.Pages[0].PageID != "summary" {
t.Fatalf("user should see only the summary, got %v", pageIDs(user.Pages))
}
if len(ps.UIForRole("admin").Pages) != 2 {
t.Error("admin should see both pages")
}
}
func TestPluginStateVisibleTo(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
const adminOnly = `
local P = {}
P.name = "adminonly"
P.version = "1.0.0"
P.exports = {}
P.state = { secret = "x" }
P.ui = { visibility = "admin", page = { page_id = "s", title = "S", mount = "<div/>" } }
return P
`
if err := ps.LoadSource("adminonly", adminOnly); err != nil {
t.Fatal(err)
}
// The data has to follow the page: a hidden UI with an open state endpoint is
// the same leak with one more step.
if ps.StateVisibleTo("adminonly", "user") {
t.Error("user must not read an admin-only plugin's state")
}
if !ps.StateVisibleTo("adminonly", "admin") {
t.Error("admin must still read it")
}
// Unknown plugin: unchanged behaviour, so a widget that is not installed does
// not start answering 403.
if !ps.StateVisibleTo("nosuchplugin", "user") {
t.Error("an unknown plugin name must not become forbidden; that would change " +
"behaviour for plugins that simply are not installed")
}
}
func pageIDs(pages []*UIPage) []string {
out := make([]string, 0, len(pages))
for _, p := range pages {
if p != nil {
out = append(out, p.PageID)
}
}
return out
}