mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
按用户要求:不是从头造,而是移植参考实现(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 全局调用 —— 已改为自含熵源。
182 lines
6.0 KiB
Go
182 lines
6.0 KiB
Go
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"])
|
||
}
|
||
}
|