Files
Cyrene_v2/plugins/channels/weixin_ilink/login_state_test.go
T
Aska f5950e4bde
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(weixin_ilink): 扫码协议与腾讯官方对齐——POST local_token_list + iLink-App-Id/ClientVersion 头 + need_verifycode/redirect 状态机
2026-09-07 22:49:25 +08:00

191 lines
6.1 KiB
Go

package weixin_ilink
import (
"bytes"
"context"
"io"
"net/http"
"sync"
"testing"
"time"
)
// scriptedTransport answers the weixin_ilink endpoints without network I/O. It
// records the host each get_qrcode_status poll hits so tests can assert the IDC
// redirect routing, and returns get_bot_qrcode with a fixed QR plus get_qrcode_status
// bodies from statusFn (n is the 1-based poll count).
type scriptedTransport struct {
qr []byte
statusFn func(n int) []byte
mu sync.Mutex
statusHosts []string
}
func (t *scriptedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
var body []byte
switch req.URL.Path {
case "/ilink/bot/get_bot_qrcode":
body = t.qr
case "/ilink/bot/get_qrcode_status":
t.mu.Lock()
n := len(t.statusHosts) + 1
t.statusHosts = append(t.statusHosts, req.URL.Host)
t.mu.Unlock()
body = t.statusFn(n)
default:
return &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(bytes.NewReader(nil)),
Header: http.Header{},
Request: req,
}, nil
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
Request: req,
}, nil
}
func (t *scriptedTransport) hosts() []string {
t.mu.Lock()
defer t.mu.Unlock()
return append([]string(nil), t.statusHosts...)
}
// newScriptedAdapter builds an Adapter wired to a scripted transport (no real
// network), with a base URL that does not resolve — the transport intercepts it.
func newScriptedAdapter(cfg Config, tr http.RoundTripper) (*Adapter, error) {
return New(cfg, &http.Client{Transport: tr}, nil)
}
const scriptedQR = `{"qrcode":"qr-r","qrcode_img_content":"https://img/r"}`
const scriptedBaseURL = "https://ilinkai.weixin.qq.com"
// TestStartLogin_ScanButRedirectRoutesPoll verifies that a scaned_but_redirect
// response with a redirect_host switches subsequent get_qrcode_status polls to
// the https://<redirect_host> IDC host.
func TestStartLogin_ScanButRedirectRoutesPoll(t *testing.T) {
t.Parallel()
tr := &scriptedTransport{
qr: []byte(scriptedQR),
statusFn: func(n int) []byte {
if n == 1 {
return []byte(`{"status":"scaned_but_redirect","redirect_host":"idc.weixin.qq.com"}`)
}
return []byte(`{"status":"confirmed","bot_token":"tok-x","ilink_bot_id":"bot-x","ilink_user_id":"o-1","baseurl":"https://ilinkai.weixin.qq.com"}`)
},
}
ad, err := newScriptedAdapter(Config{
BaseURL: scriptedBaseURL, LoginInterval: time.Millisecond, LoginTimeout: 5 * time.Second,
}, tr)
if err != nil {
t.Fatalf("New: %v", err)
}
if _, err := ad.StartLogin(context.Background()); err != nil {
t.Fatalf("StartLogin: %v", err)
}
waitFor(t, func() bool {
s, _ := ad.LoginStatus(context.Background())
return s == LoginStatusConfirmed
})
if got := ad.Config()["bot_token"]; got != "tok-x" {
t.Fatalf("bot_token = %v, want tok-x", got)
}
hosts := tr.hosts()
if len(hosts) < 2 {
t.Fatalf("status polls = %d, want >= 2 (first on base, second on redirect host)", len(hosts))
}
if hosts[0] != "ilinkai.weixin.qq.com" {
t.Fatalf("first status poll host = %q, want ilinkai.weixin.qq.com", hosts[0])
}
if hosts[1] != "idc.weixin.qq.com" {
t.Fatalf("second status poll host = %q, want idc.weixin.qq.com (IDC redirect)", hosts[1])
}
}
// TestStartLogin_VerifyCodeBlocked verifies verify_code_blocked transitions the
// login to failed.
func TestStartLogin_VerifyCodeBlocked(t *testing.T) {
t.Parallel()
tr := &scriptedTransport{
qr: []byte(scriptedQR),
statusFn: func(int) []byte { return []byte(`{"status":"verify_code_blocked"}`) },
}
ad, err := newScriptedAdapter(Config{
BaseURL: scriptedBaseURL, LoginInterval: time.Millisecond, LoginTimeout: 2 * time.Second,
}, tr)
if err != nil {
t.Fatalf("New: %v", err)
}
if _, err := ad.StartLogin(context.Background()); err != nil {
t.Fatalf("StartLogin: %v", err)
}
waitFor(t, func() bool {
s, _ := ad.LoginStatus(context.Background())
return s == LoginStatusFailed
})
if ad.IsConnected() {
t.Fatal("IsConnected() = true after verify_code_blocked")
}
}
// TestStartLogin_BindedRedirect verifies binded_redirect is treated as already
// bound (LoginStatus confirmed) rather than a failure.
func TestStartLogin_BindedRedirect(t *testing.T) {
t.Parallel()
tr := &scriptedTransport{
qr: []byte(scriptedQR),
statusFn: func(int) []byte { return []byte(`{"status":"binded_redirect"}`) },
}
ad, err := newScriptedAdapter(Config{
BaseURL: scriptedBaseURL, LoginInterval: time.Millisecond, LoginTimeout: 2 * time.Second,
}, tr)
if err != nil {
t.Fatalf("New: %v", err)
}
if _, err := ad.StartLogin(context.Background()); err != nil {
t.Fatalf("StartLogin: %v", err)
}
waitFor(t, func() bool {
s, _ := ad.LoginStatus(context.Background())
return s == LoginStatusConfirmed
})
if _, detail := ad.LoginStatus(context.Background()); detail != loginBindedRedirectDetail {
t.Fatalf("binded_redirect detail = %q, want %q", detail, loginBindedRedirectDetail)
}
}
// TestStartLogin_NeedVerifyCode verifies need_verifycode surfaces a dedicated
// LoginStatus sub-state with a hint detail, then times out to failed.
func TestStartLogin_NeedVerifyCode(t *testing.T) {
t.Parallel()
tr := &scriptedTransport{
qr: []byte(scriptedQR),
statusFn: func(int) []byte { return []byte(`{"status":"need_verifycode"}`) },
}
ad, err := newScriptedAdapter(Config{
BaseURL: scriptedBaseURL, LoginInterval: time.Millisecond, LoginTimeout: 40 * time.Millisecond,
}, tr)
if err != nil {
t.Fatalf("New: %v", err)
}
if _, err := ad.StartLogin(context.Background()); err != nil {
t.Fatalf("StartLogin: %v", err)
}
waitFor(t, func() bool {
s, _ := ad.LoginStatus(context.Background())
return s == LoginStatusNeedVerifyCode
})
if _, detail := ad.LoginStatus(context.Background()); detail != loginNeedVerifyCodeHint {
t.Fatalf("need_verifycode detail = %q, want %q", detail, loginNeedVerifyCodeHint)
}
// Without a verify-code回传 path the flow eventually times out to failed.
waitFor(t, func() bool {
s, _ := ad.LoginStatus(context.Background())
return s == LoginStatusFailed
})
}