Files
AskaEth 95f97f5df3
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · plugins/channels/weixin_ilink(20 文件,纯替换)
- 只翻注释/文档字符串:剥掉行尾注释后,每条「-」都有逐字节相同的「+」对应行(8 组,代码零改动)
- 保留协议/线上标识符原文:errcode、bot_token、context_token、getupdates、binded_redirect、PKCS#7、ECB 等
- 术语对齐既有译法:owner→主人、peer→对端、long poll→长轮询、backoff→退避、sentinel error→哨兵错误
- 已知既有欠债(未顺手修):login_reuse_test.go 在 HEAD 上就未 gofmt 干净;login_test.go 的 TestStartLogin_ScanStatusTransition 91 行 >80(HEAD 同为 91 行)
2026-10-04 12:29:05 +08:00

275 lines
10 KiB
Go

package weixin_ilink
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// 这些测试覆盖显式的「会话被拒绝」状态机(session_state.go):iLink 回复
// errcode=-14 必须表现为「需要重新扫码」,而不是悄悄变哑的适配器、或一次拿着已被
// iLink 拒绝的凭据却上报成功的连接。
// rejectingServer 对每次 getupdates 长轮询都回以「会话被拒绝」错误码,正如 iLink
// 拒绝一份会话凭据那样。
func rejectingServer(t *testing.T) *httptest.Server {
t.Helper()
return newJSONServer(t, nil, func(req recordedReq) handlerResult {
if req.path != "/ilink/bot/getupdates" {
return handlerResult{status: 404}
}
return handlerResult{body: jsonBody(map[string]any{"errcode": errcodeSessionRejected})}
})
}
// rejectByPoll 通过长轮询把适配器驱入被拒绝状态:ReadMessages 必须在 errcode=-14 时
// 返回,且适配器必须报告「需要重新扫码」状态。
func rejectByPoll(t *testing.T, ad *Adapter) {
t.Helper()
done := make(chan struct{})
go func() {
defer close(done)
ad.ReadMessages(context.Background(), func(adapter.UnifiedMessage) {})
}()
waitDone(t, done)
if required, reason := ad.RescanRequired(); !required || reason == "" {
t.Fatalf("RescanRequired() = (%v,%q), want (true, non-empty) after errcode=-14", required, reason)
}
}
// newRejectedByPollAdapter 构造一个已连接的适配器,并经轮询路径把它驱入被拒绝状态。
func newRejectedByPollAdapter(t *testing.T, srv *httptest.Server) *Adapter {
t.Helper()
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, PollInterval: time.Millisecond, BotToken: "tok-old"}, srv)
rejectByPoll(t, ad)
return ad
}
// assertRejectedSession 断言「会话被 iLink 拒绝」的可观测契约:未连接、需要重新扫码
// 且有原因、HealthCheck 能把被拒绝状态与未连接区分开、以及 Connect 以
// ErrSessionRejected 拒绝而不是假 OK。
func assertRejectedSession(t *testing.T, ad *Adapter) {
t.Helper()
if ad.IsConnected() {
t.Fatal("IsConnected() = true while iLink rejects the session")
}
required, reason := ad.RescanRequired()
if !required {
t.Fatal("RescanRequired() = false, want true")
}
if reason == "" {
t.Fatal("RescanRequired() reason is empty, want the re-scan prompt")
}
herr := ad.HealthCheck(context.Background())
if !errors.Is(herr, ErrSessionRejected) {
t.Fatalf("HealthCheck() = %v, want ErrSessionRejected", herr)
}
if errors.Is(herr, adapter.ErrNotConnected) {
t.Fatal("HealthCheck() also matches ErrNotConnected, want the rejected state to be distinguishable")
}
cerr := ad.Connect(context.Background())
if cerr == nil {
t.Fatal("Connect() = nil on a rejected session, want ErrSessionRejected (no fake OK)")
}
if !errors.Is(cerr, ErrSessionRejected) {
t.Fatalf("Connect() = %v, want ErrSessionRejected", cerr)
}
if errors.Is(cerr, ErrNeedsLogin) {
t.Fatal("Connect() = ErrNeedsLogin, want ErrSessionRejected (a rejected session is not 'unconfigured')")
}
if ad.IsConnected() {
t.Fatal("IsConnected() = true after a refused Connect on a rejected session")
}
}
func TestSessionRejected_PollMarksRescanAndRefusesConnect(t *testing.T) {
t.Parallel()
srv := rejectingServer(t)
ad := newRejectedByPollAdapter(t, srv)
assertRejectedSession(t, ad)
}
func TestSessionRejected_SendMarksRescan(t *testing.T) {
t.Parallel()
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
if req.method == http.MethodPost && req.path == "/ilink/bot/sendmessage" {
return handlerResult{body: jsonBody(map[string]any{"errcode": errcodeSessionRejected})}
}
return handlerResult{status: 404}
})
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, BotToken: "tok"}, srv)
ad.recordInbound("tok-1", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()})
err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{SenderID: "u-1", Content: "hi"}},
})
if !errors.Is(err, ErrSessionRejected) {
t.Fatalf("SendMessage() = %v, want ErrSessionRejected", err)
}
if ad.IsConnected() {
t.Fatal("IsConnected() = true after a send saw errcode=-14")
}
// 之后的发送上报的是会话被拒绝本身,而不是光秃秃的「未连接」。
err = ad.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{SenderID: "u-1", Content: "again"}},
})
if !errors.Is(err, ErrSessionRejected) {
t.Fatalf("second SendMessage() = %v, want ErrSessionRejected", err)
}
perr := ad.SendProactive(context.Background(), adapter.UnifiedMessage{SenderID: "u-1", Content: "push"})
if !errors.Is(perr, ErrSessionRejected) {
t.Fatalf("SendProactive() = %v, want ErrSessionRejected", perr)
}
assertRejectedSession(t, ad)
}
func TestSessionRejected_ClearedByScanLogin(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
srv := newJSONServer(t, rec, func(req recordedReq) handlerResult {
switch req.path {
case "/ilink/bot/getupdates":
return handlerResult{body: jsonBody(map[string]any{"errcode": errcodeSessionRejected})}
case "/ilink/bot/get_bot_qrcode":
return handlerResult{body: jsonBody(map[string]any{
"qrcode": "qr-re", "qrcode_img_content": "https://img/qr",
})}
case "/ilink/bot/get_qrcode_status":
return handlerResult{body: jsonBody(map[string]any{
"status": "confirmed", "bot_token": "tok-new",
"ilink_bot_id": "bot-new", "ilink_user_id": "o-1",
})}
}
return handlerResult{status: 404}
})
ad := newRejectedByPollAdapter(t, srv)
assertRejectedSession(t, ad)
// 被拒绝的 token 不得短路扫码流程:它不是可用绑定,而 ErrAlreadyLoggedIn 会让
// 主人无路可退。
qr, err := ad.StartLogin(context.Background())
if err != nil {
t.Fatalf("StartLogin() on a rejected session = %v, want a fresh QR", err)
}
if qr.Qrcode != "qr-re" {
t.Fatalf("StartLogin() qrcode = %q, want qr-re", qr.Qrcode)
}
// 确认的扫码交付了新 token:被拒绝状态必须被清除。
waitFor(t, func() bool { ok, _ := ad.RescanRequired(); return !ok })
if err := ad.Connect(context.Background()); err != nil {
t.Fatalf("Connect() after re-scan = %v, want nil", err)
}
if !ad.IsConnected() {
t.Fatal("IsConnected() = false after a confirmed re-scan")
}
if err := ad.HealthCheck(context.Background()); err != nil {
t.Fatalf("HealthCheck() after re-scan = %v, want nil", err)
}
if got := ad.Config()["bot_token"]; got != "tok-new" {
t.Fatalf("bot_token = %v, want tok-new", got)
}
if status, _ := ad.LoginStatus(context.Background()); status != LoginStatusConfirmed {
t.Fatalf("LoginStatus() = %q, want confirmed after a successful re-scan", status)
}
// 本次重扫请求的是真正的重新绑定:被拒绝的 token 不作为本地 token 上报(正是
// 上报它才会招来不带新凭据的 binded_redirect)。
reqs := rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/get_bot_qrcode" })
if len(reqs) != 1 {
t.Fatalf("get_bot_qrcode requests = %d, want 1", len(reqs))
}
if ltl := reqs[0].bodyJSON()["local_token_list"]; ltl == nil {
t.Fatalf("get_bot_qrcode body has no local_token_list: %s", reqs[0].body)
} else if list, ok := ltl.([]any); !ok || len(list) != 0 {
t.Fatalf("get_bot_qrcode local_token_list = %v, want an empty array", ltl)
}
}
func TestSessionRejected_ClearedByNewToken(t *testing.T) {
t.Parallel()
srv := rejectingServer(t)
ad := newRejectedByPollAdapter(t, srv)
// 被拒绝的 token 仍在配置中:会话保持被拒绝。
if err := ad.Connect(context.Background()); !errors.Is(err, ErrSessionRejected) {
t.Fatalf("Connect() = %v, want ErrSessionRejected while the rejected token is unchanged", err)
}
if err := ad.HealthCheck(context.Background()); !errors.Is(err, ErrSessionRejected) {
t.Fatalf("HealthCheck() = %v, want ErrSessionRejected while the rejected token is unchanged", err)
}
setBotToken(ad, "tok-fresh")
if required, _ := ad.RescanRequired(); required {
t.Fatal("RescanRequired() = true after a new token was configured")
}
if err := ad.Connect(context.Background()); err != nil {
t.Fatalf("Connect() with a fresh token = %v, want nil", err)
}
if !ad.IsConnected() {
t.Fatal("IsConnected() = false after Connect with a fresh token")
}
if err := ad.HealthCheck(context.Background()); err != nil {
t.Fatalf("HealthCheck() with a fresh token = %v, want nil", err)
}
}
func TestSessionRejected_LoginStatusReportsRescanAndAllowsRescan(t *testing.T) {
t.Parallel()
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
switch req.path {
case "/ilink/bot/getupdates":
return handlerResult{body: jsonBody(map[string]any{"errcode": errcodeSessionRejected})}
case "/ilink/bot/get_bot_qrcode":
return handlerResult{body: jsonBody(map[string]any{
"qrcode": "qr-2", "qrcode_img_content": "https://img/qr2",
})}
}
return handlerResult{body: jsonBody(map[string]any{"status": "wait"})}
})
ad := newConnectedAdapter(t, Config{
BaseURL: srv.URL, PollInterval: time.Millisecond,
LoginInterval: time.Millisecond, LoginTimeout: 30 * time.Millisecond,
BotToken: "tok-old",
}, srv)
rejectByPoll(t, ad)
// 被拒绝的凭据不得被读作 "confirmed":否则面板会重新持久化它并把绑定当作成功。
status, detail := ad.LoginStatus(context.Background())
if status != LoginStatusFailed {
t.Fatalf("LoginStatus() = %q, want failed while iLink rejects the stored token", status)
}
if detail == "" {
t.Fatal("LoginStatus() detail is empty, want the re-scan reason")
}
// 面板的扫码登录仍必须能生成二维码(ErrAlreadyLoggedIn 会堵死唯一的恢复路径)。
qr, err := ad.StartLogin(context.Background())
if err != nil {
t.Fatalf("StartLogin() = %v, want a fresh QR", err)
}
if qr.Qrcode != "qr-2" {
t.Fatalf("StartLogin() qrcode = %q, want qr-2", qr.Qrcode)
}
// 重扫进行期间上报的是它自己的状态,而不是被拒绝状态。
if status, _ := ad.LoginStatus(context.Background()); status != LoginStatusWaiting {
t.Fatalf("LoginStatus() during the re-scan = %q, want waiting", status)
}
}
// setBotToken 替换已配置的 bot token,用来模拟面板保存 + 热重载(会重建适配器)
// 或任何其他原地配置刷新;适配器没有公开的 setter,因为生产环境总是依据新配置构造
// 全新实例。
func setBotToken(ad *Adapter, token string) {
ad.mu.Lock()
ad.cfg.BotToken = token
ad.mu.Unlock()
}