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) } } }