diff --git a/internal/api/handlers/management/api_key_usage_test.go b/internal/api/handlers/management/api_key_usage_test.go index f2be17d7..70d9b11e 100644 --- a/internal/api/handlers/management/api_key_usage_test.go +++ b/internal/api/handlers/management/api_key_usage_test.go @@ -24,7 +24,6 @@ func sumRecentRequestBuckets(buckets []coreauth.RecentRequestBucket) (int64, int func TestGetAPIKeyUsage_GroupsByProviderAndAPIKey(t *testing.T) { t.Setenv("MANAGEMENT_PASSWORD", "") - gin.SetMode(gin.TestMode) manager := coreauth.NewManager(nil, nil, nil) if _, err := manager.Register(context.Background(), &coreauth.Auth{ diff --git a/internal/api/handlers/management/auth_files_batch_test.go b/internal/api/handlers/management/auth_files_batch_test.go index ec001ae5..59b631c8 100644 --- a/internal/api/handlers/management/auth_files_batch_test.go +++ b/internal/api/handlers/management/auth_files_batch_test.go @@ -18,7 +18,6 @@ import ( func TestUploadAuthFile_BatchMultipart(t *testing.T) { t.Setenv("MANAGEMENT_PASSWORD", "") - gin.SetMode(gin.TestMode) authDir := t.TempDir() manager := coreauth.NewManager(nil, nil, nil) @@ -86,7 +85,6 @@ func TestUploadAuthFile_BatchMultipart(t *testing.T) { func TestUploadAuthFile_BatchMultipart_InvalidJSONDoesNotOverwriteExistingFile(t *testing.T) { t.Setenv("MANAGEMENT_PASSWORD", "") - gin.SetMode(gin.TestMode) authDir := t.TempDir() manager := coreauth.NewManager(nil, nil, nil) @@ -152,7 +150,6 @@ func TestUploadAuthFile_BatchMultipart_InvalidJSONDoesNotOverwriteExistingFile(t func TestDeleteAuthFile_BatchQuery(t *testing.T) { t.Setenv("MANAGEMENT_PASSWORD", "") - gin.SetMode(gin.TestMode) authDir := t.TempDir() files := []string{"alpha.json", "beta.json"} diff --git a/internal/api/handlers/management/auth_files_delete_test.go b/internal/api/handlers/management/auth_files_delete_test.go index b67f1f66..1287ab12 100644 --- a/internal/api/handlers/management/auth_files_delete_test.go +++ b/internal/api/handlers/management/auth_files_delete_test.go @@ -17,7 +17,6 @@ import ( func TestDeleteAuthFile_UsesAuthPathFromManager(t *testing.T) { t.Setenv("MANAGEMENT_PASSWORD", "") - gin.SetMode(gin.TestMode) tempDir := t.TempDir() authDir := filepath.Join(tempDir, "auth") @@ -101,7 +100,6 @@ func TestDeleteAuthFile_UsesAuthPathFromManager(t *testing.T) { func TestDeleteAuthFile_FallbackToAuthDirPath(t *testing.T) { t.Setenv("MANAGEMENT_PASSWORD", "") - gin.SetMode(gin.TestMode) authDir := t.TempDir() fileName := "fallback-user.json" @@ -130,7 +128,6 @@ func TestDeleteAuthFile_FallbackToAuthDirPath(t *testing.T) { func TestDeleteAuthFile_RemovesRuntimeAuth(t *testing.T) { t.Setenv("MANAGEMENT_PASSWORD", "") - gin.SetMode(gin.TestMode) authDir := t.TempDir() fileName := "runtime-remove-user.json" diff --git a/internal/api/handlers/management/auth_files_download_test.go b/internal/api/handlers/management/auth_files_download_test.go index 88024fbb..b4e39fce 100644 --- a/internal/api/handlers/management/auth_files_download_test.go +++ b/internal/api/handlers/management/auth_files_download_test.go @@ -14,7 +14,6 @@ import ( func TestDownloadAuthFile_ReturnsFile(t *testing.T) { t.Setenv("MANAGEMENT_PASSWORD", "") - gin.SetMode(gin.TestMode) authDir := t.TempDir() fileName := "download-user.json" @@ -40,7 +39,6 @@ func TestDownloadAuthFile_ReturnsFile(t *testing.T) { func TestDownloadAuthFile_RejectsPathSeparators(t *testing.T) { t.Setenv("MANAGEMENT_PASSWORD", "") - gin.SetMode(gin.TestMode) h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, nil) diff --git a/internal/api/handlers/management/auth_files_download_windows_test.go b/internal/api/handlers/management/auth_files_download_windows_test.go index 88fc7f11..bc71c087 100644 --- a/internal/api/handlers/management/auth_files_download_windows_test.go +++ b/internal/api/handlers/management/auth_files_download_windows_test.go @@ -16,7 +16,6 @@ import ( func TestDownloadAuthFile_PreventsWindowsSlashTraversal(t *testing.T) { t.Setenv("MANAGEMENT_PASSWORD", "") - gin.SetMode(gin.TestMode) tempDir := t.TempDir() authDir := filepath.Join(tempDir, "auth") diff --git a/internal/api/handlers/management/auth_files_patch_fields_test.go b/internal/api/handlers/management/auth_files_patch_fields_test.go index 072e487e..e01f1d5c 100644 --- a/internal/api/handlers/management/auth_files_patch_fields_test.go +++ b/internal/api/handlers/management/auth_files_patch_fields_test.go @@ -18,7 +18,6 @@ import ( func TestPatchAuthFileFields_MergeHeadersAndDeleteEmptyValues(t *testing.T) { t.Setenv("MANAGEMENT_PASSWORD", "") - gin.SetMode(gin.TestMode) store := &memoryAuthStore{} manager := coreauth.NewManager(store, nil, nil) @@ -113,7 +112,6 @@ func TestPatchAuthFileFields_MergeHeadersAndDeleteEmptyValues(t *testing.T) { func TestPatchAuthFileFields_HeadersEmptyMapIsNoop(t *testing.T) { t.Setenv("MANAGEMENT_PASSWORD", "") - gin.SetMode(gin.TestMode) store := &memoryAuthStore{} manager := coreauth.NewManager(store, nil, nil) @@ -168,7 +166,6 @@ func TestPatchAuthFileFields_HeadersEmptyMapIsNoop(t *testing.T) { func TestPatchAuthFileFields_WebsocketsFalseIsUpdate(t *testing.T) { t.Setenv("MANAGEMENT_PASSWORD", "") - gin.SetMode(gin.TestMode) store := &memoryAuthStore{} manager := coreauth.NewManager(store, nil, nil) @@ -217,7 +214,6 @@ func TestPatchAuthFileFields_WebsocketsFalseIsUpdate(t *testing.T) { func TestPatchAuthFileFields_ArbitraryFieldsPersistToFile(t *testing.T) { t.Setenv("MANAGEMENT_PASSWORD", "") - gin.SetMode(gin.TestMode) authDir := t.TempDir() fileName := "generic.json" diff --git a/internal/api/handlers/management/auth_files_project_id_test.go b/internal/api/handlers/management/auth_files_project_id_test.go index 0c462934..3bacc9a4 100644 --- a/internal/api/handlers/management/auth_files_project_id_test.go +++ b/internal/api/handlers/management/auth_files_project_id_test.go @@ -16,7 +16,6 @@ import ( func TestListAuthFiles_IncludesProjectIDFromManager(t *testing.T) { t.Setenv("MANAGEMENT_PASSWORD", "") - gin.SetMode(gin.TestMode) authDir := t.TempDir() fileName := "gemini-user@example.com-project-a.json" @@ -55,7 +54,6 @@ func TestListAuthFiles_IncludesProjectIDFromManager(t *testing.T) { func TestListAuthFilesFromDisk_IncludesProjectID(t *testing.T) { t.Setenv("MANAGEMENT_PASSWORD", "") - gin.SetMode(gin.TestMode) authDir := t.TempDir() filePath := filepath.Join(authDir, "gemini-user@example.com-project-a.json") @@ -73,7 +71,6 @@ func TestListAuthFilesFromDisk_IncludesProjectID(t *testing.T) { func TestListAuthFiles_IncludesWebsocketsFromManager(t *testing.T) { t.Setenv("MANAGEMENT_PASSWORD", "") - gin.SetMode(gin.TestMode) authDir := t.TempDir() fileName := "codex-user@example.com-pro.json" @@ -111,7 +108,6 @@ func TestListAuthFiles_IncludesWebsocketsFromManager(t *testing.T) { func TestListAuthFilesFromDisk_IncludesWebsockets(t *testing.T) { t.Setenv("MANAGEMENT_PASSWORD", "") - gin.SetMode(gin.TestMode) authDir := t.TempDir() filePath := filepath.Join(authDir, "codex-user@example.com-pro.json") diff --git a/internal/api/handlers/management/auth_files_recent_requests_test.go b/internal/api/handlers/management/auth_files_recent_requests_test.go index 404bf484..f3c5107c 100644 --- a/internal/api/handlers/management/auth_files_recent_requests_test.go +++ b/internal/api/handlers/management/auth_files_recent_requests_test.go @@ -14,7 +14,6 @@ import ( func TestListAuthFiles_IncludesRecentRequestsBuckets(t *testing.T) { t.Setenv("MANAGEMENT_PASSWORD", "") - gin.SetMode(gin.TestMode) manager := coreauth.NewManager(nil, nil, nil) record := &coreauth.Auth{ diff --git a/internal/api/handlers/management/config_lists_delete_keys_test.go b/internal/api/handlers/management/config_lists_delete_keys_test.go index a548805e..9897c3c7 100644 --- a/internal/api/handlers/management/config_lists_delete_keys_test.go +++ b/internal/api/handlers/management/config_lists_delete_keys_test.go @@ -24,7 +24,6 @@ func writeTestConfigFile(t *testing.T) string { func TestDeleteGeminiKey_RequiresBaseURLWhenAPIKeyDuplicated(t *testing.T) { t.Parallel() - gin.SetMode(gin.TestMode) h := &Handler{ cfg: &config.Config{ @@ -52,7 +51,6 @@ func TestDeleteGeminiKey_RequiresBaseURLWhenAPIKeyDuplicated(t *testing.T) { func TestDeleteGeminiKey_DeletesOnlyMatchingBaseURL(t *testing.T) { t.Parallel() - gin.SetMode(gin.TestMode) h := &Handler{ cfg: &config.Config{ @@ -83,7 +81,6 @@ func TestDeleteGeminiKey_DeletesOnlyMatchingBaseURL(t *testing.T) { func TestDeleteClaudeKey_DeletesEmptyBaseURLWhenExplicitlyProvided(t *testing.T) { t.Parallel() - gin.SetMode(gin.TestMode) h := &Handler{ cfg: &config.Config{ @@ -114,7 +111,6 @@ func TestDeleteClaudeKey_DeletesEmptyBaseURLWhenExplicitlyProvided(t *testing.T) func TestDeleteVertexCompatKey_DeletesOnlyMatchingBaseURL(t *testing.T) { t.Parallel() - gin.SetMode(gin.TestMode) h := &Handler{ cfg: &config.Config{ @@ -145,7 +141,6 @@ func TestDeleteVertexCompatKey_DeletesOnlyMatchingBaseURL(t *testing.T) { func TestDeleteCodexKey_RequiresBaseURLWhenAPIKeyDuplicated(t *testing.T) { t.Parallel() - gin.SetMode(gin.TestMode) h := &Handler{ cfg: &config.Config{ diff --git a/internal/api/handlers/management/handler_test.go b/internal/api/handlers/management/handler_test.go index 73c370ed..148ec030 100644 --- a/internal/api/handlers/management/handler_test.go +++ b/internal/api/handlers/management/handler_test.go @@ -41,7 +41,6 @@ func TestAuthenticateManagementKey_LocalhostIPBan_BlocksCorrectKeyDuringBan(t *t } func TestMiddlewareSetsSupportPluginHeader(t *testing.T) { - gin.SetMode(gin.TestMode) h := &Handler{ cfg: &config.Config{}, diff --git a/internal/api/handlers/management/logs_test.go b/internal/api/handlers/management/logs_test.go index 8c3e0ead..c3b045ee 100644 --- a/internal/api/handlers/management/logs_test.go +++ b/internal/api/handlers/management/logs_test.go @@ -706,7 +706,6 @@ func performGetLogs(t *testing.T, h *Handler, target string) logsAPIResponse { func performGetLogsRaw(t *testing.T, h *Handler, target string) (int, string) { t.Helper() - gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodGet, target, nil) diff --git a/internal/api/handlers/management/oauth_callback_test.go b/internal/api/handlers/management/oauth_callback_test.go index a9ff971f..065f89f0 100644 --- a/internal/api/handlers/management/oauth_callback_test.go +++ b/internal/api/handlers/management/oauth_callback_test.go @@ -14,7 +14,6 @@ import ( ) func TestPostOAuthCallbackCreatesMissingAuthDir(t *testing.T) { - gin.SetMode(gin.TestMode) authDir := filepath.Join(t.TempDir(), "missing-auth") state := "test-antigravity-state" diff --git a/internal/api/handlers/management/plugin_store_test.go b/internal/api/handlers/management/plugin_store_test.go index c6a92b84..c5037e15 100644 --- a/internal/api/handlers/management/plugin_store_test.go +++ b/internal/api/handlers/management/plugin_store_test.go @@ -3,7 +3,6 @@ package management import ( "archive/zip" "bytes" - "context" "crypto/sha256" "encoding/hex" "encoding/json" @@ -25,7 +24,6 @@ import ( func TestListPluginStoreMergesInstalledStatus(t *testing.T) { t.Parallel() - gin.SetMode(gin.TestMode) pluginsDir := writeManagementPluginFile(t, "sample-provider") h := &Handler{ @@ -84,7 +82,6 @@ func TestListPluginStoreMergesInstalledStatus(t *testing.T) { func TestListPluginStoreEscapesRegistryStrings(t *testing.T) { t.Parallel() - gin.SetMode(gin.TestMode) h := &Handler{ cfg: &config.Config{ @@ -150,7 +147,6 @@ func TestListPluginStoreEscapesRegistryStrings(t *testing.T) { func TestListPluginStoreShowsLatestReleaseVersionAndCaches(t *testing.T) { t.Parallel() - gin.SetMode(gin.TestMode) httpClient := &countingPluginStoreHTTPClient{responses: fakePluginStoreHTTPClient{ "https://registry.example/registry.json": registryJSON(t), @@ -203,7 +199,6 @@ func TestListPluginStoreShowsLatestReleaseVersionAndCaches(t *testing.T) { func TestListPluginStoreFallsBackToRegistryVersion(t *testing.T) { t.Parallel() - gin.SetMode(gin.TestMode) h := &Handler{ cfg: &config.Config{ @@ -242,7 +237,6 @@ func TestListPluginStoreFallsBackToRegistryVersion(t *testing.T) { func TestListPluginStoreIncludesThirdPartySources(t *testing.T) { t.Parallel() - gin.SetMode(gin.TestMode) h := &Handler{ cfg: &config.Config{ @@ -304,7 +298,6 @@ func TestListPluginStoreIncludesThirdPartySources(t *testing.T) { func TestInstallPluginFromStoreWritesFileAndEnablesConfig(t *testing.T) { t.Parallel() - gin.SetMode(gin.TestMode) pluginsDir := t.TempDir() archiveData := makeManagementPluginStoreZip(t, "sample-provider"+managementPluginExtension(runtime.GOOS), "library-data") @@ -335,6 +328,7 @@ func TestInstallPluginFromStoreWritesFileAndEnablesConfig(t *testing.T) { "https://downloads.example/checksums.txt": []byte(hex.EncodeToString(checksum[:]) + " " + archiveName + "\n"), }, } + reloads, reloadDone := captureConfigReload(h) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) @@ -346,6 +340,11 @@ func TestInstallPluginFromStoreWritesFileAndEnablesConfig(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } + cfgSnapshot := waitForAsyncReload(t, reloads) + waitForReloadDone(t, reloadDone) + if cfgSnapshot == h.cfg { + t.Fatalf("reload config = handler config %p, want independent snapshot", h.cfg) + } var body pluginInstallResponse if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil { t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String()) @@ -371,18 +370,27 @@ func TestInstallPluginFromStoreWritesFileAndEnablesConfig(t *testing.T) { if item.Enabled == nil || !*item.Enabled { t.Fatalf("plugin enabled = %#v, want true", item.Enabled) } + snapshotItem := cfgSnapshot.Plugins.Configs["sample-provider"] + if snapshotItem.Enabled == nil || !*snapshotItem.Enabled { + t.Fatalf("snapshot plugin enabled = %#v, want true", snapshotItem.Enabled) + } if h.cfg.Plugins.Enabled { t.Fatal("global plugins.enabled changed to true") } + if cfgSnapshot.Plugins.Enabled { + t.Fatal("snapshot global plugins.enabled changed to true") + } raw := marshalPluginRaw(t, item) if !strings.Contains(raw, "mode: fast") { t.Fatalf("plugin raw config lost custom field:\n%s", raw) } + if raw := marshalPluginRaw(t, snapshotItem); !strings.Contains(raw, "mode: fast") { + t.Fatalf("snapshot plugin raw config lost custom field:\n%s", raw) + } } func TestInstallPluginFromStoreUsesRequestedThirdPartySource(t *testing.T) { t.Parallel() - gin.SetMode(gin.TestMode) pluginsDir := t.TempDir() archiveData := makeManagementPluginStoreZip(t, "sample-provider"+managementPluginExtension(runtime.GOOS), "third-party-library-data") @@ -411,6 +419,7 @@ func TestInstallPluginFromStoreUsesRequestedThirdPartySource(t *testing.T) { "https://downloads.example/checksums.txt": []byte(hex.EncodeToString(checksum[:]) + " " + archiveName + "\n"), }, } + reloads, reloadDone := captureConfigReload(h) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) @@ -423,6 +432,11 @@ func TestInstallPluginFromStoreUsesRequestedThirdPartySource(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } + cfgSnapshot := waitForAsyncReload(t, reloads) + waitForReloadDone(t, reloadDone) + if cfgSnapshot == h.cfg { + t.Fatalf("reload config = handler config %p, want independent snapshot", h.cfg) + } var body pluginInstallResponse if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil { t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String()) @@ -438,11 +452,14 @@ func TestInstallPluginFromStoreUsesRequestedThirdPartySource(t *testing.T) { if string(data) != "third-party-library-data" { t.Fatalf("installed file = %q, want third-party-library-data", data) } + snapshotItem := cfgSnapshot.Plugins.Configs["sample-provider"] + if snapshotItem.Enabled == nil || !*snapshotItem.Enabled { + t.Fatalf("snapshot plugin enabled = %#v, want true", snapshotItem.Enabled) + } } func TestInstallPluginFromStoreRequiresSourceForDuplicateIDs(t *testing.T) { t.Parallel() - gin.SetMode(gin.TestMode) h := &Handler{ cfg: &config.Config{ @@ -476,7 +493,6 @@ func TestInstallPluginFromStoreRequiresSourceForDuplicateIDs(t *testing.T) { func TestInstallPluginFromStoreOverwritesFilePreservesConfigAndReloads(t *testing.T) { t.Parallel() - gin.SetMode(gin.TestMode) pluginsDir := t.TempDir() existingPath := filepath.Join(pluginsDir, "sample-provider"+managementPluginExtension(runtime.GOOS)) @@ -511,10 +527,7 @@ func TestInstallPluginFromStoreOverwritesFilePreservesConfigAndReloads(t *testin "https://downloads.example/checksums.txt": []byte(hex.EncodeToString(checksum[:]) + " " + archiveName + "\n"), }, } - reloads := make(chan *config.Config, 1) - h.SetConfigReloadHook(func(_ context.Context, cfg *config.Config) { - reloads <- cfg - }) + reloads, reloadDone := captureConfigReload(h) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) @@ -526,8 +539,10 @@ func TestInstallPluginFromStoreOverwritesFilePreservesConfigAndReloads(t *testin if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } - if cfg := waitForAsyncReload(t, reloads); cfg != h.cfg { - t.Fatalf("reload config = %p, want handler config %p", cfg, h.cfg) + cfgSnapshot := waitForAsyncReload(t, reloads) + waitForReloadDone(t, reloadDone) + if cfgSnapshot == h.cfg { + t.Fatalf("reload config = handler config %p, want independent snapshot", h.cfg) } data, errRead := os.ReadFile(existingPath) if errRead != nil { @@ -540,13 +555,23 @@ func TestInstallPluginFromStoreOverwritesFilePreservesConfigAndReloads(t *testin if item.Enabled == nil || !*item.Enabled { t.Fatalf("plugin enabled = %#v, want true", item.Enabled) } + snapshotItem := cfgSnapshot.Plugins.Configs["sample-provider"] + if snapshotItem.Enabled == nil || !*snapshotItem.Enabled { + t.Fatalf("snapshot plugin enabled = %#v, want true", snapshotItem.Enabled) + } if item.Priority != 5 { t.Fatalf("plugin priority = %d, want 5", item.Priority) } + if snapshotItem.Priority != 5 { + t.Fatalf("snapshot plugin priority = %d, want 5", snapshotItem.Priority) + } raw := marshalPluginRaw(t, item) if !strings.Contains(raw, "mode: fast") || !strings.Contains(raw, "extra: keep") { t.Fatalf("plugin raw config lost custom fields:\n%s", raw) } + if raw := marshalPluginRaw(t, snapshotItem); !strings.Contains(raw, "mode: fast") || !strings.Contains(raw, "extra: keep") { + t.Fatalf("snapshot plugin raw config lost custom fields:\n%s", raw) + } } func TestEnablePluginConfigLockedPreservesExistingFields(t *testing.T) { diff --git a/internal/api/handlers/management/plugins_test.go b/internal/api/handlers/management/plugins_test.go index dfb273c7..a03b217d 100644 --- a/internal/api/handlers/management/plugins_test.go +++ b/internal/api/handlers/management/plugins_test.go @@ -32,9 +32,27 @@ func waitForAsyncReload(t *testing.T, reloads <-chan *config.Config) *config.Con } } +func waitForReloadDone(t *testing.T, done <-chan struct{}) { + t.Helper() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("timed out waiting for config reload hook to finish") + } +} + +func captureConfigReload(h *Handler) (<-chan *config.Config, <-chan struct{}) { + reloads := make(chan *config.Config, 1) + done := make(chan struct{}) + h.SetConfigReloadHook(func(_ context.Context, cfg *config.Config) { + defer close(done) + reloads <- cfg + }) + return reloads, done +} + func TestListPluginsIncludesScannedAndConfiguredPlugins(t *testing.T) { t.Parallel() - gin.SetMode(gin.TestMode) pluginsDir := writeManagementPluginFile(t, "scanned") disabled := false @@ -117,7 +135,6 @@ func TestListPluginsIncludesScannedAndConfiguredPlugins(t *testing.T) { func TestGetPluginConfigReturnsPreservedRawConfig(t *testing.T) { t.Parallel() - gin.SetMode(gin.TestMode) h := &Handler{ cfg: &config.Config{ @@ -174,7 +191,6 @@ options: func TestGetPluginConfigReturnsEmptyObjectForKnownUnconfiguredPlugin(t *testing.T) { t.Parallel() - gin.SetMode(gin.TestMode) pluginsDir := writeManagementPluginFile(t, "scanned") h := &Handler{ @@ -207,7 +223,6 @@ func TestGetPluginConfigReturnsEmptyObjectForKnownUnconfiguredPlugin(t *testing. func TestGetPluginConfigReturnsNotFoundForUnknownPlugin(t *testing.T) { t.Parallel() - gin.SetMode(gin.TestMode) h := &Handler{ cfg: &config.Config{}, @@ -228,7 +243,6 @@ func TestGetPluginConfigReturnsNotFoundForUnknownPlugin(t *testing.T) { func TestPatchPluginEnabledUpdatesOnlyPluginConfig(t *testing.T) { t.Parallel() - gin.SetMode(gin.TestMode) h := &Handler{ cfg: &config.Config{ @@ -241,10 +255,7 @@ func TestPatchPluginEnabledUpdatesOnlyPluginConfig(t *testing.T) { }, configFilePath: writeTestConfigFile(t), } - reloads := make(chan *config.Config, 1) - h.SetConfigReloadHook(func(_ context.Context, cfg *config.Config) { - reloads <- cfg - }) + reloads, reloadDone := captureConfigReload(h) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) @@ -257,8 +268,20 @@ func TestPatchPluginEnabledUpdatesOnlyPluginConfig(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } - if cfg := waitForAsyncReload(t, reloads); cfg != h.cfg { - t.Fatalf("reload config = %p, want handler config %p", cfg, h.cfg) + cfgSnapshot := waitForAsyncReload(t, reloads) + waitForReloadDone(t, reloadDone) + if cfgSnapshot == h.cfg { + t.Fatalf("reload config = handler config %p, want independent snapshot", h.cfg) + } + if cfgSnapshot.Plugins.Enabled { + t.Fatal("snapshot global Plugins.Enabled changed to true") + } + snapshotItem := cfgSnapshot.Plugins.Configs["sample"] + if snapshotItem.Enabled == nil || !*snapshotItem.Enabled { + t.Fatalf("snapshot sample enabled = %#v, want true", snapshotItem.Enabled) + } + if raw := marshalPluginRaw(t, snapshotItem); !strings.Contains(raw, "mode: safe") { + t.Fatalf("snapshot raw config lost custom field:\n%s", raw) } if h.cfg.Plugins.Enabled { t.Fatal("global Plugins.Enabled changed to true") @@ -273,9 +296,71 @@ func TestPatchPluginEnabledUpdatesOnlyPluginConfig(t *testing.T) { } } +func TestPatchPluginEnabledReloadSnapshotRawImmutability(t *testing.T) { + t.Parallel() + h := &Handler{ + cfg: &config.Config{ + Plugins: config.PluginsConfig{ + Configs: map[string]config.PluginInstanceConfig{ + "sample": pluginConfigFromYAML(t, "enabled: false\nmode: first\n"), + }, + }, + }, + configFilePath: writeTestConfigFile(t), + } + reloads := make(chan *config.Config, 1) + releaseReload := make(chan struct{}) + reloadDone := make(chan struct{}) + h.SetConfigReloadHook(func(_ context.Context, cfg *config.Config) { + defer close(reloadDone) + reloads <- cfg + <-releaseReload + }) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Params = gin.Params{{Key: "id", Value: "sample"}} + c.Request = httptest.NewRequest(http.MethodPatch, "/v0/management/plugins/sample/enabled", strings.NewReader(`{"enabled":true}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.PatchPluginEnabled(c) + + if rec.Code != http.StatusOK { + close(releaseReload) + waitForReloadDone(t, reloadDone) + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + cfgSnapshot := waitForAsyncReload(t, reloads) + + h.mu.Lock() + item := h.cfg.Plugins.Configs["sample"] + setPluginRawScalarValue(t, &item.Raw, "mode", "second") + h.cfg.Plugins.Configs["sample"] = item + h.mu.Unlock() + + if cfgSnapshot == h.cfg { + t.Fatalf("reload config = handler config %p, want independent snapshot", h.cfg) + } + snapshotItem := cfgSnapshot.Plugins.Configs["sample"] + if snapshotItem.Enabled == nil || !*snapshotItem.Enabled { + t.Fatalf("snapshot sample enabled = %#v, want true", snapshotItem.Enabled) + } + if got := pluginRawScalarValue(t, snapshotItem, "mode"); got != "first" { + t.Fatalf("snapshot raw mode = %q, want first", got) + } + h.mu.Lock() + handlerItem := h.cfg.Plugins.Configs["sample"] + h.mu.Unlock() + if got := pluginRawScalarValue(t, handlerItem, "mode"); got != "second" { + t.Fatalf("handler raw mode = %q, want second", got) + } + + close(releaseReload) + waitForReloadDone(t, reloadDone) +} + func TestPutPluginConfigReplacesPluginConfig(t *testing.T) { t.Parallel() - gin.SetMode(gin.TestMode) h := &Handler{ cfg: &config.Config{ @@ -311,7 +396,6 @@ func TestPutPluginConfigReplacesPluginConfig(t *testing.T) { func TestPatchPluginConfigMergesAndDeletesFields(t *testing.T) { t.Parallel() - gin.SetMode(gin.TestMode) h := &Handler{ cfg: &config.Config{ @@ -347,7 +431,6 @@ func TestPatchPluginConfigMergesAndDeletesFields(t *testing.T) { func TestDeletePluginRemovesDiscoveredFileAndConfig(t *testing.T) { t.Parallel() - gin.SetMode(gin.TestMode) pluginsDir := writeManagementPluginFile(t, "sample") h := &Handler{ @@ -363,8 +446,9 @@ func TestDeletePluginRemovesDiscoveredFileAndConfig(t *testing.T) { } reloads := make(chan *config.Config, 1) releaseReload := make(chan struct{}) - defer close(releaseReload) + reloadDone := make(chan struct{}) h.SetConfigReloadHook(func(_ context.Context, cfg *config.Config) { + defer close(reloadDone) reloads <- cfg <-releaseReload }) @@ -403,14 +487,23 @@ func TestDeletePluginRemovesDiscoveredFileAndConfig(t *testing.T) { if _, errStat := os.Stat(path); !os.IsNotExist(errStat) { t.Fatalf("plugin file stat error = %v, want not exist", errStat) } - if cfg := waitForAsyncReload(t, reloads); cfg != h.cfg { - t.Fatalf("reload config = %p, want handler config %p", cfg, h.cfg) + cfgSnapshot := waitForAsyncReload(t, reloads) + if cfgSnapshot == h.cfg { + close(releaseReload) + waitForReloadDone(t, reloadDone) + t.Fatalf("reload config = handler config %p, want independent snapshot", h.cfg) } + if _, ok := cfgSnapshot.Plugins.Configs["sample"]; ok { + close(releaseReload) + waitForReloadDone(t, reloadDone) + t.Fatal("snapshot plugin config still exists after delete") + } + close(releaseReload) + waitForReloadDone(t, reloadDone) } func TestDeletePluginReturnsNotFoundForUnknownPlugin(t *testing.T) { t.Parallel() - gin.SetMode(gin.TestMode) h := &Handler{ cfg: &config.Config{}, @@ -523,3 +616,25 @@ func marshalPluginRaw(t *testing.T, item config.PluginInstanceConfig) string { } return string(data) } + +func pluginRawScalarValue(t *testing.T, item config.PluginInstanceConfig, key string) string { + t.Helper() + for i := 0; i+1 < len(item.Raw.Content); i += 2 { + if item.Raw.Content[i] != nil && item.Raw.Content[i].Value == key && item.Raw.Content[i+1] != nil { + return item.Raw.Content[i+1].Value + } + } + t.Fatalf("plugin raw missing scalar key %q", key) + return "" +} + +func setPluginRawScalarValue(t *testing.T, node *yaml.Node, key, value string) { + t.Helper() + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i] != nil && node.Content[i].Value == key && node.Content[i+1] != nil { + node.Content[i+1].Value = value + return + } + } + t.Fatalf("plugin raw missing scalar key %q", key) +} diff --git a/internal/api/handlers/management/test_main_test.go b/internal/api/handlers/management/test_main_test.go new file mode 100644 index 00000000..f6ff4e4a --- /dev/null +++ b/internal/api/handlers/management/test_main_test.go @@ -0,0 +1,13 @@ +package management + +import ( + "os" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestMain(m *testing.M) { + gin.SetMode(gin.TestMode) + os.Exit(m.Run()) +} diff --git a/internal/api/handlers/management/usage_test.go b/internal/api/handlers/management/usage_test.go index bdb8aa2e..a0777b06 100644 --- a/internal/api/handlers/management/usage_test.go +++ b/internal/api/handlers/management/usage_test.go @@ -11,7 +11,6 @@ import ( ) func TestGetUsageQueuePopsRequestedRecords(t *testing.T) { - gin.SetMode(gin.TestMode) withManagementUsageQueue(t, func() { redisqueue.Enqueue([]byte(`{"id":1}`)) redisqueue.Enqueue([]byte(`{"id":2}`)) @@ -46,7 +45,6 @@ func TestGetUsageQueuePopsRequestedRecords(t *testing.T) { } func TestGetUsageQueueInvalidCountDoesNotPop(t *testing.T) { - gin.SetMode(gin.TestMode) withManagementUsageQueue(t, func() { redisqueue.Enqueue([]byte(`{"id":1}`))