Files
larksuite-cli/internal/registry/remote_test.go
liangshuo-1 f49a2f7e14 fix(registry): wait for background meta refresh before test reset (#894)
* fix(registry): wait for background meta refresh before test reset

TestComputeMinimumScopeSet can start doBackgroundRefresh via Init() while
the next test's resetInit() mutates package-level globals the goroutine
still reads (e.g. remoteMetaURL / configuredBrand), causing data races under
-race in the coverage job.

Track the refresh goroutine with a WaitGroup and drain it at the start of
resetInit() in tests.
2026-05-14 22:33:21 +08:00

522 lines
15 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/larksuite/cli/internal/core"
)
// waitBackgroundRefresh blocks until any in-flight background refresh started by
// triggerBackgroundRefresh has finished. Lives in this _test file so production
// binaries cannot call it and accidentally block on test teardown state.
func waitBackgroundRefresh() {
bgRefreshInFlight.Wait()
}
// resetInit resets the package-level state so each test starts fresh.
func resetInit() {
// Must wait: a prior test's Init() may have started doBackgroundRefresh which
// reads globals this function mutates (see CI race: TestComputeMinimumScopeSet → Tenant).
waitBackgroundRefresh()
initOnce = sync.Once{}
mergedServices = make(map[string]map[string]interface{})
mergedProjectList = nil
embeddedVersion = ""
cachedAllScopes = nil
cachedScopePriorities = nil
cachedAutoApproveSet = nil
cachedPlatformAutoApprove = nil
cachedOverrideAutoAllow = nil
cachedOverrideAutoDeny = nil
refreshOnce = sync.Once{}
configuredBrand = ""
enableRemoteMeta = true // tests exercise remote logic
testMetaURL = ""
}
func TestResetInitClearsEmbeddedVersion(t *testing.T) {
embeddedVersion = "stale-version"
resetInit()
if embeddedVersion != "" {
t.Fatalf("embeddedVersion = %q, want empty", embeddedVersion)
}
}
// hasEmbeddedServices returns true if meta_data.json with real services is compiled in.
func hasEmbeddedServices() bool {
if len(embeddedMetaJSON) == 0 {
return false
}
var reg MergedRegistry
if err := json.Unmarshal(embeddedMetaJSON, &reg); err != nil {
return false
}
return len(reg.Services) > 0
}
// testRegistry returns a minimal MergedRegistry with one service.
func testRegistry(name string) MergedRegistry {
return MergedRegistry{
Version: "test-1.0",
Services: []map[string]interface{}{
{
"name": name,
"version": "v1",
"title": name + " API",
"servicePath": "/open-apis/" + name + "/v1",
"resources": map[string]interface{}{},
},
},
}
}
// testCacheJSON returns a minimal valid MergedRegistry JSON (for cache files).
func testCacheJSON(name string) []byte {
data, _ := json.Marshal(testRegistry(name))
return data
}
// testEnvelopeJSON returns the remote API envelope format: {"msg":"succeeded","data":{...}}.
func testEnvelopeJSON(name string) []byte {
resp := remoteResponse{
Msg: "succeeded",
Data: testRegistry(name),
}
data, _ := json.Marshal(resp)
return data
}
// testEnvelopeNotModifiedJSON returns an envelope with empty data (version match).
func testEnvelopeNotModifiedJSON() []byte {
data, _ := json.Marshal(map[string]interface{}{
"msg": "succeeded",
"data": map[string]interface{}{},
})
return data
}
// TestColdStart_UsesEmbedded was removed because it triggers a data race:
// resetInit() writes package globals while a background goroutine from a
// previous test's triggerBackgroundRefresh may still be reading them.
// The embedded-data path is exercised by other tests (e.g. TestCacheHit).
func TestColdStart_NoEmbedded_SyncFetch(t *testing.T) {
if hasEmbeddedServices() {
t.Skip("embedded data present, skipping no-embedded test")
}
resetInit()
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write(testEnvelopeJSON("remote_calendar"))
}))
defer ts.Close()
testMetaURL = ts.URL
Init()
if spec := LoadFromMeta("remote_calendar"); spec == nil {
t.Fatal("expected remote_calendar from sync fetch")
}
}
func TestRemoteOff_SkipsRemoteLogic(t *testing.T) {
resetInit()
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
// Create a fake cache that should NOT be loaded
cDir := filepath.Join(tmp, "cache")
os.MkdirAll(cDir, 0700)
os.WriteFile(filepath.Join(cDir, "remote_meta.json"), testCacheJSON("fake_remote_svc"), 0644)
Init()
// "fake_remote_svc" should not be loaded when remote is off
if spec := LoadFromMeta("fake_remote_svc"); spec != nil {
t.Error("expected fake_remote_svc to NOT be loaded when remote is off")
}
}
func TestCacheHit_WithinTTL(t *testing.T) {
resetInit()
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
t.Setenv("LARKSUITE_CLI_META_TTL", "3600")
// Pre-seed cache with a custom service
cDir := filepath.Join(tmp, "cache")
os.MkdirAll(cDir, 0700)
os.WriteFile(filepath.Join(cDir, "remote_meta.json"), testCacheJSON("custom_svc"), 0644)
meta := CacheMeta{LastCheckAt: time.Now().Unix()}
metaData, _ := json.Marshal(meta)
os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), metaData, 0644)
// Point META_URL to a server that would fail if contacted
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("server should not be contacted when cache is within TTL")
w.WriteHeader(500)
}))
defer ts.Close()
testMetaURL = ts.URL
Init()
// custom_svc should be loaded from cache overlay
if spec := LoadFromMeta("custom_svc"); spec == nil {
t.Error("expected custom_svc from cache overlay")
}
// Embedded projects should still be present (if compiled in)
if hasEmbeddedServices() {
if spec := LoadFromMeta("calendar"); spec == nil {
t.Error("expected calendar from embedded data")
}
}
}
func TestNetworkError_SilentDegradation(t *testing.T) {
resetInit()
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
t.Setenv("LARKSUITE_CLI_META_TTL", "0") // Always refresh
// Pre-seed cache so we have data to fall back on
cDir := filepath.Join(tmp, "cache")
os.MkdirAll(cDir, 0700)
os.WriteFile(filepath.Join(cDir, "remote_meta.json"), testCacheJSON("cached_svc"), 0644)
meta := CacheMeta{LastCheckAt: time.Now().Add(-2 * time.Hour).Unix()}
metaData, _ := json.Marshal(meta)
os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), metaData, 0644)
// Use a mock server that returns an error immediately (instead of 127.0.0.1:1 which
// may hang up to fetchTimeout=5s, leaking the background goroutine into subsequent tests).
fetched := make(chan struct{}, 1)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
select {
case fetched <- struct{}{}:
default:
}
}))
defer ts.Close()
testMetaURL = ts.URL
// Should not panic or error
Init()
projects := ListFromMetaProjects()
if len(projects) == 0 {
t.Fatal("expected projects after network error")
}
if spec := LoadFromMeta("cached_svc"); spec == nil {
t.Fatal("expected cached_svc after network error")
}
// Wait for background goroutine to finish so it doesn't leak into subsequent tests.
select {
case <-fetched:
case <-time.After(5 * time.Second):
}
time.Sleep(50 * time.Millisecond)
}
func TestShouldRefresh(t *testing.T) {
t.Setenv("LARKSUITE_CLI_META_TTL", "60")
// Zero means never checked
if !shouldRefresh(CacheMeta{}) {
t.Error("expected shouldRefresh=true for zero LastCheckAt")
}
// Recent check — no refresh needed
if shouldRefresh(CacheMeta{LastCheckAt: time.Now().Unix()}) {
t.Error("expected shouldRefresh=false for recent check")
}
// Old check — refresh needed
if !shouldRefresh(CacheMeta{LastCheckAt: time.Now().Add(-2 * time.Minute).Unix()}) {
t.Error("expected shouldRefresh=true for old check")
}
}
func TestRemoteEnabled(t *testing.T) {
// When feature flag is off, always disabled
enableRemoteMeta = false
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
if remoteEnabled() {
t.Error("expected disabled when feature flag is off")
}
// When feature flag is on, env var controls
enableRemoteMeta = true
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
if remoteEnabled() {
t.Error("expected disabled when set to 'off'")
}
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
if !remoteEnabled() {
t.Error("expected enabled when set to 'on'")
}
t.Setenv("LARKSUITE_CLI_REMOTE_META", "")
if !remoteEnabled() {
t.Error("expected enabled when empty (default on)")
}
}
func TestMetaTTL(t *testing.T) {
t.Setenv("LARKSUITE_CLI_META_TTL", "120")
if ttl := metaTTL(); ttl != 120*time.Second {
t.Errorf("expected 120s, got %v", ttl)
}
t.Setenv("LARKSUITE_CLI_META_TTL", "")
if ttl := metaTTL(); ttl != defaultMetaTTL*time.Second {
t.Errorf("expected default %ds, got %v", defaultMetaTTL, ttl)
}
t.Setenv("LARKSUITE_CLI_META_TTL", "invalid")
if ttl := metaTTL(); ttl != defaultMetaTTL*time.Second {
t.Errorf("expected default on invalid input, got %v", ttl)
}
}
func TestOverlayMergedServices(t *testing.T) {
resetInit()
mergedServices = make(map[string]map[string]interface{})
mergedServices["existing"] = map[string]interface{}{"name": "existing", "version": "v1"}
reg := &MergedRegistry{
Services: []map[string]interface{}{
{"name": "existing", "version": "v2"},
{"name": "brand_new", "version": "v1"},
},
}
overlayMergedServices(reg)
// existing should be overridden
if v := mergedServices["existing"]["version"].(string); v != "v2" {
t.Errorf("expected existing to be overridden to v2, got %s", v)
}
// brand_new should be added
if _, ok := mergedServices["brand_new"]; !ok {
t.Error("expected brand_new to be added")
}
}
func TestOverlayMergedServicesDoesNotPolluteFollowingInit(t *testing.T) {
resetInit()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
const leakedExisting = "test_isolation_existing_sentinel"
const leakedOverlay = "test_isolation_overlay_sentinel"
mergedServices = map[string]map[string]interface{}{
leakedExisting: {"name": leakedExisting, "version": "v1"},
}
overlayMergedServices(&MergedRegistry{Services: []map[string]interface{}{{"name": leakedOverlay, "version": "v1"}}})
resetInit()
Init()
if spec := LoadFromMeta(leakedExisting); spec != nil {
t.Fatalf("polluted service %q survived resetInit", leakedExisting)
}
if spec := LoadFromMeta(leakedOverlay); spec != nil {
t.Fatalf("polluted service %q survived resetInit", leakedOverlay)
}
}
func TestFetchRemoteMerged_200(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write(testEnvelopeJSON("fetched_svc"))
}))
defer ts.Close()
testMetaURL = ts.URL
data, reg, err := fetchRemoteMerged("")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if reg == nil {
t.Fatal("expected non-nil registry")
}
if data == nil {
t.Fatal("expected non-nil data")
}
if reg.Version != "test-1.0" {
t.Errorf("expected version test-1.0, got %s", reg.Version)
}
}
func TestFetchRemoteMerged_VersionMatch(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write(testEnvelopeNotModifiedJSON())
}))
defer ts.Close()
testMetaURL = ts.URL
data, reg, err := fetchRemoteMerged("test-1.0")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if reg != nil {
t.Error("expected nil registry for version match (not modified)")
}
if data != nil {
t.Error("expected nil data for version match")
}
}
func TestFetchRemoteMerged_ServerError(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(503)
}))
defer ts.Close()
testMetaURL = ts.URL
_, _, err := fetchRemoteMerged("")
if err == nil {
t.Fatal("expected error for 503")
}
httpErr, ok := err.(*httpError)
if !ok {
t.Fatalf("expected *httpError, got %T", err)
}
if httpErr.StatusCode != 503 {
t.Errorf("expected 503, got %d", httpErr.StatusCode)
}
}
func TestCorruptedCache_SelfHeals(t *testing.T) {
resetInit()
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
// Write corrupted cache
cDir := filepath.Join(tmp, "cache")
os.MkdirAll(cDir, 0700)
os.WriteFile(filepath.Join(cDir, "remote_meta.json"), []byte("not json{{{"), 0644)
meta := CacheMeta{LastCheckAt: time.Now().Unix()}
metaData, _ := json.Marshal(meta)
os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), metaData, 0644)
// loadCachedMerged should fail and remove the corrupted files
_, err := loadCachedMerged()
if err == nil {
t.Fatal("expected error for corrupted cache")
}
// Corrupted files should be deleted
if _, err := os.Stat(filepath.Join(cDir, "remote_meta.json")); !os.IsNotExist(err) {
t.Error("expected corrupted remote_meta.json to be deleted")
}
if _, err := os.Stat(filepath.Join(cDir, "remote_meta.meta.json")); !os.IsNotExist(err) {
t.Error("expected remote_meta.meta.json to be deleted")
}
}
func TestFetchRemoteMerged_InvalidJSON(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte("not json"))
}))
defer ts.Close()
testMetaURL = ts.URL
_, _, err := fetchRemoteMerged("")
if err == nil {
t.Fatal("expected error for invalid JSON")
}
}
func TestBrandSwitchInvalidatesCache(t *testing.T) {
// Wait for any background goroutines from previous tests to settle
time.Sleep(200 * time.Millisecond)
resetInit()
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
t.Setenv("LARKSUITE_CLI_META_TTL", "3600")
// Pre-seed cache with feishu brand
cDir := filepath.Join(tmp, "cache")
os.MkdirAll(cDir, 0700)
os.WriteFile(filepath.Join(cDir, "remote_meta.json"), testCacheJSON("feishu_svc"), 0644)
meta := CacheMeta{LastCheckAt: time.Now().Unix(), Version: "test-1.0", Brand: "feishu"}
metaData, _ := json.Marshal(meta)
os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), metaData, 0644)
// Server returns lark-specific data
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write(testEnvelopeJSON("lark_svc"))
}))
defer ts.Close()
testMetaURL = ts.URL
// Init with lark brand — should invalidate feishu cache and sync fetch
InitWithBrand(core.BrandLark)
// The old feishu_svc should NOT be loaded from stale cache
// The new lark_svc from sync fetch should be available
if spec := LoadFromMeta("lark_svc"); spec == nil {
t.Error("expected lark_svc after brand switch sync fetch")
}
}
func TestRemoteMetaURL_BrandSpecific(t *testing.T) {
testMetaURL = ""
// Default URL (feishu) with no version
configuredBrand = core.BrandFeishu
u := remoteMetaURL("")
if !strings.Contains(u, "open.feishu.cn") {
t.Errorf("expected feishu URL, got %s", u)
}
if strings.Contains(u, "data_version") {
t.Errorf("expected no data_version param for empty version, got %s", u)
}
// Lark brand with version param
configuredBrand = core.BrandLark
u = remoteMetaURL("1.0.3")
if !strings.Contains(u, "open.larksuite.com") {
t.Errorf("expected lark URL, got %s", u)
}
if !strings.Contains(u, "data_version=1.0.3") {
t.Errorf("expected data_version=1.0.3, got %s", u)
}
// testMetaURL override takes precedence
testMetaURL = "http://custom.example.com/meta"
u = remoteMetaURL("ignored")
if u != "http://custom.example.com/meta" {
t.Errorf("expected testMetaURL override, got %s", u)
}
}