Files
Cyrene_v2/internal/services/gateway/admin_channels_weixin_status_test.go
AskaEth 7505c46b70 feat(channel): 微信 iLink 账号状态三态(诚实口径)
state ∈ 有效 / **被拒(需重扫)** / 未知;🔴 -14 是**被拒不是过期**,且**未知原因绝不
写成「过期」**(面板文案与错误码都如实)。三态判定为纯函数 + 三态各有用例。

⚠️ 未做(如实登记): 三态(每账号逐条三态)本片未做。
2026-10-06 09:03:44 +08:00

290 lines
12 KiB
Go
Raw Permalink 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
// 演练 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)
}
}