mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 20:27:23 +00:00
## 真实缺陷(我自己在上一个提交里引入,部署后实测踩到) handler 开头照其它 18 处写了 `if args.platform == nil then args = req end`。 但本函数**不传 platform 是合法用法**(清全部平台)⇒ args.platform 自然为 nil ⇒ args 被换成 HandlerRequest ⇒ 刚解出来的 dry_run 就丢了 ⇒ **传 dry_run=false 却被当成未传,静默只预演**。 破坏性操作里这是最糟的形态:操作者以为执行了、实际没执行(反过来同样危险)。 实测:不带 platform 时 deleted 恒为 nil,池子一个没动。 ## 修法 兜底只认「req 本身就是参数表」这种形态(`req.body == nil and req.query == nil`), 不再用「某字段为 nil」当判据 —— 那个字段为 nil 可能只是本次调用**合法地没用它**。 ## ★ 判据为什么第一版没咬住 我原来的夹具用 **map body** 调 Call,而缺陷只在**字符串 body**(HTTP 真实路径) 上暴露 —— `bodyOf` 对 table 直接返回,根本走不到回退那行。 补 `TestAccountClearCredentiallessRespectsDryRunViaStringBody`(用 `InvokeHandler` + 字符串 body + 不传 platform)后变异才咬住。 **教训:夹具的调用形态必须与生产一致 —— 用 map 便捷调用会绕过 body 解析路径。** 判据 4 条(含本条)+ 变异 3/3。
236 lines
8.1 KiB
Go
236 lines
8.1 KiB
Go
package core
|
||
|
||
import (
|
||
"testing"
|
||
|
||
"llmsproxy/internal/lua"
|
||
)
|
||
|
||
// ★ 无凭据账号的批量清理:**只删没有凭据的**,有凭据的一律不动。
|
||
//
|
||
// 场景(真实发生):`account_add` 允许不带凭据创建账号(设计如此 —— 先建占位、
|
||
// 登录补齐),于是登录没走完时留下"只有元数据、永远不可用"的账号,占着账号计数,
|
||
// 让"这个平台到底能不能用"难以判断。实测生产 13 个账号里只有 1 个真可用
|
||
// (codearts 10 + trae 2 全是占位)。
|
||
//
|
||
// 判据看的是**清理后的真实池状态**,而不是"函数被调用过"。
|
||
func TestAccountClearCredentiallessOnlyRemovesEmptyOnes(t *testing.T) {
|
||
h := newAccountCleanupHarness(t)
|
||
|
||
// ① 默认 dry_run:必须**不删**任何东西
|
||
pre, err := h.ps.Call("channels", "account_clear_credentialless", map[string]interface{}{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
preJ := jsonOf(t, pre)
|
||
if preJ["dry_run"] != true {
|
||
t.Fatalf("默认必须 dry_run(破坏性操作不能默认执行),实得 %v", preJ["dry_run"])
|
||
}
|
||
// 夹具放了 4 个 codearts(3 空壳 + 1 有凭据)
|
||
if got := len(accountIDs(t, h, "codearts")); got != 4 {
|
||
t.Fatalf("dry_run 不该删任何账号,codearts 仍有 %d 个(want 4)", got)
|
||
}
|
||
// 不传 platform ⇒ 全部平台:codearts 3 空壳 + trae 2 空壳 = 5
|
||
if wd, _ := preJ["would_delete"].(float64); int(wd) != 5 {
|
||
t.Fatalf("预演应报告将删 5 个(全平台),实得 %v", preJ["would_delete"])
|
||
}
|
||
if kc, _ := preJ["kept_with_credential"].(float64); int(kc) != 1 {
|
||
t.Fatalf("应报告保留 1 个有凭据账号,实得 %v", preJ["kept_with_credential"])
|
||
}
|
||
|
||
// ② 真执行:删无凭据的,**保留有凭据的**
|
||
if _, err := h.ps.Call("channels", "account_clear_credentialless",
|
||
map[string]interface{}{"dry_run": false}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := len(accountIDs(t, h, "codearts")); got != 1 {
|
||
t.Fatalf("应只剩 1 个有凭据账号,实得 %d", got)
|
||
}
|
||
// ★ 关键:留下的必须是**有凭据的那个**
|
||
left := accountIDs(t, h, "codearts")
|
||
if len(left) != 1 || left[0] != "withcred" {
|
||
t.Fatalf("留下的必须是有凭据的账号 withcred,实得 %v", left)
|
||
}
|
||
// 有凭据的账号其凭据也必须仍在(不能被误删)
|
||
if !h.hasCredential("withcred") {
|
||
t.Fatal("有凭据账号的凭据被误删了")
|
||
}
|
||
t.Logf("✓ 删 3 保 1,且有凭据账号的凭据完好")
|
||
}
|
||
|
||
// 指定平台时只动那个平台,别的不碰。
|
||
func TestAccountClearCredentiallessScopedToPlatform(t *testing.T) {
|
||
h := newAccountCleanupHarness(t)
|
||
|
||
if _, err := h.ps.Call("channels", "account_clear_credentialless",
|
||
map[string]interface{}{"platform": "codearts", "dry_run": false}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// codearts 被清(剩 1),trae 的 2 个占位**不该被动**
|
||
if got := len(accountIDs(t, h, "codearts")); got != 1 {
|
||
t.Fatalf("codearts 应剩 1,实得 %d", got)
|
||
}
|
||
if got := len(accountIDs(t, h, "trae")); got != 2 {
|
||
t.Fatalf("指定 platform=codearts 时不该动 trae(应仍 2),实得 %d", got)
|
||
}
|
||
t.Log("✓ 平台范围隔离正确")
|
||
}
|
||
|
||
// 未知平台必须报错,不能静默当成"全部平台"(否则会误删别的平台)。
|
||
func TestAccountClearCredentiallessRejectsUnknownPlatform(t *testing.T) {
|
||
h := newAccountCleanupHarness(t)
|
||
res, err := h.ps.Call("channels", "account_clear_credentialless",
|
||
map[string]interface{}{"platform": "no-such-platform", "dry_run": false})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
j := jsonOf(t, res)
|
||
if j["error"] == nil {
|
||
t.Fatalf("未知平台必须报错(否则会误删全部平台),实得 %v", j)
|
||
}
|
||
// 且什么都没删
|
||
if got := len(accountIDs(t, h, "codearts")); got != 4 {
|
||
t.Fatalf("报错时不该删任何东西,codearts 实得 %d(want 4)", got)
|
||
}
|
||
}
|
||
|
||
// ── harness ──
|
||
|
||
type accountCleanupHarness struct {
|
||
t *testing.T
|
||
ps *lua.Plugins
|
||
}
|
||
|
||
func newAccountCleanupHarness(t *testing.T) *accountCleanupHarness {
|
||
t.Helper()
|
||
c := kernelTestCore(t)
|
||
ps := c.Plugins()
|
||
src, err := readBundledChannels(t)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := ps.LoadSource("channels", src); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
h := &accountCleanupHarness{t: t, ps: ps}
|
||
// 3 个无凭据占位 + 1 个有凭据账号(codearts);另 2 个 trae 占位
|
||
for _, id := range []string{"empty1", "empty2", "empty3"} {
|
||
h.add("codearts", id, "")
|
||
}
|
||
h.add("codearts", "withcred", `{"access_key_id":"AK","secret_access_key":"SK"}`)
|
||
h.add("trae", "tempty1", "")
|
||
h.add("trae", "tempty2", "")
|
||
return h
|
||
}
|
||
|
||
func (h *accountCleanupHarness) add(platform, id, cred string) {
|
||
h.t.Helper()
|
||
args := map[string]interface{}{"platform": platform, "account_id": id}
|
||
if cred != "" {
|
||
args["credential"] = cred
|
||
}
|
||
if _, err := h.ps.Call("channels", "account_add", args); err != nil {
|
||
h.t.Fatalf("account_add(%s/%s): %v", platform, id, err)
|
||
}
|
||
}
|
||
|
||
func (h *accountCleanupHarness) hasCredential(accountID string) bool {
|
||
h.t.Helper()
|
||
res, err := h.ps.Call("channels", "account_list",
|
||
map[string]interface{}{"platform": "codearts"})
|
||
if err != nil {
|
||
h.t.Fatal(err)
|
||
}
|
||
for _, a := range accountsOf(h.t, res) {
|
||
if a["id"] == accountID {
|
||
return a["has_credential"] == true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// jsonOf 取插件调用返回的 {json:{...}} 里那层 json。
|
||
func jsonOf(t *testing.T, got interface{}) map[string]interface{} {
|
||
t.Helper()
|
||
m, ok := got.(map[string]interface{})
|
||
if !ok {
|
||
t.Fatalf("返回值不是对象: %#v", got)
|
||
}
|
||
j, ok := m["json"].(map[string]interface{})
|
||
if !ok {
|
||
t.Fatalf("返回值缺 json 层: %#v", m)
|
||
}
|
||
return j
|
||
}
|
||
|
||
// accountsOf 取 account_list 返回的账号数组。
|
||
func accountsOf(t *testing.T, got interface{}) []map[string]interface{} {
|
||
t.Helper()
|
||
j := jsonOf(t, got)
|
||
arr, _ := j["accounts"].([]interface{})
|
||
out := make([]map[string]interface{}, 0, len(arr))
|
||
for _, e := range arr {
|
||
if m, ok := e.(map[string]interface{}); ok {
|
||
out = append(out, m)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// accountIDs 列某平台的账号 id。
|
||
func accountIDs(t *testing.T, h *accountCleanupHarness, platform string) []string {
|
||
t.Helper()
|
||
res, err := h.ps.Call("channels", "account_list",
|
||
map[string]interface{}{"platform": platform})
|
||
if err != nil {
|
||
t.Fatalf("account_list(%s): %v", platform, err)
|
||
}
|
||
var out []string
|
||
for _, a := range accountsOf(t, res) {
|
||
if id, ok := a["id"].(string); ok {
|
||
out = append(out, id)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ★ 字符串 body(真实 HTTP 路径)+ 不传 platform 时,dry_run 必须被尊重。
|
||
//
|
||
// 反例(实测踩到,我自己写的):照其它 handler 写
|
||
//
|
||
// `if args.platform == nil then args = req end`
|
||
//
|
||
// —— 而"不传 platform"在本函数里是**合法用法**(清全部平台),于是 args 被换成
|
||
// HandlerRequest,body 里解出来的 dry_run 就丢了 ⇒ 传 dry_run=false 却被当成
|
||
// 未传,静默只预演。**破坏性操作里最糟的形态**:操作者以为执行了、实际没执行。
|
||
//
|
||
// 夹具必须用**字符串 body**(我第一版传 map,走不到这条路径,所以没咬住)。
|
||
func TestAccountClearCredentiallessRespectsDryRunViaStringBody(t *testing.T) {
|
||
h := newAccountCleanupHarness(t)
|
||
|
||
// 不带 platform + dry_run=false,且 body 是**字符串**(HTTP 真实形态)
|
||
res, err := h.ps.InvokeHandler("channels", "account_clear_credentialless",
|
||
&lua.HandlerRequest{Method: "POST", Body: `{"dry_run":false}`})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if res.Status != 200 {
|
||
t.Fatalf("status=%d body=%v", res.Status, res.Body)
|
||
}
|
||
j, _ := res.JSON.(map[string]interface{})
|
||
if j["dry_run"] == true {
|
||
t.Fatalf("字符串 body 里传了 dry_run=false 却被当成未传(回退吃掉 body): %v", j)
|
||
}
|
||
if j["deleted"] == nil {
|
||
t.Fatalf("dry_run=false 时必须真的执行(应有 deleted 字段): %v", j)
|
||
}
|
||
// 全平台的 5 个空壳都被删(codearts 3 + trae 2)
|
||
if got := len(accountIDs(t, h, "codearts")); got != 1 {
|
||
t.Fatalf("codearts 应剩 1(有凭据那个),实得 %d", got)
|
||
}
|
||
if got := len(accountIDs(t, h, "trae")); got != 0 {
|
||
t.Fatalf("trae 的 2 个空壳应被删,实得 %d", got)
|
||
}
|
||
t.Logf("✓ 字符串 body 且无 platform 时 dry_run=false 生效: deleted=%v", j["deleted"])
|
||
}
|