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 要写的临时文件路径 // (