mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
产品代码统一为纯文本风格(照参考实现),去掉 emoji:billing/quota/usage 插件、 channels 的提示文案、WebUI 文案,以及 go 测试、docs、packaging 脚本中的 注意/OK/FAIL 等符号化写法。第三方目录(vendor、node_modules、.probe)不动。 另:packaging/upload-release.sh 的仓库名改为真实全名 JianFeeeee/ModelRouter → HeNanMU-EEA/ModelRouter(前者是重定向,POST/PUT 不跟随 会 404)。
223 lines
7.8 KiB
Go
223 lines
7.8 KiB
Go
package core
|
||
|
||
import (
|
||
"encoding/json"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"llmsproxy/internal/lua"
|
||
)
|
||
|
||
// TestChannelsStoreIsPluginOwned 是「插件自持持久化」的回归。
|
||
//
|
||
// 背景:本插件声明 hooks = {},全部状态变更走自己的 HTTP 路由。此前 v2.0.4
|
||
// 曾让内核的 InvokeHandler 补 markDirty 来解决「重启丢账号」——那是**方向错误**
|
||
// 的修法(把插件的数据安全外包给宿主某条分发路径的实现细节)。参考实现
|
||
// (channel-pack-store.ts)踩过同一课:宿主 settings 能力一变,账号列表就退化
|
||
// 为纯内存,正解是插件自持文档。
|
||
//
|
||
// 本测试断言:插件把状态写进**自己的**存储文件,且新实例能读回。
|
||
func TestChannelsStoreIsPluginOwned(t *testing.T) {
|
||
// 固定插件目录:两次「重启」必须共用同一目录,否则测不出持久化。
|
||
// 这也是防止测试写进生产插件目录的那道隔离(kernelTestCore 会 respect 它)。
|
||
dir := t.TempDir()
|
||
t.Setenv("LLMSPROXY_PLUGIN_DIR", dir)
|
||
load := func() *lua.Plugins {
|
||
c := kernelTestCore(t)
|
||
ps := c.Plugins()
|
||
src, err := lua.ReadBundledPlugin("channels")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := ps.LoadSource("channels", src); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
_ = ps.SetPluginKernelPermission("channels",
|
||
[]string{"add_source", "list_sources", "remove_source", "status"}, false)
|
||
return ps
|
||
}
|
||
|
||
ps := load()
|
||
// 加账号 → 状态变更 → 应立即落盘
|
||
add, err := ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
|
||
Method: "POST", Body: `{"platform":"buddy","nickname":"持久化测试"}`, Role: "admin",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("account_add: %v", err)
|
||
}
|
||
aid := add.JSON.(map[string]interface{})["account_id"].(string)
|
||
|
||
// ① 插件自有存储文件必须存在
|
||
storePath := filepath.Join(dir, ".channels-store.json")
|
||
b, err := os.ReadFile(storePath)
|
||
if err != nil {
|
||
t.Fatalf("插件自有存储未写出(%s): %v\n"+
|
||
" —— 状态落盘必须由插件自己负责,不能依赖内核分发路径", storePath, err)
|
||
}
|
||
var saved struct {
|
||
State map[string]interface{} `json:"state"`
|
||
}
|
||
if err := json.Unmarshal(b, &saved); err != nil {
|
||
t.Fatalf("存储文件不是合法 JSON: %v", err)
|
||
}
|
||
pool, _ := saved.State["pool"].(map[string]interface{})
|
||
buddy, _ := pool["buddy"].(map[string]interface{})
|
||
if _, ok := buddy[aid]; !ok {
|
||
t.Fatalf("存储里应含账号 %s,实得 pool=%v", aid, pool)
|
||
}
|
||
|
||
// ② 凭据绝不进这个文件(它在 .secrets/ 保险库,0600)
|
||
if s, _ := saved.State["credentials"].(map[string]interface{}); len(s) > 0 {
|
||
t.Fatal("自有存储不得包含凭据(凭据只进 .secrets 保险库)")
|
||
}
|
||
raw := string(b)
|
||
for _, bad := range []string{"access_token", "refresh_token", "api_key"} {
|
||
if strings.Contains(raw, bad) {
|
||
t.Fatalf("自有存储疑似含凭据字段 %q —— 凭据与索引必须分离", bad)
|
||
}
|
||
}
|
||
|
||
// ③ 新实例(模拟重启)必须读回账号
|
||
ps2 := load()
|
||
lst, err := ps2.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
|
||
Method: "POST", Body: `{"platform":"buddy"}`, Role: "admin",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("account_list: %v", err)
|
||
}
|
||
found := false
|
||
for _, raw := range asArr(lst.JSON.(map[string]interface{})["accounts"]) {
|
||
if raw.(map[string]interface{})["id"] == aid {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatalf("重启后账号 %s 未恢复 —— 持久化未生效", aid)
|
||
}
|
||
}
|
||
|
||
// TestChannelsMigratesLegacyKernelState 回归:升级时把内核遗留文件
|
||
// (.channels.state.json,v2.0.4 内核写的)迁移到自有存储并删除遗留文件。
|
||
//
|
||
// 迁移必须真的搬数据(生产上那份文件含真实账号池),且**写成功才删**遗留 ——
|
||
// 写失败还删它,等于用一次迁移把数据弄丢。
|
||
func TestChannelsMigratesLegacyKernelState(t *testing.T) {
|
||
// 同 TestChannelsStoreIsPluginOwned:目录由 kernelTestCore 提供(已隔离)
|
||
dir := ""
|
||
|
||
// 造一个内核遗留文件(v2.0.4 的形状)
|
||
legacy := map[string]interface{}{
|
||
"version": 1,
|
||
"state": map[string]interface{}{
|
||
"accounts": map[string]interface{}{},
|
||
"log": []interface{}{},
|
||
"pool": map[string]interface{}{
|
||
"buddy": map[string]interface{}{
|
||
"legacy-acct": map[string]interface{}{
|
||
"id": "legacy-acct", "provider": "buddy", "nickname": "遗留账号",
|
||
"enabled": true, "credentialRef": "channels:buddy:legacy-acct",
|
||
"createdAt": 1, "last_claim": 0, "last_claim_status": 0, "last_claim_summary": "",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
b, _ := json.Marshal(legacy)
|
||
|
||
c := kernelTestCore(t)
|
||
dir = c.Config().PluginDir
|
||
legacyPath := filepath.Join(dir, ".channels.state.json")
|
||
if err := os.WriteFile(legacyPath, b, 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
ps := c.Plugins()
|
||
src, err := lua.ReadBundledPlugin("channels")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := ps.LoadSource("channels", src); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// ① 遗留账号必须被迁移进内存(可见)
|
||
lst, err := ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
|
||
Method: "POST", Body: `{"platform":"buddy"}`, Role: "admin",
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
found := false
|
||
for _, raw := range asArr(lst.JSON.(map[string]interface{})["accounts"]) {
|
||
if raw.(map[string]interface{})["id"] == "legacy-acct" {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatal("遗留账号未迁移 —— 升级即丢账号")
|
||
}
|
||
|
||
// ② 自有存储必须已写出
|
||
if _, err := os.Stat(filepath.Join(dir, ".channels-store.json")); err != nil {
|
||
t.Fatalf("迁移后自有存储应存在: %v", err)
|
||
}
|
||
// ③ 遗留文件必须被删除(否则内核 restore() 会用它覆盖自有存储)
|
||
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
|
||
t.Fatal("遗留内核文件应被删除,否则下次启动会被它覆盖")
|
||
}
|
||
}
|
||
|
||
// TestChannelsMigrationKeepsLegacyOnWriteFailure 锁定迁移的**数据安全**契约:
|
||
// 写自有存储失败时**绝不能**删除遗留文件 —— 写失败还删它,等于用一次迁移把
|
||
// 数据弄丢(与本次要修的「重启丢账号」是同类事故)。
|
||
//
|
||
// 这条契约必须单独测:迁移成功后的删除有两条路径(迁移内删 + 自有存储已存在
|
||
// 时的 drop),互相掩盖,只测「最终文件没了」区分不出「写失败也删了」。
|
||
func TestChannelsMigrationKeepsLegacyOnWriteFailure(t *testing.T) {
|
||
// 强制写失败的手段:把 persistState 要写的临时文件路径
|
||
// (<dir>/.channels-store.json.tmp)**占成一个目录**,io.open(tmp,"w") 必失败。
|
||
// 不用 chmod:测试以 root 跑,root 绕过权限位,chmod 拦不住。
|
||
sub := t.TempDir()
|
||
t.Setenv("LLMSPROXY_PLUGIN_DIR", sub)
|
||
|
||
legacy := map[string]interface{}{
|
||
"version": 1,
|
||
"state": map[string]interface{}{
|
||
"pool": map[string]interface{}{
|
||
"buddy": map[string]interface{}{
|
||
"keepme": map[string]interface{}{
|
||
"id": "keepme", "provider": "buddy",
|
||
"credentialRef": "channels:buddy:keepme", "createdAt": 1,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
b, _ := json.Marshal(legacy)
|
||
legacyPath := filepath.Join(sub, ".channels.state.json")
|
||
if err := os.WriteFile(legacyPath, b, 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.MkdirAll(filepath.Join(sub, ".channels-store.json.tmp"), 0o700); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
c := kernelTestCore(t)
|
||
ps := c.Plugins()
|
||
src, err := lua.ReadBundledPlugin("channels")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
_ = ps.LoadSource("channels", src)
|
||
|
||
// 写失败的态下,遗留文件必须原样保留
|
||
if _, err := os.Stat(legacyPath); err != nil {
|
||
t.Fatalf("写自有存储失败时遗留文件被删了 —— 数据被一次迁移弄丢: %v", err)
|
||
}
|
||
// 且自有存储不应存在(写失败了)
|
||
if _, err := os.Stat(filepath.Join(sub, ".channels-store.json")); err == nil {
|
||
t.Fatal("tmp 路径被占住时不应写出自有存储")
|
||
}
|
||
}
|