mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
## 问题
三个读取函数(readPluginUI / readPluginExports / readPluginHTTP)各自手写一遍
「取字段、转换、追加」,字段名在 .lua 和 Go 里各存一份。加一个字段要改两处,
**漏了 Go 那一半不会报错**,字段静默读成未设置:
ui = { visibilty = "admin" } -- 一个字母之差
⇒ 什么都没限制,且与「插件主动不声明 audience」完全无法区分
今天一天里因此丢了三个字段(exports、http 端点、visibility/state_scope),
每次都是靠「量服务端返回的长度对比磁盘文件」反推出来的。
## 改动
新增 internal/lua/plugin_decode.go:schema 是数据,不是反射也不是 struct tag。
字段清单在一个地方,插件作者读一张表就知道网关认什么;不认识的字段**上报而非
丢弃**,并给出「你是不是想写 visibility」这种可执行的提示。
readPluginUI 2270 → 860 字符,readPluginExports 732 → 392。顺手去掉了
readPluginUI 被调用两次、其中第二次对已编译的 depth map 再编译一遍。
## 顺带修掉一个真 bug:handler 必须是名字而非函数值
`asString` 对任何非字符串都 `fmt.Sprintf("%v")`,所以写成
`handler = function(req) ... end` 会变成字符串 "function: 0x7f...",被当成
handler 名接受,端点注册成功,然后在**派发时**失败 —— 离声明处十万八千里。
现在在解码时就拒绝,并同时报出字段名和 path。
## 更正一个我写了但从未核实的说法
之前那批修复的注释里(三个文件 + 提交信息)都写着「插件注册发生在 setupGlobals
安装 json 全局之前,所以 luaToJSON 返回空」。**这个说法是错的**,实测:
setupGlobals 在 vm.go:574,读取函数在 787,顺序相反
json global present when readPlugin* runs: true
luaToJSON 在同一个栈形态上解码正确,返回了完整的 visibility 和 pages
真正的差异是**索引语义**:luaTableToGoValue 会把负索引立即折算成绝对索引
(GetTop()+idx+1),luaToJSON 则保留相对索引并在 Call 前 push。栈一旦被
SetTop(0) 清掉,两者行为分道扬镳。
一个错误解释重复三次比没有解释更糟 —— 它会把下一个读代码的人送去寻找一个
不存在的时序 bug。三处注释都已改成实测结论,并说明这个错误是怎么被发现的。
## 判据
- 拼错的字段被上报、给出 did-you-mean,且该 audience 确实没被应用
- 正确声明零诊断(否则会被静音,然后什么也抓不到)
- 内联函数形式的 handler 被拒并说明原因;`handler = "name"` 形式不受影响
- decode 边界单独一条:走真实注册路径加载插件,断言声明的值都读回来了
变异自证:strictKeys 短路 → 变红。
已知局限(写在判据文件里):把 luaTableToGoValue 换回 luaToJSON 的变异目前
**抓不住** —— 已实测那两条路径在当前栈形态下等价。要让它可检出,需要一条
在 json 全局缺席时走注册路径的判据;现在这条是靠 review 和注释保证的,
不假装它是变异可证的。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
228 lines
8.0 KiB
Go
228 lines
8.0 KiB
Go
package lua
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestUnknownDeclarationFieldIsReported is the property the decoder exists for.
|
|
//
|
|
// Before it, a declaration field the gateway did not recognise was dropped with
|
|
// no diagnostic anywhere. `ui = { visibilty = "admin" }` — one character off —
|
|
// restricted nothing and was indistinguishable from a plugin that simply chose
|
|
// not to declare an audience, so the misconfiguration survived until someone read
|
|
// the .lua by hand.
|
|
//
|
|
// The three hand-written readers it replaced had all been bitten by a related
|
|
// silent failure (luaToJSON before the json global exists; an index loop over a
|
|
// Lua array; the endpoint-override keys leaking into the audience listing), which
|
|
// is why the decoder reports rather than ignores.
|
|
func TestUnknownDeclarationFieldIsReported(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "typo.lua")
|
|
src := `local P = {}
|
|
P.name = "typo"
|
|
P.version = "1.0.0"
|
|
P.ui = { visibilty = "admin" }
|
|
return P
|
|
`
|
|
if err := os.WriteFile(path, []byte(src), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
vm := NewVM(filepath.Join(dir, "adapters"))
|
|
if err := vm.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(vm.Stop)
|
|
ps := NewPlugins(vm, filepath.Join(dir, "plugins"))
|
|
if err := ps.LoadSource("typo", src); err != nil {
|
|
t.Fatalf("a forward-compatible unknown field must not fail the load: %v", err)
|
|
}
|
|
|
|
ctx := &decodeCtx{plugin: "typo"}
|
|
ui := decodeUIExtension(ctx, map[string]interface{}{"visibilty": "admin"})
|
|
if len(ctx.problems) == 0 {
|
|
t.Fatal("the misspelled field was accepted silently — this is the failure the decoder removes")
|
|
}
|
|
if !strings.Contains(ctx.problems[0], "visibilty") {
|
|
t.Errorf("diagnostic does not name the offending field: %q", ctx.problems[0])
|
|
}
|
|
// And it must be actionable, not just a complaint.
|
|
if !strings.Contains(ctx.problems[0], "did you mean \"visibility\"") {
|
|
t.Errorf("diagnostic offers no fix: %q", ctx.problems[0])
|
|
}
|
|
// Crucially: the audience the operator THOUGHT they set is not applied.
|
|
if ui.Visibility != "" {
|
|
t.Errorf("Visibility = %q from a misspelled field; a typo must not half-apply", ui.Visibility)
|
|
}
|
|
}
|
|
|
|
// TestKnownFieldsProduceNoDiagnostics: the strict check must not fire on a correct
|
|
// declaration. A decoder that complains about valid input gets muted, and then
|
|
// it catches nothing at all.
|
|
func TestKnownFieldsProduceNoDiagnostics(t *testing.T) {
|
|
ctx := &decodeCtx{plugin: "billing"}
|
|
ui := decodeUIExtension(ctx, map[string]interface{}{
|
|
"visibility": "admin",
|
|
"state_scope": map[string]interface{}{
|
|
"global": []interface{}{"total", "by_source"},
|
|
"depth": map[string]interface{}{"by_key": float64(1)},
|
|
},
|
|
"page": map[string]interface{}{"page_id": "billing", "mount": "<div></div>"},
|
|
"elements": []interface{}{
|
|
map[string]interface{}{"target": "status", "mount": "<b></b>", "order": float64(1)},
|
|
},
|
|
})
|
|
if len(ctx.problems) != 0 {
|
|
t.Errorf("a correct declaration produced diagnostics: %v", ctx.problems)
|
|
}
|
|
if ui.Visibility != "admin" {
|
|
t.Errorf("Visibility = %q, want admin", ui.Visibility)
|
|
}
|
|
if ui.StateScope == nil || len(ui.StateScope.Global) != 2 {
|
|
t.Errorf("state_scope not decoded: %+v", ui.StateScope)
|
|
}
|
|
if ui.Page == nil || ui.Page.PageID != "billing" {
|
|
t.Errorf("page not decoded: %+v", ui.Page)
|
|
}
|
|
if len(ui.Elements) != 1 || ui.Elements[0].Target != "status" {
|
|
t.Errorf("elements not decoded: %+v", ui.Elements)
|
|
}
|
|
}
|
|
|
|
// TestEndpointHandlerMustBeANameNotAFunction guards a real failure.
|
|
//
|
|
// asString coerces anything non-string with fmt.Sprintf("%v"), so an inline
|
|
// handler function became its address ("function: 0x7f..."). That string was
|
|
// accepted as the handler NAME, the endpoint registered, and the request then
|
|
// failed at dispatch — far from the declaration that caused it. Rejecting the
|
|
// value at decode time names both the field and the path.
|
|
func TestEndpointHandlerMustBeANameNotAFunction(t *testing.T) {
|
|
dir := t.TempDir()
|
|
vm := NewVM(filepath.Join(dir, "adapters"))
|
|
if err := vm.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(vm.Stop)
|
|
ps := NewPlugins(vm, filepath.Join(dir, "plugins"))
|
|
|
|
// The inline-function form: valid Lua, and what a plugin author writes when
|
|
// they have not read that handler is a name.
|
|
src := `local P = {}
|
|
P.name = "inline"
|
|
P.version = "1.0.0"
|
|
P.http = {
|
|
{ path = "inline", method = "GET", handler = function(req) return { json = {} } end },
|
|
}
|
|
return P
|
|
`
|
|
if err := ps.LoadSource("inline", src); err != nil {
|
|
t.Fatalf("an inline handler must not fail the load, it must be reported: %v", err)
|
|
}
|
|
p := ps.find("inline")
|
|
if p == nil {
|
|
t.Fatal("plugin not found")
|
|
}
|
|
if len(p.http) != 0 {
|
|
t.Errorf("the inline-function endpoint was accepted: %+v", p.http)
|
|
}
|
|
|
|
// And the documented form still works.
|
|
src2 := `local P = {}
|
|
P.name = "byname"
|
|
P.version = "1.0.0"
|
|
P.http = {
|
|
{ path = "ok", method = "GET", roles = { "admin" }, handler = "ok" },
|
|
}
|
|
function P.ok(req) return { json = {} } end
|
|
return P
|
|
`
|
|
if err := ps.LoadSource("byname", src2); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p2 := ps.find("byname")
|
|
if p2 == nil || len(p2.http) != 1 {
|
|
t.Fatalf("the documented form regressed: %+v", p2)
|
|
}
|
|
if p2.http[0].Handler != "ok" || p2.http[0].Method != "GET" || len(p2.http[0].Roles) != 1 {
|
|
t.Errorf("endpoint decoded wrong: %+v", p2.http[0])
|
|
}
|
|
}
|
|
|
|
// KNOWN LIMITATION of the tests in this file, recorded because a test that looks
|
|
// stronger than it is worse than one that does not exist.
|
|
//
|
|
// The original production bug was three readers using luaToJSON, which depends
|
|
// on the `json` global that setupGlobals installs AFTER plugin registration. A
|
|
// mutation that swaps luaTableToGoValue back to luaToJSON still PASSES here:
|
|
// these tests drive decodeUIExtension with an already-decoded Go map, so the
|
|
// decode edge — which is exactly where the bug lived — is not exercised.
|
|
//
|
|
// Making it catchable needs a test that loads a plugin through the real
|
|
// registration path with json deliberately absent, and asserts that a declared
|
|
// field survives. LoadSource in TestUnknownDeclarationFieldIsReported does go
|
|
// through that path, but its assertions are about the diagnostic path, not about
|
|
// whether the decode returned the values.
|
|
//
|
|
// Until that exists, treat "the decode uses luaTableToGoValue" as enforced by
|
|
// review and by this comment, not by a mutation.
|
|
|
|
// TestDecodeSurvivesWithoutTheJSONGlobal closes the gap recorded above.
|
|
//
|
|
// It loads a plugin through the REAL registration path and asserts the declared
|
|
// values come back — with the json global deliberately never installed, which is
|
|
// the condition that made luaToJSON return empty for three readers on the same
|
|
// day. Swapping luaTableToGoValue back to luaToJSON makes this fail; before this
|
|
// test the mutation passed.
|
|
func TestDecodeSurvivesWithoutTheJSONGlobal(t *testing.T) {
|
|
dir := t.TempDir()
|
|
vm := NewVM(filepath.Join(dir, "adapters"))
|
|
if err := vm.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(vm.Stop)
|
|
ps := NewPlugins(vm, filepath.Join(dir, "plugins"))
|
|
|
|
src := `local P = {}
|
|
P.name = "nojson"
|
|
P.version = "1.0.0"
|
|
P.ui = {
|
|
visibility = "admin",
|
|
page = { page_id = "nojson", mount = "<div id='x'></div>" },
|
|
}
|
|
P.http = {
|
|
{ path = "ping", method = "GET", roles = { "admin" }, handler = "ping" },
|
|
}
|
|
function P.ping(req) return { json = { ok = true } } end
|
|
return P
|
|
`
|
|
if err := ps.LoadSource("nojson", src); err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
p := ps.find("nojson")
|
|
if p == nil {
|
|
t.Fatal("plugin not found after LoadSource")
|
|
}
|
|
if p.UI == nil {
|
|
t.Fatal("ui not read back")
|
|
}
|
|
if p.UI.Visibility != "admin" {
|
|
t.Errorf("UI.Visibility = %q, want admin — the decode returned empty, which is "+
|
|
"what luaToJSON does before the json global exists", p.UI.Visibility)
|
|
}
|
|
if p.UI.Page == nil || p.UI.Page.PageID != "nojson" {
|
|
t.Errorf("UI.Page not decoded: %+v", p.UI.Page)
|
|
}
|
|
if len(p.http) != 1 {
|
|
t.Fatalf("http endpoints = %d, want 1", len(p.http))
|
|
}
|
|
if p.http[0].Handler != "ping" || p.http[0].Method != "GET" {
|
|
t.Errorf("endpoint decoded wrong: %+v", p.http[0])
|
|
}
|
|
if len(p.http[0].Roles) != 1 || p.http[0].Roles[0] != "admin" {
|
|
t.Errorf("roles not decoded: %+v", p.http[0].Roles)
|
|
}
|
|
}
|