package core import ( "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "llmsproxy/internal/lua" ) // 有道登录闭环的判据:portal 会把授权重定向回 **本机 loopback 接收器** // (127.0.0.1:{port}/auth/callback 精确路径),而不是用户机器的 localhost、 // 也不是本插件的 /api/plugins/http 挂载点。远程用户在服务端浏览器里完成后, // 重定向落本机端口,接收器写文件,poll_login 读它完成交换。 // // 参考 lobsterai-oauth.ts 明确:redirect_uri 必须该形态(登录页会校验), // state 不匹配必须拒绝(400),交换体含 authCode/firstKeyfrom/latestKeyfrom/ // uuid/version 五字段,凭据必须带 uuid+first_keyfrom(否则续期失败)。 func TestLobsterLoopbackClosedLoop(t *testing.T) { var exch int var gotVersion string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { exch++ var b map[string]string _ = json.NewDecoder(r.Body).Decode(&b) if b["authCode"] != "AC-1" { t.Errorf("exchange authCode = %q", b["authCode"]) } gotVersion = b["version"] if b["firstKeyfrom"] == "" || b["uuid"] == "" { t.Errorf("exchange 缺字段: %#v", b) } w.Header().Set("Content-Type", "application/json") io.WriteString(w, `{"code":0,"data":{"accessToken":"AT-1","refreshToken":"RT-1","expiresIn":3600,"user":{"id":"UID1"}}}`) })) defer ts.Close() // 版本号是 exchange 的必填字段,**不能写死**:2026-10-13 实测上游已是 // 2026.9.23,而参考的兜底常量还停在 2026.9.4。此处假上游返回真形态 // (非统一信封,值在 data.value.version)。 ver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") io.WriteString(w, `{"data":{"value":{"version":"2026.9.23","date":"2026-9-23"}}}`) })) defer ver.Close() c := kernelTestCore(t) ps := c.Plugins() src, err := readBundledChannels(t) if err != nil { t.Fatal(err) } if err := ps.LoadSource("channels", src); err != nil { t.Fatal(err) } _ = ps.SetPluginKernelPermission("channels", []string{"add_source", "remove_source", "status"}, false) if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{ "platform": "lobsterai", "callback": map[string]interface{}{"token_url": ts.URL}, "client_version": map[string]interface{}{"url": ver.URL}, }); err != nil { t.Fatal(err) } out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ Method: "POST", Body: `{"platform":"lobsterai"}`, Role: "admin", }) if err != nil { t.Fatal(err) } j := out.JSON.(map[string]interface{}) sess := j["session"].(map[string]interface{}) nonce := sess["nonce"].(string) sid := sess["id"].(string) redirectURI, _ := j["redirect_uri"].(string) if !strings.HasPrefix(redirectURI, "http://127.0.0.1:") || !strings.HasSuffix(redirectURI, "/auth/callback") { t.Fatalf("redirect_uri 必须是 127.0.0.1:{port}/auth/callback,实得 %q", redirectURI) } url, _ := j["url"].(string) if !strings.Contains(url, "/portal#/login?") { t.Fatalf("授权页应是 /portal#/login?…,实得 %q", url) } if !strings.Contains(url, "source=electron") { t.Fatalf("授权页缺 source=electron: %q", url) } t.Run("行1 state 不匹配被拒", func(t *testing.T) { resp, err := http.Get(redirectURI + "?code=AC-1&state=WRONG") if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != 400 { t.Fatalf("state 不匹配应 400,实得 %d", resp.StatusCode) } if exch != 0 { t.Fatal("state 不匹配不应触发交换") } }) t.Run("行2 正确回调完成登录并落完整凭据", func(t *testing.T) { resp, err := http.Get(redirectURI + "?code=AC-1&state=" + nonce) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != 200 { t.Fatalf("合法回调应 200,实得 %d", resp.StatusCode) } // 接收器写文件后 poll_login 读它完成交换(可能需要一拍) var st map[string]interface{} for i := 0; i < 20; i++ { o, err := ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{ Method: "GET", Query: map[string]string{"id": sid}, Role: "admin", }) if err != nil { t.Fatal(err) } st = o.JSON.(map[string]interface{}) if st["state"] != "pending" { break } time.Sleep(100 * time.Millisecond) } if st["state"] != "completed" { t.Fatalf("应完成登录,实得 %v", st) } raw, ok, err := ps.GetSecret("channels", "channels:lobsterai") if err != nil || !ok { t.Fatalf("凭据未落库: %v", err) } var cred map[string]interface{} if err := json.Unmarshal([]byte(raw), &cred); err != nil { t.Fatal(err) } for _, k := range []string{"access_token", "refresh_token", "uuid", "first_keyfrom", "expires_at", "uid"} { if cred[k] == nil || cred[k] == "" { t.Errorf("凭据缺 %s(有道的 uuid/first_keyfrom 丢了就续期失败): %s", k, raw) } } if cred["access_token"] != "AT-1" { t.Fatalf("access_token = %v", cred["access_token"]) } // 版本号必须来自远端(2026.9.23),不能是写死的兜底 2026.9.4 if gotVersion != "2026.9.23" { t.Fatalf("exchange version 应为远端 2026.9.23(写死 2026.9.4 是 bug),实得 %q", gotVersion) } }) t.Run("行3 接收器用完即收", func(t *testing.T) { // 完成后监听应已关闭(连接被拒) _, err := http.Get(redirectURI + "?code=AC-1&state=" + nonce) if err == nil { t.Fatal("接收器应在完成后关闭") } else if !strings.Contains(err.Error(), "refused") && !strings.Contains(err.Error(), "connect") { t.Logf("关闭判定: %v", err) } }) _ = fmt.Sprint }