Files
multica/server/internal/integrations/lark/region_test.go
Bohan Jiang 6ac8314711 feat(lark): support both Feishu and Lark from one deployment (MUL-3083) (#3815)
* feat(lark): serve Feishu and Lark from one deployment, per installation

The Lark integration was locked to a single open-platform host chosen
deployment-wide (MULTICA_LARK_HTTP_BASE_URL / _CALLBACK_BASE_URL,
defaulting to open.feishu.cn), so one deployment could talk to only the
mainland Feishu cloud OR Lark international — never both. Teams on the
other tenant could not use the integration at all.

Make the host per-installation. The device-flow installer already
auto-detects the tenant (Lark emits tenant_brand="lark" mid-poll); we now
persist that as lark_installation.region, carry it on
InstallationCredentials.Region, and resolve the open-platform host per
call (REST + WS bootstrap) from the region. An explicit cfg.BaseURL
(env / httptest) still overrides every region, so existing tests and
staging/proxy setups keep working.

- migration 116: lark_installation.region TEXT NOT NULL DEFAULT 'feishu'
  CHECK (region IN ('feishu','lark')) — existing rows are all mainland.
- lark.Region enum + OpenPlatformBaseURL/RegionOrDefault helpers.
- registration: thread the detected region into finishSuccess so the
  install-time GetBotInfo hits the right cloud AND the row records it.
- every credential-build site (patcher, replier, WS provider, union_id
  backfill) copies region off the installation row.
- region is part of the WS supervisor fingerprint so a re-install that
  switches cloud restarts the connection.
- API: surface region on the installation listing DTO.

MUL-3083

Co-authored-by: multica-agent <github@multica.ai>

* feat(lark): surface installation region in settings UI

Read the per-installation region off the listings response: build the
"Manage in Lark" dev-console host from it (open.feishu.cn vs
open.larksuite.com instead of a hardcoded mainland host) and render a
Feishu / Lark badge on each connected bot. The field is optional and
defaults to Feishu when an older server omits it (API-compat). Adds the
region_feishu / region_lark labels to all four locales.

MUL-3083

Co-authored-by: multica-agent <github@multica.ai>

* docs(lark): document simultaneous Feishu + Lark support

The cloud each bot belongs to is now auto-detected at install and stored
per installation, so one deployment serves both. Replace the old
"point MULTICA_LARK_HTTP_BASE_URL at larksuite for international tenants"
guidance (now just an optional override) in all four locales.

MUL-3083

Co-authored-by: multica-agent <github@multica.ai>

* fix(lark): repair legacy Lark-international installs on upgrade

Review follow-up (MUL-3083). Migration 116 backfilled every existing
lark_installation to region='feishu', assuming all historical rows were
mainland. But self-host deployments could already run Lark international
via the deployment-wide MULTICA_LARK_HTTP_BASE_URL override, so those
rows are really Lark — clearing the override after upgrade (which the new
docs invite) would route them to open.feishu.cn and break them.

Add a one-shot startup repair, BackfillRegionFromLegacyOverride, fired
off the hot path like BackfillBotUnionIDs: when the deployment's global
base-URL override targets open.larksuite.com, relabel the still-default
'feishu' rows to 'lark'. Gating on the deployment-wide override is what
makes it safe — every pre-existing install on such a deployment was Lark.
Idempotent; no-op on mainland / fresh deployments. Verified end-to-end
against a scratch DB (flip then 0-row idempotent re-run).

Also document that a Lark/飞书 app_id is globally unique across both
clouds, which is what makes the app_id-keyed token cache and the
UNIQUE(app_id) constraint safe across regions (review nit).

MUL-3083

Co-authored-by: multica-agent <github@multica.ai>

* docs(lark): fix ops guidance to match auto per-installation region

Review follow-up (MUL-3083). .env.example and docker-compose.selfhost.yml
still told operators that international Lark requires pointing both base
URLs at open.larksuite.com — now wrong, and it would push a fresh
deployment back into a single-cloud override. Rewrite them: the base
URLs are optional deployment-wide overrides; normal dual-cloud operation
keeps them empty. Document the first-boot auto-relabel for deployments
migrating off the old single-cloud override, across the integration docs
(en/zh/ja/ko).

MUL-3083

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 16:03:13 +08:00

199 lines
6.7 KiB
Go

package lark
import (
"context"
"io"
"net/http"
"strings"
"testing"
)
// capturingRoundTripper records the host of every outbound request and
// replies with a canned Lark-style JSON body that satisfies every decode
// path the client takes (token mint, bot info, contact union_id). It lets
// a test assert WHICH open-platform host a call targeted without dialing
// the real public Feishu / Lark domains.
type capturingRoundTripper struct {
hosts []string
}
func (rt *capturingRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
rt.hosts = append(rt.hosts, r.URL.Host)
const body = `{"code":0,"msg":"ok","tenant_access_token":"t","expire":7200,` +
`"bot":{"open_id":"ou_x"},"data":{"user":{"union_id":"on_x"}}}`
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}, nil
}
// TestRegion_OpenPlatformBaseURL pins the region→host mapping that both
// the REST client and the WS bootstrap depend on.
func TestRegion_OpenPlatformBaseURL(t *testing.T) {
cases := []struct {
region Region
want string
}{
{RegionFeishu, "https://open.feishu.cn"},
{RegionLark, "https://open.larksuite.com"},
{Region(""), "https://open.feishu.cn"},
{Region("bogus"), "https://open.feishu.cn"},
}
for _, tc := range cases {
if got := tc.region.OpenPlatformBaseURL(); got != tc.want {
t.Errorf("Region(%q).OpenPlatformBaseURL() = %q, want %q", tc.region, got, tc.want)
}
}
}
// TestRegionOrDefault pins the normalization used at every credential-
// build site: unknown / empty strings collapse to Feishu so a malformed
// row never yields an empty host or a CHECK-violating write.
func TestRegionOrDefault(t *testing.T) {
cases := map[string]Region{
"feishu": RegionFeishu,
"lark": RegionLark,
"": RegionFeishu,
"LARK": RegionFeishu, // case-sensitive on purpose; CHECK stores lowercase
"intl": RegionFeishu,
}
for in, want := range cases {
if got := RegionOrDefault(in); got != want {
t.Errorf("RegionOrDefault(%q) = %q, want %q", in, got, want)
}
}
}
// TestIsLarkInternationalHost gates the upgrade-repair backfill: only a
// deployment-wide override pointing at open.larksuite.com should relabel
// legacy installs. Mainland, empty, mock/staging, and scheme-less values
// must NOT trigger it.
func TestIsLarkInternationalHost(t *testing.T) {
cases := map[string]bool{
"https://open.larksuite.com": true,
"https://open.larksuite.com/": true,
"https://OPEN.LARKSUITE.COM": true, // host compare is case-insensitive
"https://open.feishu.cn": false,
"": false,
" ": false,
"https://mock.internal:8080": false,
"open.larksuite.com": false, // no scheme → not a usable override anyway
}
for in, want := range cases {
if got := isLarkInternationalHost(in); got != want {
t.Errorf("isLarkInternationalHost(%q) = %v, want %v", in, got, want)
}
}
}
// TestHTTPClient_ResolvesHostFromRegion is the core dual-region guarantee:
// with NO deployment-wide BaseURL override, the open-platform host is
// chosen per call from InstallationCredentials.Region, so Feishu and Lark
// installations served by one process each reach their own cloud.
func TestHTTPClient_ResolvesHostFromRegion(t *testing.T) {
cases := []struct {
name string
region Region
host string
}{
{"feishu", RegionFeishu, "open.feishu.cn"},
{"lark", RegionLark, "open.larksuite.com"},
{"empty defaults to feishu", Region(""), "open.feishu.cn"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rt := &capturingRoundTripper{}
// No BaseURL → region resolution governs the host.
c := NewHTTPAPIClient(HTTPClientConfig{HTTPClient: &http.Client{Transport: rt}})
if _, err := c.GetBotInfo(context.Background(), InstallationCredentials{
AppID: "cli_x", AppSecret: "s", Region: tc.region,
}); err != nil {
t.Fatalf("GetBotInfo: %v", err)
}
if len(rt.hosts) == 0 {
t.Fatalf("no requests captured")
}
for _, h := range rt.hosts {
if h != tc.host {
t.Errorf("request targeted host %q, want %q", h, tc.host)
}
}
})
}
}
// TestHTTPClient_BaseURLOverridesRegion pins the test / staging seam: an
// explicit cfg.BaseURL forces every region to that host, which is how the
// existing test suite (and MULTICA_LARK_HTTP_BASE_URL) keeps working.
func TestHTTPClient_BaseURLOverridesRegion(t *testing.T) {
rt := &capturingRoundTripper{}
c := NewHTTPAPIClient(HTTPClientConfig{
BaseURL: "https://override.example.com",
HTTPClient: &http.Client{Transport: rt},
})
if _, err := c.GetBotInfo(context.Background(), InstallationCredentials{
AppID: "cli_x", AppSecret: "s", Region: RegionLark, // would be larksuite, but override wins
}); err != nil {
t.Fatalf("GetBotInfo: %v", err)
}
for _, h := range rt.hosts {
if h != "override.example.com" {
t.Errorf("override not honored: host=%q, want override.example.com", h)
}
}
}
// TestWSEndpoint_ResolvesHostFromRegion pins that the long-conn bootstrap
// POST (/callback/ws/endpoint) also targets the per-installation region
// host when no deployment-wide override is set.
func TestWSEndpoint_ResolvesHostFromRegion(t *testing.T) {
cases := []struct {
region Region
host string
}{
{RegionFeishu, "open.feishu.cn"},
{RegionLark, "open.larksuite.com"},
{Region(""), "open.feishu.cn"},
}
for _, tc := range cases {
rt := &wsEndpointRoundTripper{}
f, err := NewHTTPConnectionTokenFetcher(HTTPConnectionTokenConfig{
HTTPClient: &http.Client{Transport: rt},
})
if err != nil {
t.Fatalf("NewHTTPConnectionTokenFetcher: %v", err)
}
if _, err := f.Endpoint(context.Background(), InstallationCredentials{
AppID: "cli_x", AppSecret: "s", Region: tc.region,
}); err != nil {
t.Fatalf("Endpoint(region=%q): %v", tc.region, err)
}
if rt.host != tc.host {
t.Errorf("ws bootstrap targeted host %q, want %q (region=%q)", rt.host, tc.host, tc.region)
}
if rt.path != "/callback/ws/endpoint" {
t.Errorf("ws bootstrap path = %q, want /callback/ws/endpoint", rt.path)
}
}
}
// wsEndpointRoundTripper returns a valid endpointResponse so Endpoint's
// decode succeeds, while recording the host + path it was asked to reach.
type wsEndpointRoundTripper struct {
host string
path string
}
func (rt *wsEndpointRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
rt.host = r.URL.Host
rt.path = r.URL.Path
const body = `{"code":0,"msg":"ok","data":{"URL":"wss://example/ws?service_id=1&device_id=d",` +
`"ClientConfig":{"ReconnectCount":1,"ReconnectInterval":120,"ReconnectNonce":30,"PingInterval":120}}}`
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}, nil
}