Files
ModelRouter/internal/core/channels_more_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

406 lines
14 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 (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"modelrouter/internal/lua"
)
// channelsMoreTest:新引入的三条真实流程 + 两个边界流的行为对齐测试。
// 平台定义、决策表、端点逐字照抄参考(见 channels.lua 注释),测试照参考行为搬。
// ---- Cline(WorkOS 设备码 + register 两步) ----
// clineHarness 假上游:device code / authenticate / register 三端点。
type clineHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
tok atomic.Value // 当前 authenticate 响应脚本 []scriptStep
step int32
}
func newClineHarness(t *testing.T) *clineHarness {
t.Helper()
h := &clineHarness{t: t}
h.tok.Store([]scriptStep{})
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/user_management/authorize/device":
fmt.Fprint(w, `{"device_code":"CD1","user_code":"WXYZ-1234","verification_uri":"https://example/activate","interval":1,"expires_in":600}`)
case "/user_management/authenticate":
i := int(atomic.AddInt32(&h.step, 1)) - 1
s := h.tok.Load().([]scriptStep)
if i >= len(s) {
w.WriteHeader(500)
fmt.Fprint(w, `{"error":"unexpected"}`)
return
}
if s[i].http != 0 && s[i].http != 200 {
w.WriteHeader(s[i].http)
}
fmt.Fprint(w, s[i].body)
case "/api/v1/auth/register":
// 校验请求体是驼峰 accessToken/refreshToken(参考 registerClineTokens)
b, _ := io.ReadAll(r.Body)
for _, want := range []string{`"accessToken":"WO-AT-1"`, `"refreshToken":"WO-RT-1"`} {
if !strings.Contains(string(b), want) {
t.Errorf("register 请求体缺 %s: %s", want, b)
}
}
fmt.Fprint(w, `{"success":true,"data":{"accessToken":"CLINE-AT-1","refreshToken":"CLINE-RT-1","userInfo":{"clineUserId":"u1","email":"dev@example.com"}}}`)
default:
w.WriteHeader(404)
}
}))
t.Cleanup(h.ts.Close)
c := kernelTestCore(t)
h.ps = c.Plugins()
src, _ := readBundledChannels(t)
if err := h.ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "cline",
"device": map[string]interface{}{"code_url": h.ts.URL + "/user_management/authorize/device", "token_url": h.ts.URL + "/user_management/authenticate"},
"register": map[string]interface{}{"url": h.ts.URL + "/api/v1/auth/register"},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
return h
}
func (h *clineHarness) begin() map[string]interface{} {
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"cline"}`,
})
if err != nil {
h.t.Fatal(err)
}
return out.JSON.(map[string]interface{})
}
func (h *clineHarness) poll(id string) map[string]interface{} {
out, _ := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": id},
})
return out.JSON.(map[string]interface{})
}
// runWorkosTable 是 WorkOS(Cline)分支的行为契约,供变异测试杀 workos 变异体。
// 决策表 5 行,跟 TestClineWorkOSBehavior 同源(这里只返回布尔,不报细节)。
func (h *clineHarness) runWorkosTable() bool {
st := func(steps ...string) map[string]interface{} {
h.tok.Store(scriptSteps(steps...))
atomic.StoreInt32(&h.step, 0)
j := h.begin()
sid := j["session"].(map[string]interface{})["id"].(string)
return h.poll(sid)
}
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false)
// 行1: 非200+error=authorization_pending → 等
if j := st(`400 {"error":"authorization_pending"}`); j["state"] != "pending" {
return false
}
// 行2: 非200+error=slow_down → 等 + interval+1(基线 1)
h.tok.Store(scriptSteps(`400 {"error":"slow_down"}`))
atomic.StoreInt32(&h.step, 0)
j := h.begin()
sid := j["session"].(map[string]interface{})["id"].(string)
if j2 := h.poll(sid); j2["state"] != "pending" {
return false
} else if iv, _ := j2["interval"].(float64); iv != 2 {
return false
}
// 行3: error=access_denied → failed
if j := st(`400 {"error":"access_denied","error_description":"user declined"}`); j["state"] != "failed" {
return false
}
// 行4: 成功(access+refresh)→ register 换 CLINE token → completed
if j := st(`{"access_token":"WO-AT-1","refresh_token":"WO-RT-1"}`); j["state"] != "completed" || j["account"] != "dev@example.com" {
return false
}
v, _, _ := h.ps.GetSecret("channels", "channels:cline")
if v != "CLINE-AT-1" {
return false
}
// 行5: 非200无error字段 → failed(不是死等)
if j := st(`500 {"foo":"bar"}`); j["state"] != "failed" {
return false
}
return true
}
// TestClineWorkOSBehavior:WorkOS 判据(error 字段)+ register 两步 + slow_down +1s。
func TestClineWorkOSBehavior(t *testing.T) {
h := newClineHarness(t)
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false)
st := func(steps ...string) map[string]interface{} {
h.tok.Store(scriptSteps(steps...))
atomic.StoreInt32(&h.step, 0)
j := h.begin()
sid := j["session"].(map[string]interface{})["id"].(string)
return h.poll(sid)
}
t.Run("行1 error=authorization_pending_非200也继续等", func(t *testing.T) {
if j := st(`400 {"error":"authorization_pending"}`); j["state"] != "pending" {
t.Fatalf("应 pending: %v", j)
}
})
t.Run("行2 error=slow_down_退避1秒", func(t *testing.T) {
h.tok.Store(scriptSteps(`400 {"error":"slow_down"}`))
atomic.StoreInt32(&h.step, 0)
j := h.begin()
sid := j["session"].(map[string]interface{})["id"].(string)
j2 := h.poll(sid)
if j2["state"] != "pending" {
t.Fatalf("应 pending: %v", j2)
}
if iv, _ := j2["interval"].(float64); iv != 2 { // 基线 1 + slow_down_step 1
t.Fatalf("WorkOS slow_down 应 interval+1s 得 2, 实得 %v", iv)
}
})
t.Run("行3 error=access_denied_失败带拒绝", func(t *testing.T) {
if j := st(`400 {"error":"access_denied","error_description":"user declined"}`); j["state"] != "failed" {
t.Fatalf("应 failed: %v", j)
}
})
t.Run("行4 成功_access+refresh_再打register_凭据是CLINE-AT-1", func(t *testing.T) {
j := st(`{"access_token":"WO-AT-1","refresh_token":"WO-RT-1"}`)
if j["state"] != "completed" || j["account"] != "dev@example.com" {
t.Fatalf("应 completed+dev@example.com: %v", j)
}
v, _, _ := h.ps.GetSecret("channels", "channels:cline")
if v != "CLINE-AT-1" {
t.Fatalf("凭据应是 register 的 CLINE-AT-1, 实得 %q", v)
}
})
t.Run("行5 非200无error字段_失败", func(t *testing.T) {
if j := st(`500 {"foo":"bar"}`); j["state"] != "failed" {
t.Fatalf("应 failed: %v", j)
}
})
}
// ---- Buddy(CodeBuddy state 轮询) ----
type buddyHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
tokStep int32
tokOk int32
acctOk int32
}
func newBuddyHarness(t *testing.T) *buddyHarness {
t.Helper()
h := &buddyHarness{t: t, tokOk: 1, acctOk: 1}
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == "POST" && r.URL.Path == "/v2/plugin/auth/state":
fmt.Fprint(w, `{"data":{"state":"ST-1","authUrl":"https://www.codebuddy.cn/login/?platform=ide&state=ST-1"}}`)
case r.Method == "GET" && r.URL.Path == "/v2/plugin/auth/token":
i := atomic.AddInt32(&h.tokStep, 1)
if atomic.LoadInt32(&h.tokOk) == 0 {
w.WriteHeader(200)
fmt.Fprint(w, `{"code":11217,"message":"token not ready"}`)
return
}
if i == 1 {
fmt.Fprint(w, `{"code":11217,"message":"token not ready"}`)
} else {
fmt.Fprint(w, `{"data":{"accessToken":"BD-AT-1","refreshToken":"BD-RT-1"}}`)
}
case r.Method == "GET" && r.URL.Path == "/v2/plugin/login/account":
if atomic.LoadInt32(&h.acctOk) == 0 {
w.WriteHeader(500)
fmt.Fprint(w, `{"code":1}`)
return
}
fmt.Fprint(w, `{"data":{"nickName":"buddy-dev"}}`)
default:
w.WriteHeader(404)
}
}))
t.Cleanup(h.ts.Close)
c := kernelTestCore(t)
h.ps = c.Plugins()
src, _ := readBundledChannels(t)
if err := h.ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "buddy",
"state": map[string]interface{}{"state_url": h.ts.URL + "/v2/plugin/auth/state", "token_url": h.ts.URL + "/v2/plugin/auth/token", "account_url": h.ts.URL + "/v2/plugin/login/account"},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
return h
}
func (h *buddyHarness) begin() (map[string]interface{}, string) {
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"buddy"}`,
})
if err != nil {
h.t.Fatal(err)
}
j := out.JSON.(map[string]interface{})
return j, j["session"].(map[string]interface{})["id"].(string)
}
// beginPool 走 UI 的真实流程:先 account/add 建占位账号,再带 account_id 登录。
// 这正是用户报障("登录了但无法获取账号信息")的那条路径。
func (h *buddyHarness) beginPool() (map[string]interface{}, string, string) {
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"buddy"}`,
})
if err != nil {
h.t.Fatal(err)
}
aid := add.JSON.(map[string]interface{})["account_id"].(string)
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: fmt.Sprintf(`{"platform":"buddy","account_id":%q}`, aid),
})
if err != nil {
h.t.Fatal(err)
}
j := out.JSON.(map[string]interface{})
return j, j["session"].(map[string]interface{})["id"].(string), aid
}
func (h *buddyHarness) accountList() []interface{} {
out, err := h.ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"buddy"}`,
})
if err != nil {
h.t.Fatal(err)
}
m := out.JSON.(map[string]interface{})
accts, _ := m["accounts"].([]interface{})
return accts
}
func (h *buddyHarness) poll(id string) map[string]interface{} {
out, _ := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": id},
})
return out.JSON.(map[string]interface{})
}
// TestBuddyStatePollBehavior:state-poll 两拍(先 11217 后 token)+ 账号查询 + account 失败不阻塞。
func TestBuddyStatePollBehavior(t *testing.T) {
h := newBuddyHarness(t)
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false)
t.Run("行1 state申请给出authUrl", func(t *testing.T) {
j, _ := h.begin()
if j["url"] == "" {
t.Fatalf("应有 authUrl: %v", j)
}
})
t.Run("行2 首拍11217仍pending_次拍token_completed", func(t *testing.T) {
atomic.StoreInt32(&h.tokStep, 0)
_, sid := h.begin()
if j := h.poll(sid); j["state"] != "pending" {
t.Fatalf("首拍应 pending: %v", j)
}
if j := h.poll(sid); j["state"] != "completed" || j["account"] != "buddy-dev" {
t.Fatalf("次拍应 completed+buddy-dev: %v", j)
}
// 凭据是**完整凭据 JSON**(对齐参考 buildCredential),不再是裸 token:
// 必须带 refresh_token,否则 refreshable=false、无法静默续期。
v, _, _ := h.ps.GetSecret("channels", "channels:buddy")
if !strings.Contains(v, `"access_token":"BD-AT-1"`) {
t.Fatalf("凭据应含 access_token, 实得 %q", v)
}
if !strings.Contains(v, `"refresh_token":"BD-RT-1"`) {
t.Fatalf("凭据必须含 refresh_token(否则无法续期), 实得 %q", v)
}
if !strings.Contains(v, "buddy-dev") {
t.Fatalf("凭据应回填昵称, 实得 %q", v)
}
})
t.Run("行3 account查询失败不阻塞_回退默认名", func(t *testing.T) {
atomic.StoreInt32(&h.tokStep, 0)
atomic.StoreInt32(&h.acctOk, 0)
defer atomic.StoreInt32(&h.acctOk, 1)
_, sid := h.begin()
h.poll(sid)
j := h.poll(sid)
if j["state"] != "completed" {
t.Fatalf("account 失败不应阻塞: %v", j)
}
})
// 行4 回归:UI 真实流程(account/add 建占位账号 → 带 account_id 登录)。
// 缺陷复现:poll_login 完成时不传 account_id → 凭据落到 `channels:buddy`,
// 而池账号 credentialRef 是 `channels:buddy:<aid>` → account_list 报
// has_credential=false、credits 报「无凭据」——即用户报障的
// 「登录了但无法获取账号信息」。
t.Run("行4 池路径:登录后账号必须真的拿到凭据", func(t *testing.T) {
atomic.StoreInt32(&h.tokStep, 0)
_, sid, aid := h.beginPool()
h.poll(sid) // 首拍 11217
j := h.poll(sid)
if j["state"] != "completed" {
t.Fatalf("池路径登录应完成: %v", j)
}
// ① 凭据必须落在池账号的 ref 上
v, ok, _ := h.ps.GetSecret("channels", "channels:buddy:"+aid)
if !ok || v == "" {
t.Fatalf("凭据未落到池账号 ref(用户报障的根因); aid=%s ok=%v", aid, ok)
}
if !strings.Contains(v, `"refresh_token":"BD-RT-1"`) {
t.Fatalf("池凭据应是完整 JSON: %q", v)
}
// ② account_list 必须报 has_credential=true(UI 读的就是这个字段)
found := false
for _, raw := range h.accountList() {
a, _ := raw.(map[string]interface{})
if a["id"] == aid {
found = true
if a["has_credential"] != true {
t.Fatalf("has_credential 应为 true, 实得 %v", a["has_credential"])
}
if a["refreshable"] != true {
t.Fatalf("refreshable 应为 true(有 refresh_token), 实得 %v", a["refreshable"])
}
}
}
if !found {
t.Fatalf("池账号 %s 未出现在 account_list", aid)
}
})
}
// ---- 通用 ----
func scriptSteps(steps ...string) []scriptStep {
var out []scriptStep
for _, b := range steps {
code := 200
switch {
case strings.HasPrefix(b, "400 "):
code, b = 400, b[4:]
case strings.HasPrefix(b, "403 "):
code, b = 403, b[4:]
case strings.HasPrefix(b, "500 "):
code, b = 500, b[4:]
}
out = append(out, scriptStep{http: code, body: b})
}
return out
}