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-rejection 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 credential iLink // already refused. // rejectingServer answers every getupdates long poll with the rejected-session // errcode, the way iLink refuses a session credential. 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 drives the adapter into the rejected state through the long poll: // ReadMessages must return on errcode=-14 and the adapter must report the // rescan-required state. 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 builds a connected adapter and drives it into the // rejected state via the poll path. 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 asserts the observable contract of a session iLink rejected: // not connected, rescan required with a reason, HealthCheck distinguishing the // rejected state from not-connected, and Connect refusing with ErrSessionRejected // instead of a fake 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") } // A later send reports the rejected 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, 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) // A rejected 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 a rejected 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 rejected 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 rejected 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 TestSessionRejected_ClearedByNewToken(t *testing.T) { t.Parallel() srv := rejectingServer(t) ad := newRejectedByPollAdapter(t, srv) // The rejected token is still configured: the session stays rejected. 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) // A rejected credential 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 iLink rejects the stored token", 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 rejected 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() }