- 适配器新增过期状态机(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)
243 lines
8.8 KiB
Go
243 lines
8.8 KiB
Go
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")
|
||
}
|
||
}
|