package lua import ( "errors" "os" "path/filepath" "strings" "testing" ) // TestRouteConflictDetectedAndArbitrated(用户要求 A): // 两个插件声明同一条 /api/plugins/http/ 服务路径 → 必须报冲突(列出双方), // 运维裁决后裁决生效;裁决持久化,重启(重新 LoadDir)后仍然生效。 // 全程不崩溃、不拒绝加载 —— 两个插件都保持 loaded。 func TestRouteConflictDetectedAndArbitrated(t *testing.T) { dir := t.TempDir() _, ps := bridgeVM(t) ps.dir = dir // a.lua 与 b.lua 都声明路径 "shared" const aSrc = ` local P = {} P.name = "plugA" P.version = "1" P.exports = {} P.http = { { path = "shared", handler = "h", roles = {"admin"} } } function P.h(req) return { status = 200, json = { from = "A" } } end return P ` const bSrc = ` local P = {} P.name = "plugB" P.version = "1" P.exports = {} P.http = { { path = "shared", handler = "h", roles = {"admin"} } } function P.h(req) return { status = 200, json = { from = "B" } } end return P ` for name, src := range map[string]string{"plugA": aSrc, "plugB": bSrc} { if err := ps.LoadSource(name, src); err != nil { t.Fatalf("加载 %s: %v", name, err) } } // ① 冲突被报出来,候选齐整,未裁决 cs := ps.RouteConflicts() if len(cs) != 1 { t.Fatalf("应恰有 1 条路径冲突, 实得 %d: %+v", len(cs), cs) } c := cs[0] if c.Path != "shared" { t.Fatalf("冲突路径应为 shared, 实得 %q", c.Path) } if len(c.Candidates) != 2 || c.Candidates[0] != "plugA" || c.Candidates[1] != "plugB" { t.Fatalf("候选应为 [plugA plugB], 实得 %v", c.Candidates) } if c.Resolved != "" { t.Fatalf("初始应未裁决, 实得 %q", c.Resolved) } // 两个插件都保持 loaded(不许因冲突拒绝启动/加载) if ps.List()[0]["loaded"] != true || ps.List()[1]["loaded"] != true { t.Fatal("冲突的两个插件都应保持 loaded") } // ② 非法裁决被拒(赢家必须确实是候选之一) if err := ps.ResolveRouteConflict("shared", "plugC"); err == nil { t.Fatal("赢家不是候选应被拒绝") } if err := ps.ResolveRouteConflict("other", "plugA"); err == nil { t.Fatal("未冲突的路径不应可裁决") } // ③ 合法裁决 + 持久化 if err := ps.ResolveRouteConflict("shared", "plugB"); err != nil { t.Fatalf("合法裁决失败: %v", err) } if ps.RouteWinner("shared") != "plugB" { t.Fatalf("裁决后赢家应为 plugB, 实得 %q", ps.RouteWinner("shared")) } if _, err := os.Stat(filepath.Join(dir, ".route-conflicts.json")); err != nil { t.Fatalf("裁决应落盘 .route-conflicts.json: %v", err) } // ④ 重启语义:新 registry 读回裁决 _, ps2 := bridgeVM(t) ps2.dir = dir if err := ps2.LoadRouteConflicts(); err != nil { t.Fatalf("读回裁决: %v", err) } if err := ps2.LoadSource("plugA", aSrc); err != nil { t.Fatal(err) } if err := ps2.LoadSource("plugB", bSrc); err != nil { t.Fatal(err) } cs2 := ps2.RouteConflicts() if len(cs2) != 1 || cs2[0].Resolved != "plugB" { t.Fatalf("重启后裁决应保留 plugB, 实得 %+v", cs2) } } // TestRouteConflictWinnerGoneBackToUnresolved:已裁决的赢家插件被删/改后不再 // 声明该路径 → 裁决作废回到未裁决态,而不是指向一个不存在的赢家。 func TestRouteConflictWinnerGoneBackToUnresolved(t *testing.T) { _, ps := bridgeVM(t) const aSrc = ` local P = {} P.name = "plugA" P.http = { { path = "s", handler = "h", roles = {"admin"} } } function P.h(req) return { status = 200, json = {} } end return P ` const bSrc = ` local P = {} P.name = "plugB" P.http = { { path = "s", handler = "h", roles = {"admin"} } } function P.h(req) return { status = 200, json = {} } end return P ` if err := ps.LoadSource("plugA", aSrc); err != nil { t.Fatal(err) } if err := ps.LoadSource("plugB", bSrc); err != nil { t.Fatal(err) } if err := ps.ResolveRouteConflict("s", "plugA"); err != nil { t.Fatal(err) } // plugA 被禁用 → 不再占路由 → 冲突消失(不是回未裁决) if err := ps.SetEnabled("plugA", false); err != nil { t.Fatal(err) } if got := ps.RouteConflicts(); len(got) != 0 { t.Fatalf("只 plugB 声明时不应再有冲突, 实得 %+v", got) } } // TestAdapterNameConflictSuspendsForArbitration(用户要求 B): // 插件安装与内置同名的适配器 → 不再硬拒绝,而是挂起待裁决: // - 现任内置继续服务(版本号不变) // - 返回 ErrAdapterConflictPending(typed,可区分「代码错」) // - 冲突出现在 AdapterConflicts() 列表 // - 运维 ResolveAdapterConflict("incumbent") → 现任保留、冲突清除 // - 运维 ResolveAdapterConflict("candidate") → 插件版真正安装、归属转移 // - 裁决持久化;重启后插件再装同名适配器自动套用上次裁决 func TestAdapterNameConflictSuspendsForArbitration(t *testing.T) { dir := t.TempDir() _, ps := bridgeVM(t) ps.dir = dir builtin := `local a={} a.name="openai" a.version="BUILTIN" a.endpoint="/x" return a` if err := ps.vm.LoadAdapterSource("openai", builtin); err != nil { t.Fatal(err) } evil := `local a={} a.name="openai" a.version="PLUGIN_V1" a.endpoint="/x" return a` // ① 安装挂起:现任保留,typed 错误,不静默覆盖 got, err := ps.InstallAdapter("sneaky", "openai", evil) if err == nil { t.Fatal("撞内置名应返回错误(挂起)") } var pend *ErrAdapterConflictPending if !errors.As(err, &pend) { t.Fatalf("错误类型应为 ErrAdapterConflictPending, got %T: %v", err, err) } if got != "" { t.Fatalf("挂起时不得返回生效名, 实得 %q", got) } if v := ps.vm.AdapterVersion("openai"); v != "BUILTIN" { t.Fatalf("现任内置必须继续服务, 版本实得 %q", v) } // ② 冲突可见 ac := ps.AdapterConflicts() if len(ac) != 1 || ac[0].Name != "openai" || ac[0].Candidate != "sneaky" { t.Fatalf("挂起冲突应可见: %+v", ac) } // ③ 裁决 incumbent → 现任保留 if err := ps.ResolveAdapterConflict("openai", "incumbent"); err != nil { t.Fatalf("裁决 incumbent: %v", err) } if v := ps.vm.AdapterVersion("openai"); v != "BUILTIN" { t.Fatalf("裁决 incumbent 后内置应保留, 实得 %q", v) } if got := ps.AdapterConflicts(); len(got) != 0 { t.Fatalf("裁决后冲突应清除, 实得 %+v", got) } // 归属仍是 operator(内置) if o := ps.AdapterOwnerOf("openai"); o != "" { t.Fatalf("内置归属应为空(operator), 实得 %q", o) } // ④ 重启后插件再装 → 之前的 incumbent 裁决自动套用(不再二次挂起,也不安装) if err := ps.LoadRouteConflicts(); err != nil { t.Fatal(err) } _, err = ps.InstallAdapter("sneaky", "openai", evil) var pend2 *ErrAdapterConflictPending if !errors.As(err, &pend2) { t.Fatalf("自动套用 incumbent 裁决应仍返回 pending 错误(未安装), got %v", err) } if v := ps.vm.AdapterVersion("openai"); v != "BUILTIN" { t.Fatalf("持久 incumbent 裁决应保持内置, 实得 %q", v) } // ⑤ 裁决 candidate → 插件版真正装上、归属转移、持久化 if err := ps.ResolveAdapterConflict("openai", "candidate"); err != nil { t.Fatalf("裁决 candidate: %v", err) } if v := ps.vm.AdapterVersion("openai"); v != "PLUGIN_V1" { t.Fatalf("裁决 candidate 后插件版应生效, 实得 %q", v) } if o := ps.AdapterOwnerOf("openai"); o != "sneaky" { t.Fatalf("归属应转移给 sneaky, 实得 %q", o) } // 持久化文件里是 candidate:sneaky b, _ := os.ReadFile(filepath.Join(dir, ".route-conflicts.json")) if !strings.Contains(string(b), "candidate:sneaky") { t.Fatalf("裁决应持久化为 candidate:sneaky, 文件: %s", b) } } // TestAdapterPluginConflictSuspendsForArbitration:插件 A 装了适配器后插件 B // 再装同名 → 挂起待裁决(与内置撞名同一机制),现任 A 的版本继续服务。 func TestAdapterPluginConflictSuspendsForArbitration(t *testing.T) { _, ps := bridgeVM(t) if _, err := ps.InstallAdapter("alpha", "shared_auth", tokenAdapterV1); err != nil { t.Fatal(err) } _, err := ps.InstallAdapter("beta", "shared_auth", tokenAdapterV2) var pend *ErrAdapterConflictPending if !errors.As(err, &pend) { t.Fatalf("B 装同名应挂起, got %v", err) } // tokenAdapterV1 声明 name="myauth",池落在生效名下(注册键 shared_auth)。 if v := ps.vm.AdapterVersion("myauth"); v != "1.0" { t.Fatalf("alpha 的现任版本应继续服务, 实得 %q", v) } ac := ps.AdapterConflicts() if len(ac) != 1 || ac[0].Incumbent != "plugin:alpha" { t.Fatalf("冲突应点名现任归属 plugin:alpha, 实得 %+v", ac) } // 裁决 candidate → beta 接管 if err := ps.ResolveAdapterConflict("shared_auth", "candidate"); err != nil { t.Fatal(err) } if v := ps.vm.AdapterVersion("myauth"); v != "2.0" { t.Fatalf("beta 版应生效, 实得 %q", v) } if o := ps.AdapterOwnerOf("myauth"); o != "beta" { t.Fatalf("归属应转移给 beta, 实得 %q", o) } } // TestAdapterConflictRejectsBadWinner:裁决值必须合法;无挂起冲突时拒绝。 func TestAdapterConflictRejectsBadWinner(t *testing.T) { _, ps := bridgeVM(t) if err := ps.ResolveAdapterConflict("nope", "candidate"); err == nil { t.Fatal("无挂起冲突应拒绝") } if _, err := ps.InstallAdapter("p", "myauth", tokenAdapterV1); err != nil { t.Fatal(err) } // myauth 是插件自己装的 → 更新路径(update 走 UpdateAdapter,非冲突) if err := ps.ResolveAdapterConflict("myauth", "bogus"); err == nil { t.Fatal("非法 winner 值应拒绝") } } // TestPluginOwnUpdateStillWorks:插件更新自己装的适配器(token 轮换)不走冲突 // 路径 —— 行为与改造前一致。守住「挂起只发生在撞名」这条边界。 func TestPluginOwnUpdateStillWorks(t *testing.T) { _, ps := bridgeVM(t) if _, err := ps.InstallAdapter("authtoken", "myauth", tokenAdapterV1); err != nil { t.Fatal(err) } if err := ps.UpdateAdapter("authtoken", "myauth", tokenAdapterV2); err != nil { t.Fatalf("own update 不应受影响: %v", err) } if v := ps.vm.AdapterVersion("myauth"); v != "2.0" { t.Fatalf("轮换后版本应 2.0, 实得 %q", v) } if got := ps.AdapterConflicts(); len(got) != 0 { t.Fatalf("own update 不应产生冲突, 实得 %+v", got) } }