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,285 @@
|
||||
package gateway
|
||||
|
||||
// This file adds the weixin-iLink scan-login admin endpoints (design/08 §4),
|
||||
// owner only and audited. The panel triggers a QR bind (login-qr), then polls the
|
||||
// bind status (login-status); on confirmation the handler persists the newly
|
||||
// obtained bot_token / bot_id into channels.json and hot-reloads the platform so
|
||||
// the bridge connects the now-configured adapter — no restart required.
|
||||
//
|
||||
// The adapter owns the token: it is never logged, and it is returned to the panel
|
||||
// only masked (**** + trailing 4). Identity stays on the adapter's actor metadata,
|
||||
// never a prompt.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink"
|
||||
)
|
||||
|
||||
// weixinLoginAdmin is the subset of the weixin-ilink adapter's scan-login surface
|
||||
// the gateway panel endpoints invoke. It is implemented by *weixin_ilink.Adapter
|
||||
// and reached by unwrapping the registration wrapper (see asWeixinLoginAdmin).
|
||||
type weixinLoginAdmin interface {
|
||||
StartLogin(ctx context.Context) (weixin_ilink.LoginQR, error)
|
||||
LoginStatus(ctx context.Context) (status, detail string)
|
||||
Config() map[string]any
|
||||
}
|
||||
|
||||
// weixinLoginQRResponse is the wire shape of GET /admin/channels/{platform}/login-qr.
|
||||
type weixinLoginQRResponse struct {
|
||||
QrUrl string `json:"qrUrl,omitempty"`
|
||||
QrImage string `json:"qrImage,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// weixinLoginStatusResponse is the wire shape of GET /admin/channels/{platform}/login-status.
|
||||
type weixinLoginStatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
BotID string `json:"botId,omitempty"`
|
||||
BotToken string `json:"botToken,omitempty"`
|
||||
Err string `json:"err,omitempty"`
|
||||
}
|
||||
|
||||
// adminWeixinLoginQR triggers (or returns the in-progress) QR code for a
|
||||
// weixin-ilink account. It is the panel's "扫码登录" entry point; it never blocks
|
||||
// on the user's scan — LoginStatus is polled separately.
|
||||
func (s *Server) adminWeixinLoginQR(w http.ResponseWriter, r *http.Request) {
|
||||
platform := r.PathValue("platform")
|
||||
accountID := r.URL.Query().Get("accountId")
|
||||
wx, found := s.weixinTarget(r, w, "channel.weixin.login.qr", platform, accountID)
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
qr, err := wx.StartLogin(r.Context())
|
||||
if err != nil {
|
||||
if errors.Is(err, weixin_ilink.ErrAlreadyLoggedIn) {
|
||||
s.auditClaims(r, "channel.weixin.login.qr", "ok")
|
||||
writeJSON(w, http.StatusOK, weixinLoginQRResponse{Status: weixin_ilink.LoginStatusConfirmed})
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "channel.weixin.login.qr", "error")
|
||||
s.log.Error("weixin login qr", "platform", platform, "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "weixin_login_failed", "获取登录二维码失败:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
status, _ := wx.LoginStatus(r.Context())
|
||||
s.auditClaims(r, "channel.weixin.login.qr", "ok")
|
||||
writeJSON(w, http.StatusOK, weixinLoginQRResponse{
|
||||
QrUrl: qr.Image,
|
||||
QrImage: qrImageDataURL(r.Context(), qr.Image),
|
||||
Status: status,
|
||||
})
|
||||
}
|
||||
|
||||
// adminWeixinLoginStatus reports the scan-login state. On confirmation it also
|
||||
// persists the freshly obtained token to channels.json and hot-reloads the
|
||||
// platform, so the bridge connects the now-configured adapter.
|
||||
func (s *Server) adminWeixinLoginStatus(w http.ResponseWriter, r *http.Request) {
|
||||
platform := r.PathValue("platform")
|
||||
accountID := r.URL.Query().Get("accountId")
|
||||
wx, found := s.weixinTarget(r, w, "channel.weixin.login.status", platform, accountID)
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
status, detail := wx.LoginStatus(r.Context())
|
||||
resp := weixinLoginStatusResponse{Status: status}
|
||||
if status == weixin_ilink.LoginStatusConfirmed {
|
||||
cfg := wx.Config()
|
||||
token, _ := cfg["bot_token"].(string)
|
||||
if token != "" {
|
||||
botID, _ := cfg["bot_id"].(string)
|
||||
ownerID, _ := cfg["owner_id"].(string)
|
||||
resp.BotID = botID
|
||||
resp.BotToken = maskToken(token)
|
||||
if err := s.persistWeixinConfirmed(platform, accountID, token, botID, ownerID); err != nil {
|
||||
s.log.Error("weixin login persist", "platform", platform, "err", err)
|
||||
} else {
|
||||
s.auditClaims(r, "channel.weixin.login.persist", "ok")
|
||||
}
|
||||
}
|
||||
} else if status == weixin_ilink.LoginStatusFailed {
|
||||
resp.Err = detail
|
||||
}
|
||||
s.auditClaims(r, "channel.weixin.login.status", "ok")
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// persistWeixinConfirmed writes the login-obtained credentials into the account's
|
||||
// channels.json config (merging onto the user's curated base_url/cdn fields and
|
||||
// preserving its allow/deny/limit lists) and hot-reloads the platform so the
|
||||
// bridge connects the now-configured adapter.
|
||||
func (s *Server) persistWeixinConfirmed(platform, accountID, token, botID, ownerID string) error {
|
||||
if s.channels == nil {
|
||||
return fmt.Errorf("weixin login: channel config store not injected")
|
||||
}
|
||||
accounts := s.channels.Load()
|
||||
var base config.ChannelAccount
|
||||
for _, acct := range accounts[platform] {
|
||||
if accountID == "" || acct.ID == accountID {
|
||||
base = acct
|
||||
break
|
||||
}
|
||||
}
|
||||
if base.ID == "" && accountID != "" {
|
||||
base.ID = accountID
|
||||
}
|
||||
cfg := make(map[string]any, len(base.Config)+3)
|
||||
for k, v := range base.Config {
|
||||
cfg[k] = v
|
||||
}
|
||||
cfg["bot_token"] = token
|
||||
if botID != "" {
|
||||
cfg["bot_id"] = botID
|
||||
}
|
||||
if ownerID != "" {
|
||||
cfg["owner_id"] = ownerID
|
||||
}
|
||||
acct := config.ChannelAccount{
|
||||
ID: base.ID,
|
||||
Config: cfg,
|
||||
AllowGroups: base.AllowGroups,
|
||||
DenyGroups: base.DenyGroups,
|
||||
AllowUsers: base.AllowUsers,
|
||||
DenyUsers: base.DenyUsers,
|
||||
GroupMsgPerMin: base.GroupMsgPerMin,
|
||||
MsgPerMin: base.MsgPerMin,
|
||||
MaxMessages: base.MaxMessages,
|
||||
}
|
||||
if acct.ID == "" {
|
||||
acct.ID = runtimeconfig.NewID()
|
||||
}
|
||||
updated := runtimeconfig.UpsertChannel(accounts, platform, acct)
|
||||
if err := runtimeconfig.SaveChannels(s.channels.Path(), updated); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.channels.Reload(platform)
|
||||
}
|
||||
|
||||
// weixinAccountFor resolves the *weixin_ilink.Adapter registered for the
|
||||
// platform/account. It is nil for an unknown account or a wrapped adapter that
|
||||
// does not expose the login surface.
|
||||
func weixinAccountFor(caps CapabilityStore, platform, accountID string) (weixinLoginAdmin, bool) {
|
||||
if caps == nil {
|
||||
return nil, false
|
||||
}
|
||||
for _, cap := range caps.List(capreg.KindChannel) {
|
||||
ch, ok := cap.Value.(sdk.Channel)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
plat, acct := splitAdapterName(ch.Name())
|
||||
if plat != platform {
|
||||
continue
|
||||
}
|
||||
if accountID != "" && acct != accountID {
|
||||
continue
|
||||
}
|
||||
if w, ok := asWeixinLoginAdmin(cap.Value); ok {
|
||||
return w, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// asWeixinLoginAdmin unwraps a registered channel value to its inner weixin-ilink
|
||||
// adapter, mirroring asKookAdmin: the registration wraps the adapter in cmd/cyrene's
|
||||
// namedAdapter (an unexported struct embedding adapter.PlatformAdapter); reflection
|
||||
// reads the embedded field and type-asserts to the login surface. A direct
|
||||
// *weixin_ilink.Adapter value (tests) is also accepted.
|
||||
func asWeixinLoginAdmin(v any) (weixinLoginAdmin, bool) {
|
||||
if w, ok := v.(weixinLoginAdmin); ok {
|
||||
return w, true
|
||||
}
|
||||
rv := reflect.ValueOf(v)
|
||||
if !rv.IsValid() {
|
||||
return nil, false
|
||||
}
|
||||
if rv.Kind() == reflect.Ptr {
|
||||
rv = rv.Elem()
|
||||
}
|
||||
if rv.Kind() != reflect.Struct {
|
||||
return nil, false
|
||||
}
|
||||
f := rv.FieldByName("PlatformAdapter")
|
||||
if !f.IsValid() || !f.CanInterface() {
|
||||
return nil, false
|
||||
}
|
||||
if w, ok := f.Interface().(weixinLoginAdmin); ok {
|
||||
return w, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// weixinTarget resolves the weixin-ilink adapter for a platform/account, writing
|
||||
// an honest error and reporting found=false when it cannot.
|
||||
func (s *Server) weixinTarget(r *http.Request, w http.ResponseWriter, what, platform, accountID string) (weixinLoginAdmin, bool) {
|
||||
platform = strings.TrimSpace(platform)
|
||||
if channelBasePlatform(platform) != "weixin-ilink" {
|
||||
s.auditClaims(r, what, "error")
|
||||
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "仅微信 iLink 平台提供扫码登录端点", HTTPCode: http.StatusNotImplemented})
|
||||
return nil, false
|
||||
}
|
||||
wx, found := weixinAccountFor(s.caps, "weixin-ilink", accountID)
|
||||
if !found {
|
||||
s.auditClaims(r, what, "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found",
|
||||
"weixin-ilink 账号 %q 未找到(请先在「渠道配置」保存一个 weixin-ilink 账号)", accountID))
|
||||
return nil, false
|
||||
}
|
||||
return wx, true
|
||||
}
|
||||
|
||||
// maskToken redacts a secret to "****" + the trailing 4 characters, matching the
|
||||
// project's maskToken so a token never lands verbatim in a wire response.
|
||||
func maskToken(s string) string {
|
||||
if len(s) <= 4 {
|
||||
return "****"
|
||||
}
|
||||
return "****" + s[len(s)-4:]
|
||||
}
|
||||
|
||||
// qrImageDataURL turns a QR image's raw content into an embeddable data URL. When
|
||||
// the content is an http(s) URL it fetches and base64-encodes it (best effort); on
|
||||
// any failure it returns "" so the caller keeps qrUrl. A content already carrying a
|
||||
// data: prefix is passed through; anything else is treated as raw base64.
|
||||
func qrImageDataURL(ctx context.Context, raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(raw, "data:") {
|
||||
return raw
|
||||
}
|
||||
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return ""
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil || len(body) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(body)
|
||||
}
|
||||
return "data:image/png;base64," + raw
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -419,6 +419,8 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("POST /api/v1/admin/channels/open", ownerAuth(http.HandlerFunc(s.adminChannelOpen)))
|
||||
mux.Handle("POST /api/v1/admin/channels/close", ownerAuth(http.HandlerFunc(s.adminChannelClose)))
|
||||
mux.Handle("POST /api/v1/admin/channels/test", ownerAuth(http.HandlerFunc(s.adminChannelTest)))
|
||||
mux.Handle("GET /api/v1/admin/channels/{platform}/login-qr", ownerAuth(http.HandlerFunc(s.adminWeixinLoginQR)))
|
||||
mux.Handle("GET /api/v1/admin/channels/{platform}/login-status", ownerAuth(http.HandlerFunc(s.adminWeixinLoginStatus)))
|
||||
mux.Handle("GET /api/v1/admin/channels/{platform}/view", ownerAuth(http.HandlerFunc(s.adminChannelPlatformView)))
|
||||
mux.Handle("GET /api/v1/admin/channels/{platform}/friends", ownerAuth(http.HandlerFunc(s.adminChannelPlatformFriends)))
|
||||
mux.Handle("POST /api/v1/admin/channels/{platform}/friend/handle", ownerAuth(http.HandlerFunc(s.adminChannelFriendHandle)))
|
||||
|
||||
Reference in new issue
Block a user