Files
Cyrene_v2/plugins/channels/weixin_ilink/login_reuse_test.go
T

214 lines
8.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package weixin_ilink
import (
"context"
"fmt"
"log/slog"
"strings"
"testing"
"time"
)
// TestStartLogin_ScanedExposesWaitingDetail 验证观测到的线上状态 "scaned" 会以
// 向后兼容的 waiting 状态加「请确认」详情呈现给面板,使前端不新增状态就能保留其
// 等待界面。
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 验证重新取码保护:一旦轮询观测到 "scaned"
// (等待确认),StartLogin 即使在超过 qrReuseWindow 后也复用当前二维码,绝不发出第二次
// get_bot_qrcode——待处理的确认不会被丢弃。
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)
}
// 等到轮询 goroutine 观测到 "scaned"(该会话处于确认中)。
waitFor(t, func() bool {
ad.loginMu.Lock()
defer ad.loginMu.Unlock()
return ad.loginSession != nil && ad.loginSession.currentPhase() == "scaned"
})
// 把 startedAt 推到远超复用窗口之前,使保住该二维码的只能是保护逻辑而非窗口本身。
// 此时一次刷新仍必须复用它。
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 验证显式刷新场景仍然成立:仍在等待(尚未
// 被扫描)且已过复用窗口的二维码会用新的 get_bot_qrcode 重新生成,且被取消的旧轮询
// 绝不会用 "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)
}
// 被取消的旧轮询不得覆盖刚生成出来的 waiting 状态。
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 验证扫码登录路径只记录已脱敏的标识:可渲染的二维码 URL 与
// bot id 会出现,但原始 qrcode 标识符、bot_token 以及主人的 user_id 绝不写入。
//
// ⚠️ 日志缓冲必须是**并发安全**的 syncBuffer(M56 修掉既有 DATA RACE):写侧是
// StartLogin 起的登录 goroutine(completeLogin 里的 `login confirmed`),读侧是测试
// goroutine 的 buf.String(),两侧之间没有 happens-before 边 —— 裸 bytes.Buffer 在
// -race 下必报。等待条件也必须等**那一行日志**落盘,而不只是 ad.IsConnected():
// completeLogin 先在 a.mu 下置 connected、**之后**才打日志,只等 IsConnected 会
// 偶发地读到还没有 `login confirmed` 的缓冲(同一处竞态的另一种表现:抖动)。
func TestLogin_LogRedaction(t *testing.T) {
t.Parallel()
var buf syncBuffer
rec := &reqRecorder{}
// 用 reqRecorder 数状态轮询次数(它自带互斥),而不是在处理器里 += 一个裸 int:
// 处理器跑在 httptest 的服务端 goroutine 上,任何无保护的共享计数器都是同一类
// 竞态的下一个落点。
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-redact", "qrcode_img_content": "https://img/redact",
})}
case "/ilink/bot/get_qrcode_status":
// rec.add 在处理器被调用之前完成,所以这里数到的已包含本次请求。
calls := len(rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/get_qrcode_status" }))
if calls == 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() && strings.Contains(buf.String(), "login confirmed")
})
logs := buf.String()
for _, want := range []string{
"get_bot_qrcode ok",
"https://img/redact", // 已脱敏的可渲染 URL
"login confirmed",
"bot-redact", // bot id 非敏感
} {
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)
}
}
}