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) } }