feat(weixin): 微信iLink扫码登录——面板触发+二维码显示+状态轮询+确认热连接
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

适配器: 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:
Aska committed 2026-09-07 20:50:28 +08:00
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)
}
}
+2
View File
@@ -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)))