package gateway // 演练 weixin-iLink **会话凭据三态**(有效 / 被拒 / 未知)在**既有**登录状态端点上的 // 浮现(design/08 §4 的 weixin-ilink 条目)。判定与文案的权威在适配器侧 // (plugins/channels/weixin_ilink/session_status.go);这里守的是**线上口径**: // // - 有凭据但还没有一次被 iLink 接受的调用 ⇒ `authState:"unknown"`,**不是**「有效」; // - errcode=-14 ⇒ `authState:"rejected"` + 明说「已被 iLink 拒绝,需要重新扫码」; // - 除了断言取值,还直接断言**整个响应体**里不出现「过期」—— -14 是被拒不是过期, // 未知同样不许说成过期(那是一句会让主人「等一会儿就好了」的假话)。 // // 适配器没有这条状态面时两个键**缺席**(≠ unknown)。 import ( "context" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "strings" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink" ) // weixinAuthStatus 是登录状态端点里本切片关心的字段。 type weixinAuthStatus struct { Status string `json:"status"` AuthState string `json:"authState"` AuthDetail string `json:"authDetail"` } // fetchWeixinAuthStatus 打既有的登录状态端点并解析三态,同时钉住「全文不出现过期」。 func fetchWeixinAuthStatus(t *testing.T, base, tok string) weixinAuthStatus { t.Helper() resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/channels/weixin-ilink/login-status?accountId=conf0", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("login-status status = %d, body=%s", resp.StatusCode, body) } if strings.Contains(string(body), "过期") { t.Fatalf("响应体里出现「过期」:-14 是「被拒」不是「过期」,未知也不许说成过期\nbody=%s", body) } var out struct { Data weixinAuthStatus `json:"data"` } if err := json.Unmarshal(body, &out); err != nil { t.Fatalf("decode login-status: %v", err) } return out.Data } // newWeixinGatewayWithAdapter 用**给定的** weixin 适配器接线一个网关(仿照 // newWeixinGateway,但适配器由测试掌控,以便先把状态驱到某一点再打端点)。 func newWeixinGatewayWithAdapter(t *testing.T, name string, wa *weixin_ilink.Adapter) (string, string) { t.Helper() reg := registry.New() if err := reg.RegisterChannel(&gatewayWeixinWrap{PlatformAdapter: wa, name: name}); err != nil { t.Fatalf("register channel: %v", err) } _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: reg}) tok := signAny(t, jwt, "acct", auth.RoleOwner) return ts.URL, tok } // newWeixinPollAdapter 构造一个**已连接**、带 token、并按 fn(收到第 call 次 // getupdates)作答的 weixin 适配器,同时跑起读循环(读循环是「有效」的证据来源)。 // 清理时先停读循环、再关假服务端。 func newWeixinPollAdapter(t *testing.T, fn func(call int) map[string]any) *weixin_ilink.Adapter { t.Helper() calls := 0 srv := 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" { http.NotFound(w, r) return } calls++ _ = json.NewEncoder(w).Encode(fn(calls)) })) t.Cleanup(srv.Close) wa, err := weixin_ilink.New( weixin_ilink.Config{BaseURL: srv.URL, PollInterval: time.Millisecond, BotToken: "tok-gw"}, srv.Client(), slog.New(slog.NewTextHandler(io.Discard, nil)), ) if err != nil { t.Fatalf("weixin new: %v", err) } if err := wa.Connect(context.Background()); err != nil { t.Fatalf("weixin connect: %v", err) } ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done) wa.ReadMessages(ctx, func(adapter.UnifiedMessage) {}) }() t.Cleanup(func() { // 后注册 ⇒ 先执行:先把读循环停干净,再让假服务端关闭。 cancel() select { case <-done: case <-time.After(2 * time.Second): t.Error("weixin ReadMessages 未在取消后返回") } }) return wa } // waitWeixinAuthState 轮询适配器直到它报告想要的会话三态,超时则失败。 func waitWeixinAuthState(t *testing.T, wa *weixin_ilink.Adapter, want string) { t.Helper() deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { if state, _ := wa.SessionStatus(); state == want { return } time.Sleep(2 * time.Millisecond) } state, detail := wa.SessionStatus() t.Fatalf("SessionStatus() = (%q,%q), want %q within timeout", state, detail, want) } // TestAdminWeixinLoginStatus_AuthStateUnknownUntilAccepted 钉住「有凭据 ≠ 有效」: // 预置了 token、但 iLink 从未接受过它 ⇒ 端点上必须是 unknown(带如实的说明), // 而**扫码登录面**照旧报 confirmed(两件事,互不影响)。 func TestAdminWeixinLoginStatus_AuthStateUnknownUntilAccepted(t *testing.T) { t.Parallel() wa, err := weixin_ilink.New( weixin_ilink.Config{BaseURL: "https://example.invalid", BotToken: "tok-pre", BotID: "bot-pre"}, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), ) if err != nil { t.Fatalf("weixin new: %v", err) } base, tok := newWeixinGatewayWithAdapter(t, "weixin-ilink:conf0", wa) got := fetchWeixinAuthStatus(t, base, tok) if got.AuthState != weixin_ilink.SessionStateUnknown { t.Fatalf("authState = %q, want unknown(尚未被 iLink 接受过,不能画成有效)", got.AuthState) } if got.AuthDetail == "" { t.Fatal("authDetail 为空,面板将无从如实说明「为什么是未知」") } if got.Status != weixin_ilink.LoginStatusConfirmed { t.Fatalf("status = %q, want confirmed(登录面看「有没有凭据」,与会话三态是两件事)", got.Status) } } // TestAdminWeixinLoginStatus_AuthStateActiveAfterAcceptedPoll 一次成功的长轮询就是 // iLink 接受该凭据的证据 ⇒ 三态变 active。 func TestAdminWeixinLoginStatus_AuthStateActiveAfterAcceptedPoll(t *testing.T) { t.Parallel() wa := newWeixinPollAdapter(t, func(int) map[string]any { return map[string]any{"msgs": []any{}, "get_updates_buf": "c1"} }) base, tok := newWeixinGatewayWithAdapter(t, "weixin-ilink:conf0", wa) waitWeixinAuthState(t, wa, weixin_ilink.SessionStateActive) got := fetchWeixinAuthStatus(t, base, tok) if got.AuthState != weixin_ilink.SessionStateActive { t.Fatalf("authState = %q, want active after a successful getupdates", got.AuthState) } if got.AuthDetail == "" { t.Fatal("authDetail 为空,want 一条写明依据的说明") } } // TestAdminWeixinLoginStatus_AuthStateRejectedIsNotExpired 钉住铁律:iLink 回 // errcode=-14 ⇒ authState=rejected + 要求重新扫码;登录面同时报 failed(不假 OK)。 func TestAdminWeixinLoginStatus_AuthStateRejectedIsNotExpired(t *testing.T) { t.Parallel() wa := newWeixinPollAdapter(t, func(call int) map[string]any { if call == 1 { return map[string]any{"msgs": []any{}, "get_updates_buf": "c1"} } return map[string]any{"errcode": -14} }) base, tok := newWeixinGatewayWithAdapter(t, "weixin-ilink:conf0", wa) waitWeixinAuthState(t, wa, weixin_ilink.SessionStateRejected) got := fetchWeixinAuthStatus(t, base, tok) if got.AuthState != weixin_ilink.SessionStateRejected { t.Fatalf("authState = %q, want rejected after errcode=-14", got.AuthState) } if !strings.Contains(got.AuthDetail, "拒绝") { t.Fatalf("authDetail = %q, want 明说「被拒」", got.AuthDetail) } if !strings.Contains(got.AuthDetail, "重新扫码") { t.Fatalf("authDetail = %q, want 给出出路(重新扫码)", got.AuthDetail) } if got.Status != weixin_ilink.LoginStatusFailed { t.Fatalf("status = %q, want failed(被拒的凭据绝不能被报成 confirmed)", got.Status) } } // weixinNoSessionChannel 是一个**没有**会话三态能力、但暴露扫码登录面的渠道值: // 用来证明「适配器没这条状态面 ⇒ 两个键缺席」,而不是凭空编一个 unknown 出来。 type weixinNoSessionChannel struct { adapter.PlatformAdapter name string } func (c weixinNoSessionChannel) Name() string { return c.name } func (weixinNoSessionChannel) StartLogin(context.Context) (weixin_ilink.LoginQR, error) { return weixin_ilink.LoginQR{}, nil } func (weixinNoSessionChannel) LoginStatus(context.Context) (string, string) { return weixin_ilink.LoginStatusIdle, "" } func (weixinNoSessionChannel) Config() map[string]any { return map[string]any{} } func TestAdminWeixinLoginStatus_AuthStateAbsentWithoutCapability(t *testing.T) { t.Parallel() reg := registry.New() if err := reg.RegisterChannel(weixinNoSessionChannel{name: "weixin-ilink:conf0"}); err != nil { t.Fatalf("register channel: %v", err) } _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: reg}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels/weixin-ilink/login-status?accountId=conf0", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("login-status status = %d, body=%s", resp.StatusCode, body) } if strings.Contains(string(body), "authState") || strings.Contains(string(body), "authDetail") { t.Fatalf("适配器没有会话三态能力,两个键必须缺席(缺席 ≠ unknown)\nbody=%s", body) } } // TestLiveAccountAuthStates_三态齐全且不丢未知 钉住通用账号状态面的收集口径 // (liveAccountAuthStates):① 能穿过注册包装器(namedAdapter 式)抵达具体适配器; // ② 「未知」是**值**不是缺席(有状态面但尚未确认 ⇒ 带说明进 map);③ 没有这条状态面 // 的渠道值干脆不进 map(缺席 = 该账号没有这条状态面,绝不冒充 unknown)。 // // ⚠️ 该收集器当前**尚未接线**到 GET /admin/channels 的 accounts[](那需要给 // AdminChannelAccount 新增两个字段,落在本片文件面之外)⇒ 接线清单见 // DevDocs/_dispatch/CODE-F3-iLink账号状态三态.md。 func TestLiveAccountAuthStates_三态齐全且不丢未知(t *testing.T) { t.Parallel() wa, err := weixin_ilink.New( weixin_ilink.Config{BaseURL: "https://example.invalid", BotToken: "tok"}, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), ) if err != nil { t.Fatalf("weixin new: %v", err) } reg := registry.New() if err := reg.RegisterChannel(&gatewayWeixinWrap{PlatformAdapter: wa, name: "weixin-ilink:conf0"}); err != nil { t.Fatalf("register channel: %v", err) } s := &Server{caps: reg} got := s.liveAccountAuthStates()["weixin-ilink"]["conf0"] if got.State != weixin_ilink.SessionStateUnknown { t.Fatalf("state = %q, want unknown(有凭据但尚未被 iLink 接受 ⇒ 是值,不是缺席)", got.State) } if got.Detail == "" { t.Fatal("detail 为空:未知必须带着「为什么未知」上屏") } assertNoExpiryWording(t, "liveAccountAuthStates 的未知", got.Detail) // 没有三态能力的渠道值:不进 map(缺席 ≠ unknown,面板必须能区分这两件事)。 plain := registry.New() if err := plain.RegisterChannel(weixinNoSessionChannel{name: "weixin-ilink:conf0"}); err != nil { t.Fatalf("register channel: %v", err) } if _, ok := (&Server{caps: plain}).liveAccountAuthStates()["weixin-ilink"]["conf0"]; ok { t.Fatal("没有三态能力的账号不该出现在 map 里(缺席只留给「没有这条状态面」)") } } // assertNoExpiryWording 钉住「-14 是被拒不是过期」这条铁律在**网关侧**也不许破: // 任何交给客户端的说明里都不出现「过期」。 func assertNoExpiryWording(t *testing.T, label, detail string) { t.Helper() if strings.Contains(detail, "过期") || strings.Contains(detail, "expired") { t.Fatalf("%s detail 含「过期/expired」:errcode=-14 是被拒不是过期,未知同样不许说成过期:%q", label, detail) } }