Files
ModelRouter/internal/core/channels_store_test.go
JianFeeeee 688dce88d1 refactor(rename)!: llmsproxy → modelrouter(module / 二进制 / 路径 / env)
产品名早已是 ModelRouter,但仓库标识符仍沿用旧名。这次统一,读写两侧都做兼容,
已部署的机器不会因为顺序问题立刻失效。

- go module `llmsproxy` → `modelrouter`;全仓 import 与 `cmd/modelrouter` 同步。
- `Makefile BINARY=modelrouter`;发行包名、systemd unit、/usr/share 资源、
  `/usr/local/bin` 目标全部改为 modelrouter。
- 默认配置目录 `/etc/llmsproxy` → `/etc/modelrouter`。**读取侧回退**:插件与
  `storeDir` 仍会检查旧路径,所以"先换二进制、后改 unit"不会立即失效。
- 环境变量 `LLMSPROXY_*`(PLUGIN_DIR / XDISPLAY_BASE / ARCH / LIVE_ZEN /
  BUILD_DIR)改为 `MODELROUTER_*`;`LLMS_PROXY_MASTER_KEY` →
  `MODELROUTER_MASTER_KEY`。旧名在读取侧继续接受。
- 桌面端内嵌核心 `bin/llmsproxy` → `bin/modelrouter`,并保留旧文件名回退,
  避免升级到一半的 profile 被判成"core binary missing"。
- 新增 `packaging/migrate-to-modelrouter.sh`:**默认 dry-run**,复制而非移动、
  旧目录永不删除、支持 `--rollback`;随 release tar 分发并由 core-dist 校验。
- `deploy.sh` 增加改名保护:发现旧 `/etc/llmsproxy` 而新配置不存在时**中止**,
  否则会部署出一个"能启动但没有 key、没有源"的空实例。
- 临时目录前缀 `/tmp/llmsproxy-*` → `/tmp/modelrouter-*`;清理逻辑同时回收两代。

兼容别名(`__llmsproxy_*` 全局、`llmsproxy` SDK 别名、旧路径/旧 env)刻意保留,
它们是升级路径而非遗留;docs/plugins.md 已标注为废弃。

本提交未包含:`adapters/opencodezen.lua`、`adapter_opencodezen_test.go`、
`adapter_opencodezen_live_test.go`(OpenCode UA/免费池的既有改动,另批提交)。
2026-10-26 22:28:08 +08:00

227 lines
7.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"modelrouter/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("MODELROUTER_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
// 网关不再自动创建 plugin_dir(插件不由内核安装),测试自己建。
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
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("MODELROUTER_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 路径被占住时不应写出自有存储")
}
}