Files
ModelRouter/internal/core/channels_device_test.go
JianFeeeee 6f2b6b8d3d feat(channels): device-poll 通用执行器 + 行为对齐测试(照参考实现逐行移植)
按用户要求:不是从头造,而是移植参考实现(minimax-oauth.ts)的行为,
并照对方代码的行为形成测试项。

新增(channels.lua):
- b64url/hexToBin/devicePkce:PKCE S256(对齐 createMinimaxPkce)
- deviceOneStep:轮询决策单步执行器,分支顺序逐行对齐 pollMinimaxDeviceToken:
    200+status=pending        → 等
    200+status=slow_down      → interval+5s,等
    200+status=denied/access_denied → failed(用户拒绝)
    200+status=expired/expired_token → failed(码过期)
    非200+error=authorization_pending → 等(OAuth 标准形态)
    非200+error=slow_down     → interval+5s,等
    200+access_token          → 成功
    其它                      → failed(带 error 文本)
- start_login device-poll 分支:申请设备码(PKCE)、user_code/授权URL透传、
  verifier 只存会话不回显
- poll_login:device-poll 会话每次推进一步(页面驱动,不阻塞 —— 插件单VM单锁,
  阻塞循环会卡死本插件全部钩子);成功即落凭据+接入源
- set_endpoints 导出:运维把渠道端点指向自建代理/镜像;也供测试打假上游
- plugin.exports = { set_endpoints }

测试(channels_device_test.go):假上游逐次响应,8 行决策表逐行断言
(pending 等待 / slow_down interval+5 / denied→failed / expired→failed /
authorization_pending 等 / slow_down(4xx) 等 / 成功→completed+account 取
nickname / error→failed 带文本)。全部通过。

教训(词法作用域):devicePkce 原引用文件后段才定义的 newToken,Lua 下那是
nil 全局调用 —— 已改为自含熵源。
2026-10-11 20:08:00 +08:00

