205 lines
7.2 KiB
Go
205 lines
7.2 KiB
Go
package weixin_ilink
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestStartLogin_ScanedExposesWaitingDetail verifies that an observed "scaned" wire
|
|
// status is surfaced to the panel as the backward-compatible waiting status with the
|
|
// "please confirm" detail, so the frontend keeps its waiting UI without a new status.
|
|
func TestStartLogin_ScanedExposesWaitingDetail(t *testing.T) {
|
|
t.Parallel()
|
|
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
|
|
switch req.path {
|
|
case "/ilink/bot/get_bot_qrcode":
|
|
return handlerResult{body: jsonBody(map[string]any{
|
|
"qrcode": "qr-s1", "qrcode_img_content": "https://img/s1",
|
|
})}
|
|
case "/ilink/bot/get_qrcode_status":
|
|
return handlerResult{body: jsonBody(map[string]any{"status": "scaned"})}
|
|
}
|
|
return handlerResult{status: 404}
|
|
})
|
|
|
|
cfg := Config{BaseURL: srv.URL, LoginInterval: time.Millisecond, LoginTimeout: 5 * time.Second}
|
|
ad := newAdapter(t, cfg, srv)
|
|
if _, err := ad.StartLogin(context.Background()); err != nil {
|
|
t.Fatalf("StartLogin: %v", err)
|
|
}
|
|
waitFor(t, func() bool {
|
|
status, detail := ad.LoginStatus(context.Background())
|
|
return status == LoginStatusWaiting && detail == loginScannedDetail
|
|
})
|
|
}
|
|
|
|
// TestStartLogin_ScanedPastWindowReusesQR verifies the re-mint protection: once the
|
|
// poll has observed "scaned" (awaiting confirmation), StartLogin reuses the current QR
|
|
// even past the qrReuseWindow and never issues a second get_bot_qrcode — the pending
|
|
// confirm is not dropped.
|
|
func TestStartLogin_ScanedPastWindowReusesQR(t *testing.T) {
|
|
t.Parallel()
|
|
rec := &reqRecorder{}
|
|
srv := newJSONServer(t, rec, func(req recordedReq) handlerResult {
|
|
switch req.path {
|
|
case "/ilink/bot/get_bot_qrcode":
|
|
return handlerResult{body: jsonBody(map[string]any{
|
|
"qrcode": "qr-scaned", "qrcode_img_content": "https://img/scaned",
|
|
})}
|
|
case "/ilink/bot/get_qrcode_status":
|
|
return handlerResult{body: jsonBody(map[string]any{"status": "scaned"})}
|
|
}
|
|
return handlerResult{status: 404}
|
|
})
|
|
|
|
cfg := Config{BaseURL: srv.URL, LoginInterval: time.Millisecond, LoginTimeout: 5 * time.Second}
|
|
ad := newAdapter(t, cfg, srv)
|
|
first, err := ad.StartLogin(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("first StartLogin: %v", err)
|
|
}
|
|
|
|
// Wait until the poll goroutine has observed "scaned" (the session is confirming).
|
|
waitFor(t, func() bool {
|
|
ad.loginMu.Lock()
|
|
defer ad.loginMu.Unlock()
|
|
return ad.loginSession != nil && ad.loginSession.currentPhase() == "scaned"
|
|
})
|
|
|
|
// Push startedAt well past the reuse window so only the protection, not the window,
|
|
// could keep the QR. Then a refresh must still reuse it.
|
|
ad.loginMu.Lock()
|
|
if ad.loginSession != nil {
|
|
ad.loginSession.startedAt = time.Now().Add(-(60*time.Second + time.Second))
|
|
}
|
|
ad.loginMu.Unlock()
|
|
|
|
second, err := ad.StartLogin(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("second StartLogin: %v", err)
|
|
}
|
|
if first.Qrcode != second.Qrcode || first.Image != second.Image {
|
|
t.Fatalf("scaned session re-minted: first=%+v second=%+v (want identical reuse)", first, second)
|
|
}
|
|
if got := len(rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/get_bot_qrcode" })); got != 1 {
|
|
t.Fatalf("get_bot_qrcode called %d times, want 1 (confirming session must not be re-minted)", got)
|
|
}
|
|
}
|
|
|
|
// TestStartLogin_WaitingPastWindowReMints verifies the explicit-refresh scenario is
|
|
// preserved: a QR that is still waiting (not yet scanned) and is past the reuse window
|
|
// is re-minted with a fresh get_bot_qrcode, and the cancelled old poll never clobbers
|
|
// the new session's state with "failed".
|
|
func TestStartLogin_WaitingPastWindowReMints(t *testing.T) {
|
|
t.Parallel()
|
|
rec := &reqRecorder{}
|
|
mintCalls := 0
|
|
srv := newJSONServer(t, rec, func(req recordedReq) handlerResult {
|
|
switch req.path {
|
|
case "/ilink/bot/get_bot_qrcode":
|
|
mintCalls++
|
|
return handlerResult{body: jsonBody(map[string]any{
|
|
"qrcode": fmt.Sprintf("qr-w%d", mintCalls),
|
|
"qrcode_img_content": fmt.Sprintf("https://img/w%d", mintCalls),
|
|
})}
|
|
case "/ilink/bot/get_qrcode_status":
|
|
return handlerResult{body: jsonBody(map[string]any{"status": "wait"})}
|
|
}
|
|
return handlerResult{status: 404}
|
|
})
|
|
|
|
cfg := Config{BaseURL: srv.URL, LoginInterval: time.Millisecond, LoginTimeout: 5 * time.Second}
|
|
ad := newAdapter(t, cfg, srv)
|
|
first, err := ad.StartLogin(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("first StartLogin: %v", err)
|
|
}
|
|
|
|
waitFor(t, func() bool {
|
|
ad.loginMu.Lock()
|
|
defer ad.loginMu.Unlock()
|
|
return ad.loginSession != nil && ad.loginSession.currentPhase() == "wait"
|
|
})
|
|
|
|
ad.loginMu.Lock()
|
|
if ad.loginSession != nil {
|
|
ad.loginSession.startedAt = time.Now().Add(-(60*time.Second + time.Second))
|
|
}
|
|
ad.loginMu.Unlock()
|
|
|
|
second, err := ad.StartLogin(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("second StartLogin: %v", err)
|
|
}
|
|
if second.Qrcode == first.Qrcode {
|
|
t.Fatalf("waiting session past reuse window reused stale QR %q, want a fresh code", first.Qrcode)
|
|
}
|
|
if got := len(rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/get_bot_qrcode" })); got != 2 {
|
|
t.Fatalf("get_bot_qrcode called %d times, want 2 (past-window waiting refresh re-mints)", got)
|
|
}
|
|
// The cancelled old poll must not clobber the freshly-minted waiting state.
|
|
if status, _ := ad.LoginStatus(context.Background()); status != LoginStatusWaiting {
|
|
t.Fatalf("LoginStatus after re-mint = %q, want waiting (superceded session must not set failed)", status)
|
|
}
|
|
}
|
|
|
|
// TestLogin_LogRedaction verifies the scan-login path logs only redacted identifiers:
|
|
// the renderable QR URL and the bot id are present, but the raw qrcode identifier, the
|
|
// bot_token, and the owner user_id are never written.
|
|
func TestLogin_LogRedaction(t *testing.T) {
|
|
t.Parallel()
|
|
var buf bytes.Buffer
|
|
statusCalls := 0
|
|
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
|
|
switch req.path {
|
|
case "/ilink/bot/get_bot_qrcode":
|
|
return handlerResult{body: jsonBody(map[string]any{
|
|
"qrcode": "qr-redact", "qrcode_img_content": "https://img/redact",
|
|
})}
|
|
case "/ilink/bot/get_qrcode_status":
|
|
statusCalls++
|
|
if statusCalls == 1 {
|
|
return handlerResult{body: jsonBody(map[string]any{"status": "scaned"})}
|
|
}
|
|
return handlerResult{body: jsonBody(map[string]any{
|
|
"status": "confirmed", "bot_token": "tok-secret",
|
|
"ilink_bot_id": "bot-redact", "ilink_user_id": "o-redact",
|
|
"baseurl": "https://ilinkai.weixin.qq.com",
|
|
})}
|
|
}
|
|
return handlerResult{status: 404}
|
|
})
|
|
|
|
cfg := Config{BaseURL: srv.URL, LoginInterval: time.Millisecond, LoginTimeout: 5 * time.Second}
|
|
ad, err := New(cfg, srv.Client(), slog.New(slog.NewTextHandler(&buf, nil)))
|
|
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 { return ad.IsConnected() })
|
|
|
|
logs := buf.String()
|
|
for _, want := range []string{
|
|
"get_bot_qrcode ok",
|
|
"https://img/redact", // sanitized renderable URL
|
|
"login confirmed",
|
|
"bot-redact", // bot id is non-sensitive
|
|
} {
|
|
if !strings.Contains(logs, want) {
|
|
t.Fatalf("logs missing %q:\n%s", want, logs)
|
|
}
|
|
}
|
|
for _, secret := range []string{"qr-redact", "tok-secret", "o-redact", "qrcode=qr-redact"} {
|
|
if strings.Contains(logs, secret) {
|
|
t.Fatalf("logs leaked secret %q:\n%s", secret, logs)
|
|
}
|
|
}
|
|
}
|