Files
Cyrene_v2/internal/services/gateway/admin_channels_rescan_test.go
T
AskaEth cdc370dc82
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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)
2026-10-03 18:00:01 +08:00

243 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink"
)
// rescanStubAdapter is a channel adapter that also exposes the optional rescan
// capability, exactly as *weixin_ilink.Adapter does once iLink answers
// errcode=-14 (expired bot token, re-scan required).
type rescanStubAdapter struct {
name string
connected bool
rescan bool
reason string
connectErr error
}
func (s *rescanStubAdapter) Name() string { return s.name }
func (s *rescanStubAdapter) Config() map[string]any { return map[string]any{"bot_token": "configured"} }
func (s *rescanStubAdapter) Connect(context.Context) error { return s.connectErr }
func (s *rescanStubAdapter) Disconnect(context.Context) error { return nil }
func (s *rescanStubAdapter) IsConnected() bool { return s.connected }
func (s *rescanStubAdapter) HealthCheck(context.Context) error { return nil }
func (s *rescanStubAdapter) SendMessage(context.Context, adapter.UnifiedResponse) error {
return nil
}
func (s *rescanStubAdapter) SendProactive(context.Context, adapter.UnifiedMessage) error { return nil }
func (s *rescanStubAdapter) SetMessageHandler(adapter.MessageHandler) {}
func (s *rescanStubAdapter) Capabilities() adapter.Capabilities { return adapter.DefaultCapabilities() }
func (s *rescanStubAdapter) ListChannels(context.Context) ([]adapter.ChannelInfo, error) {
return nil, nil
}
func (s *rescanStubAdapter) ReadMessages(ctx context.Context, _ func(adapter.UnifiedMessage)) {
<-ctx.Done()
}
func (s *rescanStubAdapter) RescanRequired() (bool, string) { return s.rescan, s.reason }
// namedRescanAdapter mirrors cmd/cyrene's namedAdapter: an unexported wrapper
// embedding adapter.PlatformAdapter, which promotes only the interface's methods.
// The gateway therefore has to unwrap it to reach the adapter's rescan capability.
type namedRescanAdapter struct{ adapter.PlatformAdapter }
var (
_ adapter.PlatformAdapter = (*rescanStubAdapter)(nil)
_ sdk.Channel = namedRescanAdapter{}
)
// TestAdminChannelsReportsNeedsRescan asserts the panel can tell "needs a fresh
// scan" apart from plain "not connected": the expired adapter's row carries
// needsRescan + the reason, and a healthy adapter omits both rather than shipping
// a fabricated value.
func TestAdminChannelsReportsNeedsRescan(t *testing.T) {
t.Parallel()
const reason = "iLink getupdates 返回 errcode=-14(bot token 已过期)"
caps := capreg.New()
// Registered through the wrapper, the way the composition root registers it.
if err := caps.Register(capreg.Capability{
Name: "weixin-ilink",
Kind: capreg.KindChannel,
Value: namedRescanAdapter{PlatformAdapter: &rescanStubAdapter{name: "weixin-ilink", rescan: true, reason: reason}},
}); err != nil {
t.Fatalf("register weixin-ilink: %v", err)
}
if err := caps.Register(capreg.Capability{
Name: "telegram",
Kind: capreg.KindChannel,
Value: &rescanStubAdapter{name: "telegram", connected: true},
}); err != nil {
t.Fatalf("register telegram: %v", err)
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
items, body := adminChannelsList(t, ts, tok)
if len(items) != 2 {
t.Fatalf("channels = %+v, want 2 rows", items)
}
byName := map[string]ChannelSummary{}
for _, it := range items {
byName[it.Name] = it
}
wx, ok := byName["weixin-ilink"]
if !ok {
t.Fatalf("no weixin-ilink row in %+v", items)
}
if wx.Connected {
t.Error("weixin-ilink connected = true, want false while the token is expired")
}
if !wx.NeedsRescan {
t.Error("weixin-ilink needsRescan = false, want true (expired token must ask for a re-scan)")
}
if wx.LastError != reason {
t.Errorf("weixin-ilink lastError = %q, want %q", wx.LastError, reason)
}
tg, ok := byName["telegram"]
if !ok {
t.Fatalf("no telegram row in %+v", items)
}
if !tg.Connected || tg.NeedsRescan || tg.LastError != "" {
t.Errorf("telegram = %+v, want a connected row without rescan fields", tg)
}
// The healthy channel must OMIT the fields (omitempty), not send defaults: the
// panel distinguishes "adapter did not report" from "adapter reported false".
var raw []map[string]json.RawMessage
if err := json.Unmarshal(decodeBody(t, body)["data"], &raw); err != nil {
t.Fatalf("decode raw items: %v (body=%s)", err, body)
}
for _, item := range raw {
if string(item["name"]) != `"telegram"` {
continue
}
if _, present := item["needsRescan"]; present {
t.Error("telegram row carries needsRescan although the adapter never asked for a re-scan")
}
if _, present := item["lastError"]; present {
t.Error("telegram row carries lastError although there is none")
}
}
// The reason is informational text, never a credential.
if strings.Contains(string(body), `"bot_token"`) {
t.Errorf("channel list leaked a bot_token field: %s", body)
}
}
// TestAdminChannelOpenExpiredSessionReturns409 asserts the open endpoint answers
// honestly for a session a reconnect cannot fix: 409 session_expired with the
// re-scan instruction, never a 200 that would make the panel show "connected".
func TestAdminChannelOpenExpiredSessionReturns409(t *testing.T) {
t.Parallel()
caps := capreg.New()
if err := caps.Register(capreg.Capability{
Name: "weixin-ilink",
Kind: capreg.KindChannel,
Value: &rescanStubAdapter{
name: "weixin-ilink", rescan: true,
reason: "iLink bot token 已过期",
connectErr: weixin_ilink.ErrSessionExpired,
},
}); err != nil {
t.Fatalf("register weixin-ilink: %v", err)
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/open",
map[string]any{"name": "weixin-ilink"}, tok)
if resp.StatusCode != http.StatusConflict {
t.Fatalf("open status = %d, want 409 (body=%s)", resp.StatusCode, body)
}
var eobj struct {
Code string `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(decodeBody(t, body)["error"], &eobj); err != nil {
t.Fatalf("decode error envelope: %v (body=%s)", err, body)
}
if eobj.Code != "session_expired" {
t.Fatalf("error code = %q, want session_expired", eobj.Code)
}
if !strings.Contains(eobj.Message, "扫码") {
t.Fatalf("error message = %q, want the re-scan instruction", eobj.Message)
}
}
// TestAdminChannelsRescanFromRealIlinkAdapter closes the loop across the layers:
// a REAL *weixin_ilink.Adapter that iLink answered errcode=-14 for, registered the
// way the composition root registers it (wrapped), must surface needsRescan +
// lastError in the admin channel list.
func TestAdminChannelsRescanFromRealIlinkAdapter(t *testing.T) {
t.Parallel()
ilog := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/ilink/bot/getupdates" {
w.WriteHeader(http.StatusNotFound)
return
}
_, _ = w.Write([]byte(`{"errcode":-14}`))
}))
defer ilog.Close()
ad, err := weixin_ilink.New(weixin_ilink.Config{
BaseURL: ilog.URL, BotToken: "tok", PollInterval: time.Millisecond,
}, ilog.Client(), nil)
if err != nil {
t.Fatalf("new weixin adapter: %v", err)
}
if err := ad.Connect(context.Background()); err != nil {
t.Fatalf("connect before expiry: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go ad.ReadMessages(ctx, func(adapter.UnifiedMessage) {})
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if required, _ := ad.RescanRequired(); required {
break
}
time.Sleep(time.Millisecond)
}
if required, _ := ad.RescanRequired(); !required {
t.Fatal("real adapter never reached the rescan-required state")
}
caps := capreg.New()
if err := caps.Register(capreg.Capability{
Name: "weixin-ilink",
Kind: capreg.KindChannel,
Value: namedRescanAdapter{PlatformAdapter: ad},
}); err != nil {
t.Fatalf("register weixin-ilink: %v", err)
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
items, _ := adminChannelsList(t, ts, tok)
if len(items) != 1 {
t.Fatalf("channels = %+v, want one weixin-ilink row", items)
}
if items[0].Connected {
t.Error("connected = true although iLink rejected the token")
}
if !items[0].NeedsRescan {
t.Error("needsRescan = false, want true for an expired iLink session")
}
if items[0].LastError == "" {
t.Error("lastError is empty, want the re-scan reason")
}
}