191 lines
6.1 KiB
Go
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
|
|
})
|
|
}
|