feat(weixin): 微信iLink扫码登录——面板触发+二维码显示+状态轮询+确认热连接
适配器: login_admin.go(StartLogin 幂等取二维码/LoginStatus idle-waiting-confirmed-failed);
login.go 重写 Connect 委托 StartLogin+等待,后台 runLogin 轮询,completeLogin/failLogin
(锁序 loginMu->a.mu 不嵌套反向无死锁); adapter.go 加登录会话字段
网关: admin_channels_weixin.go(2 端点 login-qr/login-status owner+审计, confirmed 合并写回
channels.json 保留 base_url/cdn/黑白名单 + Reload 热重载连接; qrImage best-effort Base64,
token 掩码) + server.go 路由
前端: WeixinLogin.tsx(token空→扫码按钮→二维码→1.5s轮询→confirmed提示已登录+显示bot_id),
AdapterDetailPage 渲染, api/types 补类型
必需偏离(最小化): 允许空token的weixin账号被构建/持久化(channels.go+runtimeconfig移出
tokenRequiringPlatforms),否则空token账号无法注册调用StartLogin; 空token保持断开(configured
仍要求token非空),确认后才持久化连接——守红线。
测试: login_admin/admin端点用例; 后端 build/vet/test 全绿 + 前端 build 101模块绿.
This commit is contained in:
1 parent
0a597cfa42
commit
e037fab7d0
13 files changed
+1155
-46
No files matched your search
@@ -0,0 +1,329 @@
|
||||
package gateway
|
||||
|
||||
// Exercises the weixin-iLink scan-login admin endpoints (design/08 §4). The
|
||||
// adapter is registered through a wrapper that embeds adapter.PlatformAdapter —
|
||||
// mirroring cmd/cyrene's namedAdapter — so these tests also cover the reflection
|
||||
// unwrap the gateway uses to reach the concrete *weixin_ilink.Adapter.
|
||||
|
||||
import (
|
||||
"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 embeds adapter.PlatformAdapter the way cmd/cyrene's namedAdapter
|
||||
// does, so the gateway's reflection-based unwrap (asWeixinLoginAdmin) is exercised.
|
||||
type gatewayWeixinWrap struct {
|
||||
adapter.PlatformAdapter
|
||||
name string
|
||||
}
|
||||
|
||||
func (w *gatewayWeixinWrap) Name() string { return w.name }
|
||||
|
||||
// fakeWeixinChannels is a minimal ChannelConfigStore for the persist-on-confirm path.
|
||||
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 answers the scan-login surface: a fixed QR and an immediate
|
||||
// confirmed status (so the background poll confirms within a poll interval).
|
||||
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 wires a registry whose weixin channel wraps a real adapter, and
|
||||
// returns the httptest URL + an owner JWT. channels may be nil (no persist test).
|
||||
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 builds a gateway whose weixin adapter already holds a
|
||||
// token, so login endpoints report confirmed without any network.
|
||||
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 answers the QR but keeps the status "wait" (never
|
||||
// confirms), so the panel polls a login that is still in progress.
|
||||
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 wires a weixin adapter whose status poll never confirms.
|
||||
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"`
|
||||
QrImage string `json:"qrImage"`
|
||||
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 content")
|
||||
}
|
||||
if !strings.HasPrefix(out.Data.QrImage, "data:image/png;base64,") {
|
||||
t.Errorf("qrImage = %q, want a base64 data URL", out.Data.QrImage)
|
||||
}
|
||||
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)
|
||||
|
||||
// Trigger the QR, then poll until the background login confirms and persists.
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user