Skip to content

Commit ae0fe4d

Browse files
JAORMXdmjb
andauthored
Fix test isolation to prevent config file modifications (#1667)
Problem: Tests were modifying the real configuration file at ~/.config/toolhive/config.yaml because they were using the global singleton config pattern, which automatically loads and saves to the default XDG config path. Root Cause: 1. The singleton pattern in pkg/config was being triggered by tests 2. LoadOrCreateConfigWithPath had a critical bug where it called config.save() instead of config.saveToPath(configPath), causing even tests with temp directories to save to the real config location 3. Tests were directly calling config.GetConfig() which triggers singleton initialization with the real config file Solution: 1. Introduced dependency injection with config.Provider interface - DefaultProvider: Uses the singleton (for production) - PathProvider: Uses a specific path (for tests) 2. Fixed the critical bug in LoadOrCreateConfigWithPath (line 179) - Changed from config.save() to config.saveToPath(configPath) 3. Updated all components to accept config providers: - API routes (registry, secrets) - Client manager - Workloads manager - Registry factory - Runner components - Migration utilities 4. Modified all tests to use CreateTestConfigProvider() helper - Creates temporary directories for test isolation - Uses PathProvider to avoid singleton initialization 5. Fixed race conditions in parallel tests by ensuring each subtest has its own mock controller Impact: - Tests no longer modify the real config file - Each test runs in complete isolation with its own config - Parallel test execution is now safe - Production code continues to use the singleton pattern - Backward compatibility maintained for existing code Verified: All 56 test functions in pkg/api/v1 and pkg/registry now pass without modifying ~/.config/toolhive/config.yaml Signed-off-by: Juan Antonio Osorio <[email protected]> Co-authored-by: Don Browne <[email protected]>
1 parent de5644f commit ae0fe4d

26 files changed

+615
-174
lines changed

cmd/thv/app/config.go

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,8 @@ func setCACertCmdFunc(_ *cobra.Command, args []string) error {
130130
}
131131

132132
func getCACertCmdFunc(_ *cobra.Command, _ []string) error {
133-
cfg := config.GetConfig()
133+
configProvider := config.NewDefaultProvider()
134+
cfg := configProvider.GetConfig()
134135

135136
if cfg.CACertificatePath == "" {
136137
fmt.Println("No CA certificate is currently configured.")
@@ -148,7 +149,8 @@ func getCACertCmdFunc(_ *cobra.Command, _ []string) error {
148149
}
149150

150151
func unsetCACertCmdFunc(_ *cobra.Command, _ []string) error {
151-
cfg := config.GetConfig()
152+
configProvider := config.NewDefaultProvider()
153+
cfg := configProvider.GetConfig()
152154

153155
if cfg.CACertificatePath == "" {
154156
fmt.Println("No CA certificate is currently configured.")
@@ -171,9 +173,11 @@ func setRegistryCmdFunc(_ *cobra.Command, args []string) error {
171173
input := args[0]
172174
registryType, cleanPath := config.DetectRegistryType(input)
173175

176+
provider := config.NewDefaultProvider()
177+
174178
switch registryType {
175179
case config.RegistryTypeURL:
176-
err := config.SetRegistryURL(cleanPath, allowPrivateRegistryIp)
180+
err := provider.SetRegistryURL(cleanPath, allowPrivateRegistryIp)
177181
if err != nil {
178182
return err
179183
}
@@ -188,14 +192,15 @@ func setRegistryCmdFunc(_ *cobra.Command, args []string) error {
188192
}
189193
return nil
190194
case config.RegistryTypeFile:
191-
return config.SetRegistryFile(cleanPath)
195+
return provider.SetRegistryFile(cleanPath)
192196
default:
193197
return fmt.Errorf("unsupported registry type")
194198
}
195199
}
196200

197201
func getRegistryCmdFunc(_ *cobra.Command, _ []string) error {
198-
url, localPath, _, registryType := config.GetRegistryConfig()
202+
provider := config.NewDefaultProvider()
203+
url, localPath, _, registryType := provider.GetRegistryConfig()
199204

200205
switch registryType {
201206
case config.RegistryTypeURL:
@@ -213,14 +218,15 @@ func getRegistryCmdFunc(_ *cobra.Command, _ []string) error {
213218
}
214219

215220
func unsetRegistryCmdFunc(_ *cobra.Command, _ []string) error {
216-
url, localPath, _, registryType := config.GetRegistryConfig()
221+
provider := config.NewDefaultProvider()
222+
url, localPath, _, registryType := provider.GetRegistryConfig()
217223

218224
if registryType == "default" {
219225
fmt.Println("No custom registry is currently configured.")
220226
return nil
221227
}
222228

223-
err := config.UnsetRegistry()
229+
err := provider.UnsetRegistry()
224230
if err != nil {
225231
return fmt.Errorf("failed to update configuration: %w", err)
226232
}

cmd/thv/app/otel.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,8 @@ func setOtelEndpointCmdFunc(_ *cobra.Command, args []string) error {
136136
}
137137

138138
func getOtelEndpointCmdFunc(_ *cobra.Command, _ []string) error {
139-
cfg := config.GetConfig()
139+
configProvider := config.NewDefaultProvider()
140+
cfg := configProvider.GetConfig()
140141

141142
if cfg.OTEL.Endpoint == "" {
142143
fmt.Println("No OpenTelemetry endpoint is currently configured.")
@@ -148,7 +149,8 @@ func getOtelEndpointCmdFunc(_ *cobra.Command, _ []string) error {
148149
}
149150

150151
func unsetOtelEndpointCmdFunc(_ *cobra.Command, _ []string) error {
151-
cfg := config.GetConfig()
152+
configProvider := config.NewDefaultProvider()
153+
cfg := configProvider.GetConfig()
152154

153155
if cfg.OTEL.Endpoint == "" {
154156
fmt.Println("No OpenTelemetry endpoint is currently configured.")
@@ -191,7 +193,8 @@ func setOtelSamplingRateCmdFunc(_ *cobra.Command, args []string) error {
191193
}
192194

193195
func getOtelSamplingRateCmdFunc(_ *cobra.Command, _ []string) error {
194-
cfg := config.GetConfig()
196+
configProvider := config.NewDefaultProvider()
197+
cfg := configProvider.GetConfig()
195198

196199
if cfg.OTEL.SamplingRate == 0.0 {
197200
fmt.Println("No OpenTelemetry sampling rate is currently configured.")
@@ -203,7 +206,8 @@ func getOtelSamplingRateCmdFunc(_ *cobra.Command, _ []string) error {
203206
}
204207

205208
func unsetOtelSamplingRateCmdFunc(_ *cobra.Command, _ []string) error {
206-
cfg := config.GetConfig()
209+
configProvider := config.NewDefaultProvider()
210+
cfg := configProvider.GetConfig()
207211

208212
if cfg.OTEL.SamplingRate == 0.0 {
209213
fmt.Println("No OpenTelemetry sampling rate is currently configured.")
@@ -243,7 +247,8 @@ func setOtelEnvVarsCmdFunc(_ *cobra.Command, args []string) error {
243247
}
244248

245249
func getOtelEnvVarsCmdFunc(_ *cobra.Command, _ []string) error {
246-
cfg := config.GetConfig()
250+
configProvider := config.NewDefaultProvider()
251+
cfg := configProvider.GetConfig()
247252

248253
if len(cfg.OTEL.EnvVars) == 0 {
249254
fmt.Println("No OpenTelemetry environment variables are currently configured.")
@@ -255,7 +260,8 @@ func getOtelEnvVarsCmdFunc(_ *cobra.Command, _ []string) error {
255260
}
256261

257262
func unsetOtelEnvVarsCmdFunc(_ *cobra.Command, _ []string) error {
258-
cfg := config.GetConfig()
263+
configProvider := config.NewDefaultProvider()
264+
cfg := configProvider.GetConfig()
259265

260266
if len(cfg.OTEL.EnvVars) == 0 {
261267
fmt.Println("No OpenTelemetry environment variables are currently configured.")

cmd/thv/app/run_flags.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,8 @@ func setupOIDCConfiguration(cmd *cobra.Command, runFlags *RunFlags) (*auth.Token
286286

287287
// setupTelemetryConfiguration sets up telemetry configuration with config fallbacks
288288
func setupTelemetryConfiguration(cmd *cobra.Command, runFlags *RunFlags) *telemetry.Config {
289-
config := cfg.GetConfig()
289+
configProvider := cfg.NewDefaultProvider()
290+
config := configProvider.GetConfig()
290291
finalOtelEndpoint, finalOtelSamplingRate, finalOtelEnvironmentVariables := getTelemetryFromFlags(cmd, config,
291292
runFlags.OtelEndpoint, runFlags.OtelSamplingRate, runFlags.OtelEnvironmentVariables)
292293

@@ -306,7 +307,8 @@ func setupRuntimeAndValidation(ctx context.Context) (runtime.Deployer, runner.En
306307
if process.IsDetached() || runtime.IsKubernetesRuntime() {
307308
envVarValidator = &runner.DetachedEnvVarValidator{}
308309
} else {
309-
envVarValidator = &runner.CLIEnvVarValidator{}
310+
cfgProvider := cfg.NewDefaultProvider()
311+
envVarValidator = runner.NewCLIEnvVarValidator(cfgProvider)
310312
}
311313

312314
return rt, envVarValidator, nil

cmd/thv/app/secret.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,9 @@ Note that some providers (like 1Password) are read-only and do not support setti
167167

168168
// Check if the provider supports writing secrets
169169
if !manager.Capabilities().CanWrite {
170-
providerType, _ := config.GetConfig().Secrets.GetProviderType()
170+
configProvider := config.NewDefaultProvider()
171+
cfg := configProvider.GetConfig()
172+
providerType, _ := cfg.Secrets.GetProviderType()
171173
fmt.Fprintf(os.Stderr, "Error: The %s secrets provider does not support setting secrets (read-only)\n", providerType)
172174
return
173175
}
@@ -250,7 +252,9 @@ If your provider is read-only or doesn't support deletion, this command returns
250252

251253
// Check if the provider supports deleting secrets
252254
if !manager.Capabilities().CanDelete {
253-
providerType, _ := config.GetConfig().Secrets.GetProviderType()
255+
configProvider := config.NewDefaultProvider()
256+
cfg := configProvider.GetConfig()
257+
providerType, _ := cfg.Secrets.GetProviderType()
254258
fmt.Fprintf(os.Stderr, "Error: The %s secrets provider does not support deleting secrets\n", providerType)
255259
return
256260
}
@@ -284,7 +288,9 @@ If descriptions exist for the secrets, the command displays them alongside the n
284288

285289
// Check if the provider supports listing secrets
286290
if !manager.Capabilities().CanList {
287-
providerType, _ := config.GetConfig().Secrets.GetProviderType()
291+
configProvider := config.NewDefaultProvider()
292+
cfg := configProvider.GetConfig()
293+
providerType, _ := cfg.Secrets.GetProviderType()
288294
fmt.Fprintf(os.Stderr, "Error: The %s secrets provider does not support listing secrets\n", providerType)
289295
return
290296
}
@@ -344,7 +350,8 @@ This command only works with the 'encrypted' secrets provider.`,
344350
}
345351

346352
func getSecretsManager() (secrets.Provider, error) {
347-
cfg := config.GetConfig()
353+
configProvider := config.NewDefaultProvider()
354+
cfg := configProvider.GetConfig()
348355

349356
// Check if secrets setup has been completed
350357
if !cfg.Secrets.SetupCompleted {

pkg/api/v1/groups_test.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -277,17 +277,21 @@ func TestGroupsRouter_Integration(t *testing.T) {
277277
logger.Initialize()
278278

279279
// Test with real managers (integration test)
280+
// Use a test config provider to avoid modifying the real config file
281+
configProvider, cleanup := CreateTestConfigProvider(t, nil)
282+
t.Cleanup(cleanup)
283+
280284
groupManager, err := groups.NewManager()
281285
if err != nil {
282286
t.Skip("Skipping integration test: failed to create group manager")
283287
}
284288

285-
workloadManager, err := workloads.NewManager(context.Background())
289+
workloadManager, err := workloads.NewManagerWithProvider(context.Background(), configProvider)
286290
if err != nil {
287291
t.Skip("Skipping integration test: failed to create workload manager")
288292
}
289293

290-
clientManager, err := client.NewManager(context.Background())
294+
clientManager, err := client.NewManagerWithProvider(context.Background(), configProvider)
291295
if err != nil {
292296
t.Skip("Skipping integration test: failed to create client manager")
293297
}

pkg/api/v1/healtcheck_test.go

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,19 @@ import (
1515
func TestGetHealthcheck(t *testing.T) {
1616
t.Parallel()
1717

18-
// Create a new gomock controller
19-
ctrl := gomock.NewController(t)
20-
t.Cleanup(func() {
21-
ctrl.Finish()
22-
})
23-
24-
// Create a mock runtime
25-
mockRuntime := mocks.NewMockRuntime(ctrl)
26-
27-
// Create healthcheck routes with the mock runtime
28-
routes := &healthcheckRoutes{containerRuntime: mockRuntime}
29-
3018
t.Run("returns 204 when runtime is running", func(t *testing.T) {
3119
t.Parallel()
20+
// Create a new gomock controller for this subtest
21+
ctrl := gomock.NewController(t)
22+
t.Cleanup(func() {
23+
ctrl.Finish()
24+
})
25+
26+
// Create a mock runtime
27+
mockRuntime := mocks.NewMockRuntime(ctrl)
28+
29+
// Create healthcheck routes with the mock runtime
30+
routes := &healthcheckRoutes{containerRuntime: mockRuntime}
3231

3332
// Setup mock to return nil (no error) when IsRunning is called
3433
mockRuntime.EXPECT().
@@ -49,6 +48,17 @@ func TestGetHealthcheck(t *testing.T) {
4948

5049
t.Run("returns 503 when runtime is not running", func(t *testing.T) {
5150
t.Parallel()
51+
// Create a new gomock controller for this subtest
52+
ctrl := gomock.NewController(t)
53+
t.Cleanup(func() {
54+
ctrl.Finish()
55+
})
56+
57+
// Create a mock runtime
58+
mockRuntime := mocks.NewMockRuntime(ctrl)
59+
60+
// Create healthcheck routes with the mock runtime
61+
routes := &healthcheckRoutes{containerRuntime: mockRuntime}
5262

5363
// Create an error to return
5464
expectedError := errors.New("container runtime is not available")

0 commit comments

Comments
 (0)