主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批: 已推库 16 个文件 + 本批剩余文件)。 校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0); 全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
398 lines
13 KiB
Go
398 lines
13 KiB
Go
package gateway
|
|
|
|
// 演练 weixin-iLink 扫码登录管理端点(design/08 §4)。适配器
|
|
// 经由一个内嵌 adapter.PlatformAdapter 的包装器注册 —— 仿照
|
|
// 仿照 cmd/cyrene 的 namedAdapter —— 因此这些测试也覆盖网关
|
|
// 用来抵达具体 *weixin_ilink.Adapter 的反射拆包。
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"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/internal/config"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink"
|
|
)
|
|
|
|
// gatewayWeixinWrap 按 cmd/cyrene 的 namedAdapter 的方式内嵌
|
|
// adapter.PlatformAdapter,从而演练网关基于反射的拆包(asWeixinLoginAdmin)。
|
|
type gatewayWeixinWrap struct {
|
|
adapter.PlatformAdapter
|
|
name string
|
|
}
|
|
|
|
func (w *gatewayWeixinWrap) Name() string { return w.name }
|
|
|
|
// fakeWeixinChannels 是供「确认即持久化」路径用的最小 ChannelConfigStore。
|
|
type fakeWeixinChannels struct {
|
|
path string
|
|
data map[string][]config.ChannelAccount
|
|
reloaded []string
|
|
}
|
|
|
|
func (f *fakeWeixinChannels) Path() string { return f.path }
|
|
func (f *fakeWeixinChannels) Load() map[string][]config.ChannelAccount {
|
|
return f.data
|
|
}
|
|
func (f *fakeWeixinChannels) Reload(platform string) error {
|
|
f.reloaded = append(f.reloaded, platform)
|
|
return nil
|
|
}
|
|
|
|
// weixinLoginServer 应答扫码登录表面:固定的二维码与立即
|
|
// 确认的状态(使后台轮询在一个轮询周期内确认)。
|
|
func weixinLoginServer(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch r.URL.Path {
|
|
case "/ilink/bot/get_bot_qrcode":
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"qrcode": "qr-x", "qrcode_img_content": "data:image/png;base64,QUJD",
|
|
})
|
|
case "/ilink/bot/get_qrcode_status":
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"status": "confirmed", "bot_token": "tok-x",
|
|
"ilink_bot_id": "bot-x", "ilink_user_id": "o-1",
|
|
})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
})
|
|
srv := httptest.NewServer(h)
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
// newWeixinGateway 接线一个注册表,其 weixin 渠道包装真实适配器,
|
|
// 并返回 httptest URL + owner JWT。channels 可为 nil(不做持久化测试)。
|
|
func newWeixinGateway(t *testing.T, name string, channels ChannelConfigStore) (string, string) {
|
|
t.Helper()
|
|
wsrv := weixinLoginServer(t)
|
|
wa, err := weixin_ilink.New(
|
|
weixin_ilink.Config{
|
|
BaseURL: wsrv.URL,
|
|
LoginInterval: time.Millisecond,
|
|
LoginTimeout: 5 * time.Second,
|
|
},
|
|
wsrv.Client(),
|
|
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: name}); err != nil {
|
|
t.Fatalf("register channel: %v", err)
|
|
}
|
|
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: reg, Channels: channels})
|
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
|
return ts.URL, tok
|
|
}
|
|
|
|
// newWeixinGatewayPresetToken 构建一个网关,其 weixin 适配器已持有
|
|
// token,因此登录端点无需任何网络就报告已确认。
|
|
func newWeixinGatewayPresetToken(t *testing.T, name string) (string, string) {
|
|
t.Helper()
|
|
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)
|
|
}
|
|
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
|
|
}
|
|
|
|
// weixinLoginWaitingServer 应答二维码但状态一直是 "wait"(永不
|
|
// 确认),因此面板轮询的是一个仍在进行中的登录。
|
|
func weixinLoginWaitingServer(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch r.URL.Path {
|
|
case "/ilink/bot/get_bot_qrcode":
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"qrcode": "qr-w", "qrcode_img_content": "data:image/png;base64,V0FJVElORw==",
|
|
})
|
|
case "/ilink/bot/get_qrcode_status":
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"status": "wait"})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
})
|
|
srv := httptest.NewServer(h)
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
// newWeixinGatewayWaiting 接线一个 weixin 适配器,其状态轮询永不确认。
|
|
func newWeixinGatewayWaiting(t *testing.T, name string) (string, string) {
|
|
t.Helper()
|
|
wsrv := weixinLoginWaitingServer(t)
|
|
wa, err := weixin_ilink.New(
|
|
weixin_ilink.Config{BaseURL: wsrv.URL, LoginInterval: 50 * time.Millisecond, LoginTimeout: 30 * time.Second},
|
|
wsrv.Client(),
|
|
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: 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
|
|
}
|
|
|
|
func TestAdminWeixinLoginStatus_Waiting(t *testing.T) {
|
|
t.Parallel()
|
|
base, tok := newWeixinGatewayWaiting(t, "weixin-ilink:conf0")
|
|
|
|
if resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/channels/weixin-ilink/login-qr?accountId=conf0", nil, tok); resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("login-qr status = %d, body=%s", resp.StatusCode, body)
|
|
}
|
|
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)
|
|
}
|
|
var out struct {
|
|
Data struct {
|
|
Status string `json:"status"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(body, &out); err != nil {
|
|
t.Fatalf("decode login-status: %v", err)
|
|
}
|
|
if out.Data.Status != weixin_ilink.LoginStatusWaiting {
|
|
t.Errorf("login-status = %q, want waiting", out.Data.Status)
|
|
}
|
|
}
|
|
|
|
func TestAdminWeixinLoginQR_ReturnsQRAndStatus(t *testing.T) {
|
|
t.Parallel()
|
|
base, tok := newWeixinGateway(t, "weixin-ilink:conf0", nil)
|
|
|
|
resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/channels/weixin-ilink/login-qr?accountId=conf0", nil, tok)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("login-qr status = %d, body=%s", resp.StatusCode, body)
|
|
}
|
|
var out struct {
|
|
Data struct {
|
|
QrUrl string `json:"qrUrl"`
|
|
QrCode string `json:"qrCode"`
|
|
Status string `json:"status"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(body, &out); err != nil {
|
|
t.Fatalf("decode login-qr: %v", err)
|
|
}
|
|
if out.Data.QrUrl == "" {
|
|
t.Errorf("qrUrl empty, want the QR image URL (qrcode_img_content)")
|
|
}
|
|
if out.Data.QrCode == "" {
|
|
t.Errorf("qrCode empty, want the raw scan identifier")
|
|
}
|
|
if out.Data.Status != weixin_ilink.LoginStatusWaiting && out.Data.Status != weixin_ilink.LoginStatusConfirmed {
|
|
t.Errorf("status = %q, want waiting|confirmed", out.Data.Status)
|
|
}
|
|
}
|
|
|
|
func TestAdminWeixinLoginStatus_ConfirmPersists(t *testing.T) {
|
|
t.Parallel()
|
|
path := filepath.Join(t.TempDir(), "channels.json")
|
|
chs := &fakeWeixinChannels{
|
|
path: path,
|
|
data: map[string][]config.ChannelAccount{
|
|
"weixin-ilink": {{ID: "conf0", Config: map[string]any{"base_url": "https://base"}}},
|
|
},
|
|
}
|
|
base, tok := newWeixinGateway(t, "weixin-ilink:conf0", chs)
|
|
|
|
// 触发二维码,然后轮询直到后台登录确认并持久化。
|
|
if resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/channels/weixin-ilink/login-qr?accountId=conf0", nil, tok); resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("login-qr status = %d, body=%s", resp.StatusCode, body)
|
|
}
|
|
|
|
var out struct {
|
|
Data struct {
|
|
Status string `json:"status"`
|
|
BotID string `json:"botId"`
|
|
BotToken string `json:"botToken"`
|
|
} `json:"data"`
|
|
}
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for {
|
|
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)
|
|
}
|
|
out = struct {
|
|
Data struct {
|
|
Status string `json:"status"`
|
|
BotID string `json:"botId"`
|
|
BotToken string `json:"botToken"`
|
|
} `json:"data"`
|
|
}{}
|
|
if err := json.Unmarshal(body, &out); err != nil {
|
|
t.Fatalf("decode login-status: %v", err)
|
|
}
|
|
if out.Data.Status == weixin_ilink.LoginStatusConfirmed {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("login never confirmed, last status=%q body=%s", out.Data.Status, body)
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
|
|
if out.Data.BotID != "bot-x" {
|
|
t.Errorf("botId = %q, want bot-x", out.Data.BotID)
|
|
}
|
|
if out.Data.BotToken != "****ok-x" {
|
|
t.Errorf("botToken = %q, want masked ****ok-x", out.Data.BotToken)
|
|
}
|
|
if len(chs.reloaded) != 1 || chs.reloaded[0] != "weixin-ilink" {
|
|
t.Errorf("reloaded = %v, want [weixin-ilink]", chs.reloaded)
|
|
}
|
|
persisted, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read persisted channels.json: %v", err)
|
|
}
|
|
if !strings.Contains(string(persisted), "tok-x") || !strings.Contains(string(persisted), "bot-x") {
|
|
t.Errorf("channels.json did not persist token/bot_id:\n%s", persisted)
|
|
}
|
|
}
|
|
|
|
func TestAdminWeixinLoginStatus_AlreadyConfirmed(t *testing.T) {
|
|
t.Parallel()
|
|
base, tok := newWeixinGatewayPresetToken(t, "weixin-ilink:conf0")
|
|
|
|
resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/channels/weixin-ilink/login-qr?accountId=conf0", nil, tok)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("login-qr status = %d, body=%s", resp.StatusCode, body)
|
|
}
|
|
var qr struct {
|
|
Data struct {
|
|
Status string `json:"status"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(body, &qr); err != nil {
|
|
t.Fatalf("decode login-qr: %v", err)
|
|
}
|
|
if qr.Data.Status != weixin_ilink.LoginStatusConfirmed {
|
|
t.Errorf("login-qr status = %q, want confirmed (already logged in)", qr.Data.Status)
|
|
}
|
|
|
|
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)
|
|
}
|
|
var out struct {
|
|
Data struct {
|
|
Status string `json:"status"`
|
|
BotID string `json:"botId"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(body, &out); err != nil {
|
|
t.Fatalf("decode login-status: %v", err)
|
|
}
|
|
if out.Data.Status != weixin_ilink.LoginStatusConfirmed || out.Data.BotID != "bot-pre" {
|
|
t.Errorf("login-status = %+v, want confirmed + bot-pre", out.Data)
|
|
}
|
|
}
|
|
|
|
func TestAdminWeixinLogin_NonWeixinNotImplemented(t *testing.T) {
|
|
t.Parallel()
|
|
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
|
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
|
|
|
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels/telegram/login-qr", nil, tok)
|
|
if resp.StatusCode != http.StatusNotImplemented {
|
|
t.Fatalf("status = %d, want 501 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
}
|
|
|
|
// ownerRegCall 记录一次 RegisterChannelOwner 调用。
|
|
type ownerRegCall struct {
|
|
platform, accountID string
|
|
}
|
|
|
|
// captureOwnerRegistrar 记录 weixin 确认路径的白名单登记。
|
|
type captureOwnerRegistrar struct {
|
|
calls []ownerRegCall
|
|
}
|
|
|
|
func (c *captureOwnerRegistrar) RegisterChannelOwner(_ context.Context, platform, accountID string) error {
|
|
c.calls = append(c.calls, ownerRegCall{platform, accountID})
|
|
return nil
|
|
}
|
|
|
|
// TestPersistWeixinConfirmed_RegistersOwner 验证:一旦扫码登录确认
|
|
// 把凭据持久化进 channels.json,被绑定的主人 id 就会以
|
|
// (weixin-ilink, ilink_user_id) 交给 OwnerRegistrar,而空
|
|
// 主人 id 会完全跳过该登记。
|
|
func TestPersistWeixinConfirmed_RegistersOwner(t *testing.T) {
|
|
t.Parallel()
|
|
path := filepath.Join(t.TempDir(), "channels.json")
|
|
chs := &fakeWeixinChannels{
|
|
path: path,
|
|
data: map[string][]config.ChannelAccount{
|
|
"weixin-ilink": {{ID: "conf0", Config: map[string]any{"base_url": "https://base"}}},
|
|
},
|
|
}
|
|
reg := &captureOwnerRegistrar{}
|
|
s := &Server{
|
|
channels: chs,
|
|
ownerRegistrar: reg,
|
|
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
}
|
|
|
|
if err := s.persistWeixinConfirmed(context.Background(), "weixin-ilink", "conf0", "tok-x", "bot-x", "o-1"); err != nil {
|
|
t.Fatalf("persistWeixinConfirmed: %v", err)
|
|
}
|
|
if len(reg.calls) != 1 || reg.calls[0].platform != "weixin-ilink" || reg.calls[0].accountID != "o-1" {
|
|
t.Fatalf("registrar calls = %+v, want [weixin-ilink/o-1]", reg.calls)
|
|
}
|
|
|
|
// 空的主人 id(一次确认了但没有产出绑定用户的绑定)绝不
|
|
// 登记任何内容。
|
|
if err := s.persistWeixinConfirmed(context.Background(), "weixin-ilink", "conf0", "tok-y", "bot-y", ""); err != nil {
|
|
t.Fatalf("persistWeixinConfirmed(no owner): %v", err)
|
|
}
|
|
if len(reg.calls) != 1 {
|
|
t.Fatalf("registrar calls after empty ownerID = %+v, want still 1", reg.calls)
|
|
}
|
|
}
|
|
|
|
// TestNew_WiresOwnerRegistrar 验证组合点:Deps.OwnerRegistrar
|
|
// 被串到 Server 上,使 weixin 确认流程有活的 registrar。
|
|
func TestNew_WiresOwnerRegistrar(t *testing.T) {
|
|
t.Parallel()
|
|
reg := &captureOwnerRegistrar{}
|
|
srv, _, _ := buildEnv(t, cfgDefault(), Deps{OwnerRegistrar: reg})
|
|
if srv.ownerRegistrar != reg {
|
|
t.Fatalf("ownerRegistrar = %#v, want the injected registrar", srv.ownerRegistrar)
|
|
}
|
|
}
|