182 lines
6.0 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"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"llmsproxy/internal/lua"
)
// TestChannelsDevicePollBehavior 是 device-poll 流的行为对齐测试。
//
// 决策表逐行搬自参考实现 pollMinimaxDeviceToken(minimax-oauth.ts),
// 上游用假服务器模拟,逐次响应由测试控制。断言的是**行为**(会话状态推进),
// 不是实现细节。
func TestChannelsDevicePollBehavior(t *testing.T) {
// 假 MiniMax:token 端点按脚本逐次响应;code 端点固定发一个码。
var step int32
var script atomic.Value
script.Store([]map[string]interface{}{})
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/oauth2/device/code":
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"device_code":"DEV1","user_code":"ABCD-EFGH","verification_uri":"https://example/activate","interval":1,"expires_in":600}`)
case "/oauth2/token":
i := int(atomic.AddInt32(&step, 1)) - 1
scripted := script.Load().([]map[string]interface{})
w.Header().Set("Content-Type", "application/json")
if i >= len(scripted) {
w.WriteHeader(500)
fmt.Fprint(w, `{"error":"unexpected"}`)
return
}
st := scripted[i]
if code, _ := st["http"].(int); code != 0 && code != 200 {
w.WriteHeader(code)
}
fmt.Fprint(w, st["body"].(string))
default:
w.WriteHeader(404)
}
}))
defer ts.Close()
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)
}
// 把 minimax 平台的端点指到假上游(登录主机也要放行:httptest 用 127.0.0.1,
// 白名单对 loopback 天然放行)。set_endpoints 是插件导出的合法运维能力。
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "minimax",
"device": map[string]interface{}{
"code_url": ts.URL + "/oauth2/device/code",
"token_url": ts.URL + "/oauth2/token",
},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
begin := func() map[string]interface{} {
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"minimax"}`,
})
if err != nil {
t.Fatal(err)
}
return out.JSON.(map[string]interface{})
}
poll := func(id string) map[string]interface{} {
out, _ := ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": id},
})
return out.JSON.(map[string]interface{})
}
setScript := func(steps ...string) {
var ms []map[string]interface{}
for _, b := range steps {
code := 200
if strings.HasPrefix(b, "403 ") || strings.HasPrefix(b, "400 ") {
code = 400
if strings.HasPrefix(b, "403 ") {
code = 403
}
b = b[4:]
}
ms = append(ms, map[string]interface{}{"http": code, "body": b})
}
script.Store(ms)
atomic.StoreInt32(&step, 0)
}
// ---- begin:设备码申请成功,user_code 透传,verifier 不回显 ----
j := begin()
sess := j["session"].(map[string]interface{})
sid := sess["id"].(string)
if sess["user_code"] != "ABCD-EFGH" {
t.Fatalf("begin 应透传 user_code, 实得 %v", sess["user_code"])
}
if j["url"] != "https://example/activate" {
t.Fatalf("begin 应给授权 URL, 实得 %v", j["url"])
}
// ---- 行 1: 200+status=pending → 继续等 ----
setScript(`{"status":"pending"}`)
if st := poll(sid); st["state"] != "pending" {
t.Fatalf("行1 pending: 应仍 pending, 实得 %v", st)
}
// ---- 行 2: 200+status=slow_down → interval+5,仍 pending ----
setScript(`{"status":"slow_down"}`)
if st := poll(sid); st["state"] != "pending" {
t.Fatalf("行2 slow_down: 应仍 pending, 实得 %v", st)
} else if iv, _ := st["interval"].(float64); iv < 6 {
t.Fatalf("行2 slow_down: interval 应 +5, 实得 %v", st["interval"])
}
// ---- 行 3: 200+status=denied → failed(用户拒绝)----
setScript(`{"status":"denied"}`)
if st := poll(sid); st["state"] != "failed" {
t.Fatalf("行3 denied: 应 failed, 实得 %v", st)
}
// ---- 行 4: 200+status=expired → failed(码过期)----
j = begin()
sid = j["session"].(map[string]interface{})["id"].(string)
setScript(`{"status":"expired_token"}`)
if st := poll(sid); st["state"] != "failed" {
t.Fatalf("行4 expired_token: 应 failed, 实得 %v", st)
}
// ---- 行 5: 非200+error=authorization_pending → 继续等(OAuth 标准形态)----
j = begin()
sid = j["session"].(map[string]interface{})["id"].(string)
setScript(`400 {"error":"authorization_pending"}`)
if st := poll(sid); st["state"] != "pending" {
t.Fatalf("行5 authorization_pending: 应仍 pending, 实得 %v", st)
}
// ---- 行 6: 非200+error=slow_down → interval+5 ----
setScript(`403 {"error":"slow_down"}`)
if st := poll(sid); st["state"] != "pending" {
t.Fatalf("行6 slow_down(400): 应仍 pending, 实得 %v", st)
} else if iv, _ := st["interval"].(float64); iv < 6 {
t.Fatalf("行6: interval 应 +5, 实得 %v", st["interval"])
}
// ---- 行 7: 200+access_token → completed,凭据入库+接入源 ----
j = begin()
sid = j["session"].(map[string]interface{})["id"].(string)
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false)
setScript(`{"access_token":"MM-ACCESS-1","refresh_token":"MM-RF-1","nickname":"mm-user"}`)
st := poll(sid)
if st["state"] != "completed" {
t.Fatalf("行7 成功: 应 completed, 实得 %v", st)
}
if st["account"] != "mm-user" {
t.Fatalf("行7: account 应取 nickname, 实得 %v", st)
}
// ---- 行 8: 其它错误 → failed 且带 error 文本 ----
j = begin()
sid = j["session"].(map[string]interface{})["id"].(string)
setScript(`{"error":"server_error"}`)
st = poll(sid)
if st["state"] != "failed" {
t.Fatalf("行8 error: 应 failed, 实得 %v", st)
}
if msg, _ := st["message"].(string); !strings.Contains(msg, "server_error") {
t.Fatalf("行8: message 应带 error 文本, 实得 %v", st["message"])
}
}