feat(weixin-ilink,gateway): 会话过期显式化 — 不再静默变哑/假 OK 重连,面板可见「需重扫」
- 适配器新增过期状态机(plugins/channels/weixin_ilink/session_state.go):getupdates/sendmessage/getuploadurl 收到 errcode=-14 即 markExpired(ERROR 仅一次,只带 bot_id,token 永不入日志),扫码登录拿到新 token 或配置换新 token 时清除(惰性比对) - Connect/HealthCheck 在过期时返回 ErrSessionExpired(可与 ErrNotConnected 区分):不再对死 token 设 connected=true 报假成功;发送路径同样返回该错误而不是笼统 ErrNotConnected - 网关:POST /admin/channels/open 遇过期 → 409 session_expired + 明确文案(不再吞错回 200);GET /admin/channels 摘要新增 needsRescan/lastError(经可选接口类型断言读取,取不到不伪造) - channelbridge:读循环非取消退出时把自身记账的 connected 置 false(热重载被替换者退出不抹掉新实例) - login/login_admin:过期时 StartLogin 允许重新出码、LoginStatus 如实报 failed(原先可能把死 token 当 confirmed 重新落盘) - Docs/api-client v0.39;新增 5 组测试 + 变异验证(去掉标记/恢复假成功/网关不填字段 均 FAIL)
This commit is contained in:
1 parent
2092094d0a
commit
cdc370dc82
14 files changed
+975
-30
No files matched your search
@@ -0,0 +1,279 @@
|
||||
package weixin_ilink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
// These tests cover the explicit session-expiry state machine (session_state.go):
|
||||
// iLink answering errcode=-14 must be visible as "re-scan required" instead of a
|
||||
// silently mute adapter or a connect that reports success with a dead token.
|
||||
|
||||
// expiringServer answers every getupdates long poll with the expired-session
|
||||
// errcode, the way iLink reports a dead bot token.
|
||||
func expiringServer(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": errSessionExpired})}
|
||||
})
|
||||
}
|
||||
|
||||
// expireByPoll drives the adapter into the expired state through the long poll:
|
||||
// ReadMessages must return on errcode=-14 and the adapter must report the
|
||||
// rescan-required state.
|
||||
func expireByPoll(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)
|
||||
}
|
||||
}
|
||||
|
||||
// newExpiredByPollAdapter builds a connected adapter and expires it via the poll
|
||||
// path.
|
||||
func newExpiredByPollAdapter(t *testing.T, srv *httptest.Server) *Adapter {
|
||||
t.Helper()
|
||||
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, PollInterval: time.Millisecond, BotToken: "tok-old"}, srv)
|
||||
expireByPoll(t, ad)
|
||||
return ad
|
||||
}
|
||||
|
||||
// assertExpiredSession asserts the observable contract of an expired session: not
|
||||
// connected, rescan required with a reason, HealthCheck distinguishing expired from
|
||||
// not-connected, and Connect refusing with ErrSessionExpired instead of a fake OK.
|
||||
func assertExpiredSession(t *testing.T, ad *Adapter) {
|
||||
t.Helper()
|
||||
if ad.IsConnected() {
|
||||
t.Fatal("IsConnected() = true while the session is expired")
|
||||
}
|
||||
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, ErrSessionExpired) {
|
||||
t.Fatalf("HealthCheck() = %v, want ErrSessionExpired", herr)
|
||||
}
|
||||
if errors.Is(herr, adapter.ErrNotConnected) {
|
||||
t.Fatal("HealthCheck() also matches ErrNotConnected, want expired to be distinguishable")
|
||||
}
|
||||
|
||||
cerr := ad.Connect(context.Background())
|
||||
if cerr == nil {
|
||||
t.Fatal("Connect() = nil on an expired session, want ErrSessionExpired (no fake OK)")
|
||||
}
|
||||
if !errors.Is(cerr, ErrSessionExpired) {
|
||||
t.Fatalf("Connect() = %v, want ErrSessionExpired", cerr)
|
||||
}
|
||||
if errors.Is(cerr, ErrNeedsLogin) {
|
||||
t.Fatal("Connect() = ErrNeedsLogin, want ErrSessionExpired (expired is not 'unconfigured')")
|
||||
}
|
||||
if ad.IsConnected() {
|
||||
t.Fatal("IsConnected() = true after a refused Connect on an expired session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionExpired_PollMarksRescanAndRefusesConnect(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := expiringServer(t)
|
||||
ad := newExpiredByPollAdapter(t, srv)
|
||||
assertExpiredSession(t, ad)
|
||||
}
|
||||
|
||||
func TestSessionExpired_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": errSessionExpired})}
|
||||
}
|
||||
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, ErrSessionExpired) {
|
||||
t.Fatalf("SendMessage() = %v, want ErrSessionExpired", err)
|
||||
}
|
||||
if ad.IsConnected() {
|
||||
t.Fatal("IsConnected() = true after a send saw errcode=-14")
|
||||
}
|
||||
// A later send reports the expired session itself, not a bare "not connected".
|
||||
err = ad.SendMessage(context.Background(), adapter.UnifiedResponse{
|
||||
Messages: []adapter.UnifiedMessage{{SenderID: "u-1", Content: "again"}},
|
||||
})
|
||||
if !errors.Is(err, ErrSessionExpired) {
|
||||
t.Fatalf("second SendMessage() = %v, want ErrSessionExpired", err)
|
||||
}
|
||||
perr := ad.SendProactive(context.Background(), adapter.UnifiedMessage{SenderID: "u-1", Content: "push"})
|
||||
if !errors.Is(perr, ErrSessionExpired) {
|
||||
t.Fatalf("SendProactive() = %v, want ErrSessionExpired", perr)
|
||||
}
|
||||
assertExpiredSession(t, ad)
|
||||
}
|
||||
|
||||
func TestSessionExpired_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": errSessionExpired})}
|
||||
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 := newExpiredByPollAdapter(t, srv)
|
||||
assertExpiredSession(t, ad)
|
||||
|
||||
// An expired token must not short-circuit the scan flow: it is not a usable
|
||||
// bind, and ErrAlreadyLoggedIn would leave the owner no way back.
|
||||
qr, err := ad.StartLogin(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("StartLogin() on an expired session = %v, want a fresh QR", err)
|
||||
}
|
||||
if qr.Qrcode != "qr-re" {
|
||||
t.Fatalf("StartLogin() qrcode = %q, want qr-re", qr.Qrcode)
|
||||
}
|
||||
|
||||
// The confirmed scan delivers a new token: the expired state must clear.
|
||||
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)
|
||||
}
|
||||
|
||||
// The re-scan asked for a real rebind: the dead token is not advertised as a
|
||||
// local token (that is what invites binded_redirect with no new credentials).
|
||||
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 TestSessionExpired_ClearedByNewToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := expiringServer(t)
|
||||
ad := newExpiredByPollAdapter(t, srv)
|
||||
|
||||
// The rejected token is still configured: the session stays expired.
|
||||
if err := ad.Connect(context.Background()); !errors.Is(err, ErrSessionExpired) {
|
||||
t.Fatalf("Connect() = %v, want ErrSessionExpired while the rejected token is unchanged", err)
|
||||
}
|
||||
if err := ad.HealthCheck(context.Background()); !errors.Is(err, ErrSessionExpired) {
|
||||
t.Fatalf("HealthCheck() = %v, want ErrSessionExpired 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 TestSessionExpired_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": errSessionExpired})}
|
||||
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)
|
||||
expireByPoll(t, ad)
|
||||
|
||||
// A dead token must not read as "confirmed": the panel would re-persist it and
|
||||
// call the bind a success.
|
||||
status, detail := ad.LoginStatus(context.Background())
|
||||
if status != LoginStatusFailed {
|
||||
t.Fatalf("LoginStatus() = %q, want failed while the stored token is expired", status)
|
||||
}
|
||||
if detail == "" {
|
||||
t.Fatal("LoginStatus() detail is empty, want the re-scan reason")
|
||||
}
|
||||
|
||||
// The panel's 扫码登录 must still mint a QR (ErrAlreadyLoggedIn would block the
|
||||
// only recovery path).
|
||||
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)
|
||||
}
|
||||
// While the re-scan is in progress its own state is reported, not the expired one.
|
||||
if status, _ := ad.LoginStatus(context.Background()); status != LoginStatusWaiting {
|
||||
t.Fatalf("LoginStatus() during the re-scan = %q, want waiting", status)
|
||||
}
|
||||
}
|
||||
|
||||
// setBotToken swaps the configured bot token, standing in for a panel save +
|
||||
// hot reload (which rebuilds the adapter) or any other in-place config refresh;
|
||||
// the adapter has no public setter because production always constructs a fresh
|
||||
// instance from the new config.
|
||||
func setBotToken(ad *Adapter, token string) {
|
||||
ad.mu.Lock()
|
||||
ad.cfg.BotToken = token
|
||||
ad.mu.Unlock()
|
||||
}
|
||||
Reference in new issue
Block a user