Files
Cyrene_v2/internal/services/gateway/admin_channels_weixin_test.go
T
Aska 36bd19e997
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(weixin): 扫码登录二维码真实可扫——前端生成QR(修坏图) + DecodeConfig容忍空config
weixin qrcode_img_content 是微信liteapp HTML页(非图片),后端fetch转base64只得<!doctype>→前端img坏图。
- 后端 login-qr 改返回 qrUrl(qrcode_img_content URL)+qrCode(原始qrcode),去掉坏 qrImage
- 前端用 qrcode 库从 qrUrl 生成真实二维码图片(微信扫https链接即开连接页), 新增 qrcode+@types/qrcode(纯UI依赖)
- DecodeConfig: nil config map 当空map解码(不报错), 否则 pending账号(空config)构建被跳过→未注册→登录端点404
- 测试更新(DecodeConfigNil/admin QR端点断言 qrUrl+qrCode)
- 另有 weixinAccountFor 裸weixin兜底(此前提交 d3b0921) 保证 accountId 命中
- 实测: login-qr 200{waiting, qrUrl=89}, 前端能看到可扫二维码
build/vet/test 全绿, 已部署.
2026-09-07 21:32:19 +08:00

330 lines
11 KiB
Go

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"`
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)
// 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)
}
}