mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 20:45:37 +02:00
* feat(lark): split bind CTA into Feishu and Lark entry points (MUL-3083 follow-up)
The single "Bind to Lark" button began the device flow against
accounts.feishu.cn and relied on a mid-poll tenant_brand="lark" to
auto-switch international users over to accounts.larksuite.com. Lark
users had to scan a QR served from a Feishu domain first, which
surfaced as confusing in real use.
Replace with two explicit CTAs side by side — "Bind to Feishu" and
"Bind to Lark" — and route the device-flow begin straight to the
matching accounts host based on the user's choice. The mid-poll
auto-switch is preserved as a safety net for users who pick the wrong
entry.
Backend
- RegistrationClient.Begin(ctx, namePreset, region): POSTs to
c.cfg.LarkDomain when region=lark, c.cfg.Domain otherwise. Empty /
unknown region falls back to Feishu (matches RegionOrDefault).
- BeginInstallParams.Region threads through to the registration session
and onto runPolling's initial region local. SwitchedDomain still
flips it on tenant_brand=lark.
- POST /api/workspaces/{id}/lark/install/begin accepts ?region=feishu|lark
with empty defaulting to feishu for back-compat.
Frontend
- api.beginLarkInstall(wsId, agentId, region) — region now required
so every call site is forced to pick a cloud explicitly.
- LarkAgentBindButton renders two buttons; dialog state collapsed into
a single dialogRegion useState so an "open but with no region picked"
intermediate state can't exist.
- LarkInstallDialog takes region as a required prop and renders
region-aware copy (title, description, scan hint, link fallback,
success toast).
i18n
- Add bind_button_{feishu,lark}, install_dialog_{title,description}_*,
install_scan_hint_*, install_open_link_fallback_*, and
install_success_toast_* keys across en, zh-Hans, ja, ko. Legacy
single-region keys are kept for now; nothing in the tree references
them anymore but a follow-up cleanup can remove them once the dust
settles.
Tests
- Two new lark.RegistrationClient tests pin region routing in both
directions (region=lark hits LarkDomain; region=feishu hits Domain).
- Two new lark-tab.test.tsx cases pin that clicking each CTA calls
beginLarkInstall with the matching region argument. Existing CTA
tests updated to expect both buttons in place of one.
MUL-3083
Co-authored-by: multica-agent <github@multica.ai>
* fix(lark): bidirectional tenant_brand swap + region-aware badge + link context menu
Addresses Elon's review on PR #3832 plus a separate report that the
"Or tap here to open in Lark" link in the install dialog had no
standard right-click affordances on the desktop app.
Backend (must-fix from review)
The PR's stated 'safety net for users who pick the wrong CTA' only
worked one direction: a Feishu-first begin already swapped to Lark on
tenant_brand=lark, but the new Lark-first begin (added by this same PR)
had no reverse path — a user who picked 'Bind to Lark' but actually
authorized with a Feishu account would carry RegionLark all the way
through finishSuccess and either fail at GetBotInfo or commit a
wrong-region row.
- PollResult now carries SwitchedDomain AND SwitchedRegion in
lockstep, so the caller never has to re-derive region from the
domain string.
- Poll() detects tenant_brand=feishu while polling against a non-Feishu
host symmetrically with the existing tenant_brand=lark check, gated
on the current host so we don't loop on a brand we already match.
- runPolling reads region from res.SwitchedRegion instead of the
hardcoded RegionLark — the SwitchedDomain branch now flips both
feishu→lark and lark→feishu cleanly.
- Tests: updated the existing TestRegistrationClient_Poll_DomainSwitchOnLarkTenant
to assert SwitchedRegion, added TestRegistrationClient_Poll_DomainSwitchOnFeishuTenant
for the reverse, and TestRegistrationClient_Poll_NoSwitchWhenAlreadyOnMatchingHost
(table-driven, both directions) to pin that the gate doesn't loop.
Backend (nit from review)
Handler comment on /lark/install/begin claimed unknown region defaults
to Feishu downstream, but the handler already returns 400 on unknown
values. Updated the comment to match the actual behavior and document
why we 400 rather than silently normalize (so a frontend typo can't
land users on the wrong cloud without telling them).
Frontend (nit from review)
The Agent inspector's Connected badge was hardcoded 'Connected to
Lark' / 'Manage in Lark' (en) and 'Connected to Feishu' / 'Manage in
Feishu' (zh-Hans) — both wrong half the time now that the install
flow can land on either cloud per agent. Made the badge text and
Manage tooltip read from installation.region:
- agent_bot_connected_label_{feishu,lark}
- agent_bot_manage_link_{feishu,lark}
- agent_bot_manage_tooltip_{feishu,lark}
across en / zh-Hans / ja / ko. Legacy single-region keys retained for
safety. Existing badge tests updated: fixtures without 'region' now
expect the Feishu copy; the region: 'lark' test was promoted to also
assert the Lark badge text and link target. 21/21 lark-tab tests pass.
Desktop (separate report)
Right-clicking an <a> in the renderer surfaced only Copy / Cut /
Paste / Select All — no 'Open Link in Browser' or 'Copy Link Address'.
The renderer's <a target="_blank"> click path already routes through
setWindowOpenHandler → openExternalSafely, but discoverability via the
context menu was missing.
context-menu.ts now appends two link-specific items when params.linkURL
is an http(s) URL. Open Link routes through openExternalSafely (reuses
the existing scheme allowlist); Copy Link Address writes to Electron's
clipboard. Labels are localized to the OS preferred language for the
four locales the renderer ships (en / zh-Hans / ja / ko); zh-* variants
all route to zh-Hans, anything else falls back to English. New
context-menu.test.ts pins five cases: link items show for http(s),
not for javascript:/mailto:/etc., not when no link is under the cursor,
zh-CN gets Chinese, fr-FR falls back to English. 198/198 desktop tests
pass.
MUL-3083
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Jiang Bohan <bhjiang@outlook.com>
649 lines
24 KiB
Go
649 lines
24 KiB
Go
package lark
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// registrationFake is a tiny HTTP stand-in for accounts.feishu.cn /
|
|
// accounts.larksuite.com. Each test registers a sequence of poll
|
|
// handlers and assertions about what the client sent.
|
|
type registrationFake struct {
|
|
t *testing.T
|
|
srv *httptest.Server
|
|
mux *http.ServeMux
|
|
|
|
beginN atomic.Int32
|
|
pollN atomic.Int32
|
|
}
|
|
|
|
func newRegistrationFake(t *testing.T) *registrationFake {
|
|
t.Helper()
|
|
f := ®istrationFake{t: t, mux: http.NewServeMux()}
|
|
f.srv = httptest.NewServer(f.mux)
|
|
t.Cleanup(f.srv.Close)
|
|
return f
|
|
}
|
|
|
|
func (f *registrationFake) URL() string { return f.srv.URL }
|
|
|
|
// stubBegin pins the /oauth/v1/app/registration response for action=begin.
|
|
// Verifies the client sent the canonical PersonalAgent registration form.
|
|
func (f *registrationFake) stubBegin(resp map[string]any) {
|
|
f.mux.HandleFunc(registrationEndpoint, func(w http.ResponseWriter, r *http.Request) {
|
|
_ = r.ParseForm()
|
|
switch r.Form.Get("action") {
|
|
case "begin":
|
|
f.beginN.Add(1)
|
|
if got := r.Form.Get("archetype"); got != "PersonalAgent" {
|
|
f.t.Errorf("begin: archetype=%q want PersonalAgent", got)
|
|
}
|
|
if got := r.Form.Get("auth_method"); got != "client_secret" {
|
|
f.t.Errorf("begin: auth_method=%q want client_secret", got)
|
|
}
|
|
if got := r.Form.Get("request_user_info"); got != "open_id" {
|
|
f.t.Errorf("begin: request_user_info=%q want open_id", got)
|
|
}
|
|
writeJSON(w, resp)
|
|
case "poll":
|
|
f.t.Errorf("poll called before stubBegin handler was replaced — tests should set stubPoll explicitly")
|
|
default:
|
|
f.t.Errorf("unknown action: %q", r.Form.Get("action"))
|
|
}
|
|
})
|
|
}
|
|
|
|
// rewriteHandler replaces /oauth/v1/app/registration with a fresh
|
|
// handler — used to install a poll script after Begin has been
|
|
// exercised.
|
|
func (f *registrationFake) rewriteHandler(h http.HandlerFunc) {
|
|
f.mux = http.NewServeMux()
|
|
f.mux.HandleFunc(registrationEndpoint, h)
|
|
f.srv.Config.Handler = f.mux
|
|
}
|
|
|
|
func TestRegistrationClient_Begin_HappyPath(t *testing.T) {
|
|
fake := newRegistrationFake(t)
|
|
fake.stubBegin(map[string]any{
|
|
"device_code": "dc_xyz",
|
|
"verification_uri_complete": "https://accounts.feishu.cn/oauth/v1/qrcode?code=abc",
|
|
"verification_uri": "https://accounts.feishu.cn/oauth/v1/qrcode",
|
|
"user_code": "ABCD-EFGH",
|
|
"interval": 3,
|
|
"expire_in": 600,
|
|
})
|
|
|
|
c := NewRegistrationClient(RegistrationConfig{Domain: fake.URL()})
|
|
res, err := c.Begin(context.Background(), "Ada - Multica", "")
|
|
if err != nil {
|
|
t.Fatalf("Begin: %v", err)
|
|
}
|
|
if res.DeviceCode != "dc_xyz" {
|
|
t.Errorf("DeviceCode: got %q", res.DeviceCode)
|
|
}
|
|
if res.Interval != 3*time.Second {
|
|
t.Errorf("Interval: got %v want 3s", res.Interval)
|
|
}
|
|
if res.ExpiresIn != 600*time.Second {
|
|
t.Errorf("ExpiresIn: got %v want 600s", res.ExpiresIn)
|
|
}
|
|
if res.Domain != fake.URL() {
|
|
t.Errorf("Domain: got %q want %q", res.Domain, fake.URL())
|
|
}
|
|
// QR URL must carry the SDK telemetry params Lark expects so the
|
|
// scanner UI surfaces the polished prompt on the user's phone.
|
|
u, err := url.Parse(res.QRCodeURL)
|
|
if err != nil {
|
|
t.Fatalf("QRCodeURL: %v", err)
|
|
}
|
|
q := u.Query()
|
|
if q.Get("from") != "sdk" {
|
|
t.Errorf("qr from=%q want sdk", q.Get("from"))
|
|
}
|
|
if q.Get("tp") != "sdk" {
|
|
t.Errorf("qr tp=%q want sdk", q.Get("tp"))
|
|
}
|
|
if !strings.HasPrefix(q.Get("source"), "go-sdk/multica") {
|
|
t.Errorf("qr source=%q want go-sdk/multica", q.Get("source"))
|
|
}
|
|
// The name preset pre-fills the Lark PersonalAgent creation form so
|
|
// the bot defaults to "<agent> - Multica" rather than the
|
|
// auto-generated "{用户姓名}的智能助手".
|
|
if q.Get("name") != "Ada - Multica" {
|
|
t.Errorf("qr name=%q want %q", q.Get("name"), "Ada - Multica")
|
|
}
|
|
}
|
|
|
|
// TestRegistrationClient_Begin_OmitsNameWhenPresetEmpty pins that an
|
|
// empty preset leaves the `name` param off the QR URL entirely (rather
|
|
// than emitting name= and pre-filling a blank), so the begin path is
|
|
// unchanged when no preset is supplied.
|
|
func TestRegistrationClient_Begin_OmitsNameWhenPresetEmpty(t *testing.T) {
|
|
fake := newRegistrationFake(t)
|
|
fake.stubBegin(map[string]any{
|
|
"device_code": "dc_noname",
|
|
"verification_uri_complete": "https://accounts.feishu.cn/oauth/v1/qrcode?code=abc",
|
|
})
|
|
|
|
c := NewRegistrationClient(RegistrationConfig{Domain: fake.URL()})
|
|
res, err := c.Begin(context.Background(), "", "")
|
|
if err != nil {
|
|
t.Fatalf("Begin: %v", err)
|
|
}
|
|
u, err := url.Parse(res.QRCodeURL)
|
|
if err != nil {
|
|
t.Fatalf("QRCodeURL: %v", err)
|
|
}
|
|
if _, ok := u.Query()["name"]; ok {
|
|
t.Errorf("qr URL should omit name when preset empty, got %q", res.QRCodeURL)
|
|
}
|
|
}
|
|
|
|
// TestRegistrationClient_Begin_RegionLarkBeginsOnLarksuite pins the
|
|
// new explicit-region routing: passing region=lark to Begin must POST
|
|
// the begin form against the configured LarkDomain (international) host
|
|
// rather than the Feishu default. This is the routing optimization the
|
|
// split "Bind to Feishu / Bind to Lark" UI relies on — without it, a
|
|
// Lark user would still hit accounts.feishu.cn first and only flip to
|
|
// larksuite mid-poll via the tenant-brand auto-switch.
|
|
func TestRegistrationClient_Begin_RegionLarkBeginsOnLarksuite(t *testing.T) {
|
|
feishuFake := newRegistrationFake(t)
|
|
feishuFake.mux.HandleFunc(registrationEndpoint, func(w http.ResponseWriter, r *http.Request) {
|
|
t.Errorf("region=lark should NOT POST begin to the Feishu host (%s)", feishuFake.URL())
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
})
|
|
|
|
larkFake := newRegistrationFake(t)
|
|
larkFake.stubBegin(map[string]any{
|
|
"device_code": "dc_lark",
|
|
"verification_uri_complete": "https://accounts.larksuite.com/oauth/v1/qrcode?code=abc",
|
|
})
|
|
|
|
c := NewRegistrationClient(RegistrationConfig{
|
|
Domain: feishuFake.URL(),
|
|
LarkDomain: larkFake.URL(),
|
|
})
|
|
res, err := c.Begin(context.Background(), "", RegionLark)
|
|
if err != nil {
|
|
t.Fatalf("Begin(region=lark): %v", err)
|
|
}
|
|
if res.Domain != larkFake.URL() {
|
|
t.Errorf("BeginResult.Domain: got %q want %q (LarkDomain) — subsequent polls must hit the larksuite host directly",
|
|
res.Domain, larkFake.URL())
|
|
}
|
|
if got := larkFake.beginN.Load(); got != 1 {
|
|
t.Errorf("Lark begin POSTs: got %d want 1", got)
|
|
}
|
|
if got := feishuFake.beginN.Load(); got != 0 {
|
|
t.Errorf("Feishu begin POSTs: got %d want 0 (region=lark must not touch Feishu host)", got)
|
|
}
|
|
}
|
|
|
|
// TestRegistrationClient_Begin_RegionFeishuBeginsOnFeishu pins the
|
|
// explicit-feishu side of the same split: passing region=feishu (or
|
|
// the empty-string back-compat default) keeps the original mainland
|
|
// host. Documenting both directions catches a future regression where
|
|
// the region selector accidentally inverts.
|
|
func TestRegistrationClient_Begin_RegionFeishuBeginsOnFeishu(t *testing.T) {
|
|
feishuFake := newRegistrationFake(t)
|
|
feishuFake.stubBegin(map[string]any{
|
|
"device_code": "dc_feishu",
|
|
"verification_uri_complete": "https://accounts.feishu.cn/oauth/v1/qrcode?code=abc",
|
|
})
|
|
|
|
larkFake := newRegistrationFake(t)
|
|
larkFake.mux.HandleFunc(registrationEndpoint, func(w http.ResponseWriter, r *http.Request) {
|
|
t.Errorf("region=feishu should NOT POST begin to the Lark host (%s)", larkFake.URL())
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
})
|
|
|
|
c := NewRegistrationClient(RegistrationConfig{
|
|
Domain: feishuFake.URL(),
|
|
LarkDomain: larkFake.URL(),
|
|
})
|
|
res, err := c.Begin(context.Background(), "", RegionFeishu)
|
|
if err != nil {
|
|
t.Fatalf("Begin(region=feishu): %v", err)
|
|
}
|
|
if res.Domain != feishuFake.URL() {
|
|
t.Errorf("BeginResult.Domain: got %q want %q (Feishu)", res.Domain, feishuFake.URL())
|
|
}
|
|
if got := feishuFake.beginN.Load(); got != 1 {
|
|
t.Errorf("Feishu begin POSTs: got %d want 1", got)
|
|
}
|
|
if got := larkFake.beginN.Load(); got != 0 {
|
|
t.Errorf("Lark begin POSTs: got %d want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestRegistrationClient_Begin_DefaultsWhenServerOmitsTimers(t *testing.T) {
|
|
// When Lark's response omits `interval` / `expire_in` (the empty
|
|
// path the upstream SDK accepts), the client falls back to its
|
|
// documented defaults rather than zero-second polling that would
|
|
// hammer the endpoint or zero-second expiry that would fail the
|
|
// very first poll.
|
|
fake := newRegistrationFake(t)
|
|
fake.stubBegin(map[string]any{
|
|
"device_code": "dc_default",
|
|
"verification_uri_complete": "https://accounts.feishu.cn/oauth/v1/qrcode?code=abc",
|
|
})
|
|
|
|
c := NewRegistrationClient(RegistrationConfig{Domain: fake.URL()})
|
|
res, err := c.Begin(context.Background(), "", "")
|
|
if err != nil {
|
|
t.Fatalf("Begin: %v", err)
|
|
}
|
|
if res.Interval != time.Duration(registrationDefaultPollSeconds)*time.Second {
|
|
t.Errorf("interval default: got %v", res.Interval)
|
|
}
|
|
if res.ExpiresIn != time.Duration(registrationDefaultExpireSeconds)*time.Second {
|
|
t.Errorf("expire default: got %v", res.ExpiresIn)
|
|
}
|
|
}
|
|
|
|
func TestRegistrationClient_Begin_LarkError(t *testing.T) {
|
|
fake := newRegistrationFake(t)
|
|
fake.stubBegin(map[string]any{
|
|
"error": "invalid_request",
|
|
"error_description": "missing archetype",
|
|
})
|
|
c := NewRegistrationClient(RegistrationConfig{Domain: fake.URL()})
|
|
_, err := c.Begin(context.Background(), "", "")
|
|
if err == nil {
|
|
t.Fatal("expected error from Lark error response")
|
|
}
|
|
var re *RegistrationError
|
|
if !errorsAs(err, &re) {
|
|
t.Fatalf("want *RegistrationError, got %T %v", err, err)
|
|
}
|
|
if re.Code != "invalid_request" {
|
|
t.Errorf("Code: got %q want invalid_request", re.Code)
|
|
}
|
|
}
|
|
|
|
func TestRegistrationClient_Begin_HTTPNon2xx(t *testing.T) {
|
|
fake := newRegistrationFake(t)
|
|
fake.mux.HandleFunc(registrationEndpoint, func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(500)
|
|
_, _ = w.Write([]byte("server boom"))
|
|
})
|
|
c := NewRegistrationClient(RegistrationConfig{Domain: fake.URL()})
|
|
_, err := c.Begin(context.Background(), "", "")
|
|
if err == nil {
|
|
t.Fatal("want error on 500")
|
|
}
|
|
var re *RegistrationError
|
|
if !errorsAs(err, &re) || re.Code != "http_500" {
|
|
t.Errorf("want http_500 RegistrationError, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRegistrationClient_Poll_AuthorizationPending(t *testing.T) {
|
|
fake := newRegistrationFake(t)
|
|
fake.mux.HandleFunc(registrationEndpoint, func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, map[string]any{"error": "authorization_pending"})
|
|
})
|
|
c := NewRegistrationClient(RegistrationConfig{Domain: fake.URL()})
|
|
res, err := c.Poll(context.Background(), fake.URL(), "dc_x")
|
|
if err != nil {
|
|
t.Fatalf("Poll: %v", err)
|
|
}
|
|
if res.Status != "authorization_pending" {
|
|
t.Errorf("Status: got %q want authorization_pending", res.Status)
|
|
}
|
|
if res.ClientID != "" || res.SwitchedDomain != "" || res.Err != nil {
|
|
t.Errorf("unexpected populated fields: %+v", res)
|
|
}
|
|
}
|
|
|
|
// TestRegistrationClient_Poll_AuthorizationPendingHTTP400 pins the
|
|
// RFC 8628 transport behaviour Lark actually uses: the device-flow
|
|
// polling endpoint returns HTTP 400 with the JSON body
|
|
// `{"error":"authorization_pending"}` while the user hasn't scanned
|
|
// the QR yet. The previous transport treated any non-2xx as a
|
|
// terminal protocol error, which killed every install session on the
|
|
// first poll and made the QR dialog silently empty in the UI —
|
|
// because the frontend received status="error" + lark_protocol_error
|
|
// within seconds of opening the dialog.
|
|
//
|
|
// Verified against the live Lark service: it returns HTTP 400 with
|
|
// `code=20094` for the wait-state, not HTTP 200.
|
|
func TestRegistrationClient_Poll_AuthorizationPendingHTTP400(t *testing.T) {
|
|
fake := newRegistrationFake(t)
|
|
fake.mux.HandleFunc(registrationEndpoint, func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = w.Write([]byte(`{"error":"authorization_pending","error_description":"","code":20094}`))
|
|
})
|
|
c := NewRegistrationClient(RegistrationConfig{Domain: fake.URL()})
|
|
res, err := c.Poll(context.Background(), fake.URL(), "dc_x")
|
|
if err != nil {
|
|
t.Fatalf("Poll: %v", err)
|
|
}
|
|
if res.Status != "authorization_pending" {
|
|
t.Errorf("Status: got %q want authorization_pending (HTTP 400 should NOT be a terminal error)", res.Status)
|
|
}
|
|
if res.Err != nil {
|
|
t.Errorf("PollResult.Err should be nil for authorization_pending wait state, got %+v", res.Err)
|
|
}
|
|
}
|
|
|
|
// TestRegistrationClient_Poll_AccessDeniedHTTP400 verifies the
|
|
// adjacent path: HTTP 400 with `error=access_denied` IS terminal (user
|
|
// cancelled in the Lark UI). The fix in doForm must distinguish these
|
|
// two cases by the body's `error` field, not by the HTTP status.
|
|
func TestRegistrationClient_Poll_AccessDeniedHTTP400(t *testing.T) {
|
|
fake := newRegistrationFake(t)
|
|
fake.mux.HandleFunc(registrationEndpoint, func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = w.Write([]byte(`{"error":"access_denied","error_description":"user cancelled"}`))
|
|
})
|
|
c := NewRegistrationClient(RegistrationConfig{Domain: fake.URL()})
|
|
res, err := c.Poll(context.Background(), fake.URL(), "dc_x")
|
|
if err != nil {
|
|
t.Fatalf("Poll: %v", err)
|
|
}
|
|
if res.Err == nil || res.Err.Code != "access_denied" {
|
|
t.Errorf("PollResult.Err: got %+v; want access_denied", res.Err)
|
|
}
|
|
}
|
|
|
|
// TestRegistrationClient_Poll_HTTP500UnparseableIsTerminal pins the
|
|
// fallback: a non-JSON 5xx (e.g. proxy returning a HTML error page) is
|
|
// still surfaced as a typed protocol error so ops can tell a Lark
|
|
// outage from a schema drift.
|
|
func TestRegistrationClient_Poll_HTTP500UnparseableIsTerminal(t *testing.T) {
|
|
fake := newRegistrationFake(t)
|
|
fake.mux.HandleFunc(registrationEndpoint, func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
_, _ = w.Write([]byte("<html><body>502 Bad Gateway</body></html>"))
|
|
})
|
|
c := NewRegistrationClient(RegistrationConfig{Domain: fake.URL()})
|
|
_, err := c.Poll(context.Background(), fake.URL(), "dc_x")
|
|
if err == nil {
|
|
t.Fatal("want error on unparseable 502 body")
|
|
}
|
|
var re *RegistrationError
|
|
if !errorsAs(err, &re) || re.Code != "http_502" {
|
|
t.Errorf("want http_502 RegistrationError, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRegistrationClient_Poll_SlowDown(t *testing.T) {
|
|
fake := newRegistrationFake(t)
|
|
fake.mux.HandleFunc(registrationEndpoint, func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, map[string]any{"error": "slow_down"})
|
|
})
|
|
c := NewRegistrationClient(RegistrationConfig{Domain: fake.URL()})
|
|
res, err := c.Poll(context.Background(), fake.URL(), "dc_x")
|
|
if err != nil {
|
|
t.Fatalf("Poll: %v", err)
|
|
}
|
|
if res.Status != "slow_down" {
|
|
t.Errorf("Status: got %q want slow_down", res.Status)
|
|
}
|
|
}
|
|
|
|
func TestRegistrationClient_Poll_EmptyBodyTreatedAsPending(t *testing.T) {
|
|
// Lark sometimes returns an empty {}; treating it as "keep polling"
|
|
// matches the upstream SDK and avoids a spurious abort during the
|
|
// window between the user authorizing and the credentials being
|
|
// minted on Lark's side.
|
|
fake := newRegistrationFake(t)
|
|
fake.mux.HandleFunc(registrationEndpoint, func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, map[string]any{})
|
|
})
|
|
c := NewRegistrationClient(RegistrationConfig{Domain: fake.URL()})
|
|
res, err := c.Poll(context.Background(), fake.URL(), "dc_x")
|
|
if err != nil {
|
|
t.Fatalf("Poll: %v", err)
|
|
}
|
|
if res.Status != "authorization_pending" {
|
|
t.Errorf("Status: got %q want authorization_pending", res.Status)
|
|
}
|
|
}
|
|
|
|
func TestRegistrationClient_Poll_DomainSwitchOnLarkTenant(t *testing.T) {
|
|
fake := newRegistrationFake(t)
|
|
fake.mux.HandleFunc(registrationEndpoint, func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, map[string]any{
|
|
"user_info": map[string]any{"tenant_brand": "lark"},
|
|
})
|
|
})
|
|
// LarkDomain points at a *distinct* host so we can assert the
|
|
// switch surfaced the right value. We do NOT actually want the
|
|
// client to hit it during this single Poll call — the switch
|
|
// signal is consumed by RegistrationService, which re-polls
|
|
// against the new host on the NEXT iteration.
|
|
c := NewRegistrationClient(RegistrationConfig{
|
|
Domain: fake.URL(),
|
|
LarkDomain: "https://lark-international.test",
|
|
})
|
|
res, err := c.Poll(context.Background(), fake.URL(), "dc_x")
|
|
if err != nil {
|
|
t.Fatalf("Poll: %v", err)
|
|
}
|
|
if res.SwitchedDomain != "https://lark-international.test" {
|
|
t.Errorf("SwitchedDomain: got %q", res.SwitchedDomain)
|
|
}
|
|
if res.SwitchedRegion != RegionLark {
|
|
t.Errorf("SwitchedRegion: got %q want %q", res.SwitchedRegion, RegionLark)
|
|
}
|
|
}
|
|
|
|
// TestRegistrationClient_Poll_DomainSwitchOnFeishuTenant pins the
|
|
// reverse direction of the tenant-brand swap: a session begun against
|
|
// the Lark international host whose authorizing account turns out to
|
|
// be on mainland Feishu must surface a switch back to Feishu, with
|
|
// the region flipping accordingly. Without this, a user who picks the
|
|
// "Bind to Lark" CTA but actually scans with a Feishu account would
|
|
// carry RegionLark all the way through finishSuccess and either fail
|
|
// at GetBotInfo or commit a wrong-region installation row. Documenting
|
|
// this side keeps the symmetry promised in the public PollResult docs
|
|
// and the split-CTA UI's "wrong entry" recovery contract.
|
|
func TestRegistrationClient_Poll_DomainSwitchOnFeishuTenant(t *testing.T) {
|
|
larkFake := newRegistrationFake(t)
|
|
larkFake.mux.HandleFunc(registrationEndpoint, func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, map[string]any{
|
|
"user_info": map[string]any{"tenant_brand": "feishu"},
|
|
})
|
|
})
|
|
// Domain (Feishu) points at a *distinct* host so we can assert the
|
|
// switch landed there; LarkDomain is the host we are CURRENTLY on
|
|
// (the larkFake) so the swap predicate (`!HasPrefix(domain, LarkDomain)`)
|
|
// resolves correctly.
|
|
c := NewRegistrationClient(RegistrationConfig{
|
|
Domain: "https://feishu-mainland.test",
|
|
LarkDomain: larkFake.URL(),
|
|
})
|
|
res, err := c.Poll(context.Background(), larkFake.URL(), "dc_x")
|
|
if err != nil {
|
|
t.Fatalf("Poll: %v", err)
|
|
}
|
|
if res.SwitchedDomain != "https://feishu-mainland.test" {
|
|
t.Errorf("SwitchedDomain: got %q want feishu host", res.SwitchedDomain)
|
|
}
|
|
if res.SwitchedRegion != RegionFeishu {
|
|
t.Errorf("SwitchedRegion: got %q want %q", res.SwitchedRegion, RegionFeishu)
|
|
}
|
|
}
|
|
|
|
// TestRegistrationClient_Poll_NoSwitchWhenAlreadyOnMatchingHost pins
|
|
// that the swap is gated on the current domain — a `tenant_brand=lark`
|
|
// hint emitted while polling AGAINST the Lark host must NOT fire a
|
|
// redundant switch (which would loop the polling state machine), and
|
|
// likewise `tenant_brand=feishu` against the Feishu host. Both arms
|
|
// of the symmetry are covered to catch a future regression where the
|
|
// gate flips on only one side.
|
|
func TestRegistrationClient_Poll_NoSwitchWhenAlreadyOnMatchingHost(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
brand string
|
|
begunOn string
|
|
feishuHost string
|
|
larkHost string
|
|
}{
|
|
{
|
|
name: "lark brand on lark host is a no-op",
|
|
brand: "lark",
|
|
begunOn: "https://lark-international.test",
|
|
feishuHost: "https://feishu-mainland.test",
|
|
larkHost: "https://lark-international.test",
|
|
},
|
|
{
|
|
name: "feishu brand on feishu host is a no-op",
|
|
brand: "feishu",
|
|
begunOn: "https://feishu-mainland.test",
|
|
feishuHost: "https://feishu-mainland.test",
|
|
larkHost: "https://lark-international.test",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
fake := newRegistrationFake(t)
|
|
fake.mux.HandleFunc(registrationEndpoint, func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, map[string]any{
|
|
"user_info": map[string]any{"tenant_brand": tc.brand},
|
|
})
|
|
})
|
|
// Use the fake's URL for whichever side we claim to be on,
|
|
// and a placeholder URL for the other — the test only
|
|
// exercises a single Poll call, the cross-host re-poll is
|
|
// the service's job.
|
|
cfg := RegistrationConfig{Domain: tc.feishuHost, LarkDomain: tc.larkHost}
|
|
if tc.begunOn == tc.feishuHost {
|
|
cfg.Domain = fake.URL()
|
|
} else {
|
|
cfg.LarkDomain = fake.URL()
|
|
}
|
|
c := NewRegistrationClient(cfg)
|
|
res, err := c.Poll(context.Background(), fake.URL(), "dc_x")
|
|
if err != nil {
|
|
t.Fatalf("Poll: %v", err)
|
|
}
|
|
if res.SwitchedDomain != "" {
|
|
t.Errorf("SwitchedDomain: got %q, want empty (already on matching host)",
|
|
res.SwitchedDomain)
|
|
}
|
|
if res.SwitchedRegion != "" {
|
|
t.Errorf("SwitchedRegion: got %q, want empty", res.SwitchedRegion)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRegistrationClient_Poll_Success(t *testing.T) {
|
|
fake := newRegistrationFake(t)
|
|
fake.mux.HandleFunc(registrationEndpoint, func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, map[string]any{
|
|
"client_id": "cli_personal_42",
|
|
"client_secret": "secret_42",
|
|
"user_info": map[string]any{"open_id": "ou_installer_42", "tenant_brand": "feishu"},
|
|
})
|
|
})
|
|
c := NewRegistrationClient(RegistrationConfig{Domain: fake.URL()})
|
|
res, err := c.Poll(context.Background(), fake.URL(), "dc_x")
|
|
if err != nil {
|
|
t.Fatalf("Poll: %v", err)
|
|
}
|
|
if res.ClientID != "cli_personal_42" {
|
|
t.Errorf("ClientID: got %q", res.ClientID)
|
|
}
|
|
if res.ClientSecret != "secret_42" {
|
|
t.Errorf("ClientSecret: got %q", res.ClientSecret)
|
|
}
|
|
if string(res.OpenID) != "ou_installer_42" {
|
|
t.Errorf("OpenID: got %q", res.OpenID)
|
|
}
|
|
}
|
|
|
|
func TestRegistrationClient_Poll_SuccessMissingOpenIDIsProtocolError(t *testing.T) {
|
|
// A "success" response without the installer open_id would leave
|
|
// the auto-bind step with nothing to insert. Treat that as a hard
|
|
// protocol error so RegistrationService never writes a half-built
|
|
// installation row.
|
|
fake := newRegistrationFake(t)
|
|
fake.mux.HandleFunc(registrationEndpoint, func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, map[string]any{
|
|
"client_id": "cli_personal_42",
|
|
"client_secret": "secret_42",
|
|
})
|
|
})
|
|
c := NewRegistrationClient(RegistrationConfig{Domain: fake.URL()})
|
|
_, err := c.Poll(context.Background(), fake.URL(), "dc_x")
|
|
if err == nil {
|
|
t.Fatal("want error on success-without-open_id")
|
|
}
|
|
var re *RegistrationError
|
|
if !errorsAs(err, &re) || re.Code != "invalid_response" {
|
|
t.Errorf("want invalid_response, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRegistrationClient_Poll_AccessDenied(t *testing.T) {
|
|
fake := newRegistrationFake(t)
|
|
fake.mux.HandleFunc(registrationEndpoint, func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, map[string]any{
|
|
"error": "access_denied",
|
|
"error_description": "user denied install",
|
|
})
|
|
})
|
|
c := NewRegistrationClient(RegistrationConfig{Domain: fake.URL()})
|
|
res, err := c.Poll(context.Background(), fake.URL(), "dc_x")
|
|
if err != nil {
|
|
t.Fatalf("Poll: %v", err)
|
|
}
|
|
if res.Err == nil || res.Err.Code != "access_denied" {
|
|
t.Errorf("Err: %+v", res.Err)
|
|
}
|
|
}
|
|
|
|
func TestRegistrationClient_Poll_ExpiredToken(t *testing.T) {
|
|
fake := newRegistrationFake(t)
|
|
fake.mux.HandleFunc(registrationEndpoint, func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, map[string]any{"error": "expired_token"})
|
|
})
|
|
c := NewRegistrationClient(RegistrationConfig{Domain: fake.URL()})
|
|
res, err := c.Poll(context.Background(), fake.URL(), "dc_x")
|
|
if err != nil {
|
|
t.Fatalf("Poll: %v", err)
|
|
}
|
|
if res.Err == nil || res.Err.Code != "expired_token" {
|
|
t.Errorf("Err: %+v", res.Err)
|
|
}
|
|
}
|
|
|
|
func TestRegistrationClient_Poll_UnknownErrorPassesThrough(t *testing.T) {
|
|
fake := newRegistrationFake(t)
|
|
fake.mux.HandleFunc(registrationEndpoint, func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, map[string]any{"error": "rate_limited"})
|
|
})
|
|
c := NewRegistrationClient(RegistrationConfig{Domain: fake.URL()})
|
|
res, err := c.Poll(context.Background(), fake.URL(), "dc_x")
|
|
if err != nil {
|
|
t.Fatalf("Poll: %v", err)
|
|
}
|
|
if res.Err == nil || res.Err.Code != "rate_limited" {
|
|
t.Errorf("Err: %+v", res.Err)
|
|
}
|
|
}
|
|
|
|
func TestRegistrationClient_Poll_MissingDeviceCode(t *testing.T) {
|
|
c := NewRegistrationClient(RegistrationConfig{})
|
|
if _, err := c.Poll(context.Background(), "", ""); err == nil {
|
|
t.Fatal("want error on missing device_code")
|
|
}
|
|
}
|
|
|
|
// errorsAs is a tiny wrapper over errors.As so the test source stays
|
|
// terse — call sites read `errorsAs(err, &re)`.
|
|
func errorsAs(err error, target any) bool { return errors.As(err, target) }
|