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(-(qrReuseWindow + 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(-(qrReuseWindow + 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) } } }