mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
用户报障:「登录了,但插件这边无法获取账号信息」。实证: account_list → has_credential=false,credits/balances → 「无凭据」, 而 UI 上账号显示已登录。 ## 根因一:完成路径没把凭据写进池账号的键 UI 的真实流程是「account/add 建占位账号(credentialRef=channels:<plat>:<aid>) → login 带 account_id → 轮询 login/status」。但 poll_login 的两条完成分支 (device-poll / state-poll)调用 complete_login 时**没有传 account_id**, 于是走了兼容旧路径,凭据落到 `channels:<plat>` 这个与占位账号无关的键; 池账号的 credentialRef 指向 `channels:<plat>:<aid>`,从未写入 → account_list 的 has_credential 恒为 false。 修:两条分支都传 `account_id = s.account_id`(对齐参考 BuddyAuth.login 的 `flowOptions.accountId` 语义:多账号场景下凭据按账号 ref 写入)。 ## 根因二:只存裸 access_token,丢掉 refresh_token 与过期信息 state-poll 分支只取 `data.accessToken` 当凭据存,而参考 `runBuddyLoginFlow` 存的是 `buildCredential(token, account)` 的**完整 JSON** (access_token + refresh_token + expires_at + refresh_expires_at + token_type + scope + domain + user_id + nickname + enterprise_id + account_type)。 只存裸 token 的后果:refreshable 恒为 false → 无静默续期;expires_at 未知。 修:新增 buildCredential(逐字段对齐参考 buddy.ts)+ jwtClaim/absExpiryMs (参照实现:token 端点只回 expiresIn 不回 expiresAt,昵称只在 JWT 声明里) + credentialExpiresAtMs。state-poll 完成时存完整凭据 JSON 并回报 expires_at/refreshable。 ## 配套 - 新增 credentialOf(ref):凭据取值助手,**裸 token 与完整 JSON 两种形态都认** (历史数据/device-poll/paste 是裸 token,buddy 是 JSON)。所有消费点 (上游源 api_key、Bearer 头、HMAC 签名、备份)改走它,无须关心存储形态。 - complete_login 从凭据自动补齐 nickname/expiresAt/refreshable,调用方漏传也不丢。 - 内核新增 base64_decode(标准/base64url/无填充都认):插件读 JWT payload 必需,否则要在 Lua 里重写 base64。通用能力,无厂商知识。 ## 判据 TestBuddyStatePollBehavior 行2/行4: - 行2 断言凭据是完整 JSON(含 refresh_token + 昵称),非裸 token - 行4 回归用户报障:走 account/add → 带 account_id 登录,断言 ① 凭据落在 channels:buddy:<aid> ② account_list 报 has_credential=true ③ refreshable=true 变异验证:去掉 account_id → 行4 立即 FAIL(已实测)。 go test ./internal/core/ 与 ./internal/lua/ 全绿。
406 lines
14 KiB
Go
406 lines
14 KiB
Go
package core
|
||
|
||
import (
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"sync/atomic"
|
||
"testing"
|
||
|
||
"llmsproxy/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
|
||
}
|