feat(weixin_ilink): 扫码协议与腾讯官方对齐——POST local_token_list + iLink-App-Id/ClientVersion 头 + need_verifycode/redirect 状态机
This commit is contained in:
1 parent
bef398d47a
commit
f5950e4bde
9 files changed
+508
-99
No files matched your search
@@ -0,0 +1,190 @@
|
||||
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
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user