package config import ( "context" "fmt" "os" "path/filepath" "testing" "github.com/bnema/zerowrap" "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/bnema/gordon/internal/boundaries/out/mocks" "github.com/bnema/gordon/internal/domain" ) func testContext() context.Context { return zerowrap.WithCtx(context.Background(), zerowrap.Default()) } func requireRoute(t *testing.T, routes map[string]routeConfig, domainName string) routeConfig { t.Helper() route, ok := routes[domainName] require.True(t, ok, "route %q not found", domainName) return route } func setupServiceWithCoexistingLegacyRoute(t *testing.T, canonicalImage, legacyImage string) (*Service, context.Context) { t.Helper() tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") err := os.WriteFile(configFile, []byte(fmt.Sprintf("[routes]\n\"app.example.com\" = %q\n\"http://app.example.com\" = %q\n", canonicalImage, legacyImage)), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) v.Set("routes", map[string]any{ "app.example.com": canonicalImage, "http://app.example.com": legacyImage, }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() require.NoError(t, svc.Load(ctx)) svc.mu.Lock() if svc.config.Routes == nil { svc.config.Routes = make(map[string]routeConfig) } svc.config.Routes["app.example.com"] = routeConfig{Image: canonicalImage, HTTPS: true} svc.config.Routes["http://app.example.com"] = routeConfig{Image: legacyImage, HTTPS: false} svc.mu.Unlock() return svc, ctx } func TestService_Load(t *testing.T) { v := viper.New() v.Set("server.port", 8080) v.Set("server.registry_port", 5000) v.Set("server.registry_domain", "registry.example.com") v.Set("server.data_dir", "/var/gordon") v.Set("auto_route.enabled", true) v.Set("network_isolation.enabled", true) v.Set("network_isolation.network_prefix", "gordon") v.Set("auth.enabled", true) v.Set("auth.username", "admin") v.Set("auth.password", "secret") v.Set("volumes.auto_create", true) v.Set("volumes.prefix", "gordon") v.Set("volumes.preserve", false) v.Set("routes", map[string]interface{}{ "app.example.com": "myapp:latest", }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() err := svc.Load(ctx) assert.NoError(t, err) assert.Equal(t, 8080, svc.GetServerPort()) assert.Equal(t, 5000, svc.GetRegistryPort()) assert.Equal(t, "registry.example.com", svc.GetRegistryDomain()) assert.Equal(t, "/var/gordon", svc.GetDataDir()) assert.True(t, svc.IsAutoRouteEnabled()) assert.True(t, svc.IsNetworkIsolationEnabled()) assert.Equal(t, "gordon", svc.GetNetworkPrefix()) autoCreate, prefix, preserve := svc.GetVolumeConfig() assert.True(t, autoCreate) assert.Equal(t, "gordon", prefix) assert.False(t, preserve) } func TestService_Load_CanonicalInlineRoute(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") err := os.WriteFile(configFile, []byte(`[routes] "insecure.example.com" = { image = "myapp:latest", https = false } `), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) require.NoError(t, v.ReadInConfig()) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() err = svc.Load(ctx) require.NoError(t, err) route, err := svc.GetRoute(ctx, "insecure.example.com") require.NoError(t, err) assert.Equal(t, "myapp:latest", route.Image) assert.False(t, route.HTTPS) } func TestService_Load_CanonicalInlineRouteDefaultsHTTPS(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") err := os.WriteFile(configFile, []byte(`[routes] "secure.example.com" = { image = "myapp:latest" } `), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) require.NoError(t, v.ReadInConfig()) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() err = svc.Load(ctx) require.NoError(t, err) route, err := svc.GetRoute(ctx, "secure.example.com") require.NoError(t, err) assert.Equal(t, "myapp:latest", route.Image) assert.True(t, route.HTTPS) } func TestService_Reload_InvalidRouteKeyPreservesPreviousState(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") initialConfig := `[routes] "app.example.com" = "myapp:v1" ` err := os.WriteFile(configFile, []byte(initialConfig), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) require.NoError(t, v.ReadInConfig()) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() err = svc.Load(ctx) require.NoError(t, err) updatedConfig := `[routes] "localhost" = "myapp:v2" ` err = os.WriteFile(configFile, []byte(updatedConfig), 0600) require.NoError(t, err) err = svc.Reload(ctx) require.Error(t, err) route, getErr := svc.GetRoute(ctx, "app.example.com") require.NoError(t, getErr) assert.Equal(t, "myapp:v1", route.Image) assert.True(t, route.HTTPS) } func TestService_GetRoutes_DeduplicatesCanonicalAndLegacyEntries(t *testing.T) { svc, ctx := setupServiceWithCoexistingLegacyRoute(t, "canonical:v2", "legacy:v1") routes := svc.GetRoutes(ctx) require.Len(t, routes, 1) assert.Equal(t, "app.example.com", routes[0].Domain) assert.Equal(t, "canonical:v2", routes[0].Image) assert.True(t, routes[0].HTTPS) } func TestService_FindRoutesByImage_DeduplicatesCanonicalAndLegacyEntries(t *testing.T) { v := viper.New() v.Set("server.registry_domain", "registry.example.com") v.Set("routes", map[string]any{}) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) svc.mu.Lock() svc.config.Routes = make(map[string]routeConfig) svc.config.Routes["app.example.com"] = routeConfig{Image: "registry.example.com/myapp:v2", HTTPS: true} svc.config.Routes["http://app.example.com"] = routeConfig{Image: "myapp:v1", HTTPS: false} svc.mu.Unlock() routes := svc.FindRoutesByImage(ctx, "myapp") require.Len(t, routes, 1) assert.Equal(t, "app.example.com", routes[0].Domain) assert.Equal(t, "registry.example.com/myapp:v2", routes[0].Image) assert.True(t, routes[0].HTTPS) } func TestService_AddRoute_StoresHTTPSFalseInCanonicalForm(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") err := os.WriteFile(configFile, []byte("[routes]\n"), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest", HTTPS: false}) require.NoError(t, err) routeCfg := requireRoute(t, svc.GetConfig().Routes, "new.example.com") assert.Equal(t, "newapp:latest", routeCfg.Image) assert.False(t, routeCfg.HTTPS) } func TestService_AddRoute_RewritesRoutesToCanonicalInlineTables(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") initialConfig := `[routes] "secure.example.com" = "secure:v1" "http://insecure.example.com" = "insecure:v1" ` err := os.WriteFile(configFile, []byte(initialConfig), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) require.NoError(t, v.ReadInConfig()) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() err = svc.Load(ctx) require.NoError(t, err) err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "new:v1"}) require.NoError(t, err) content, err := os.ReadFile(configFile) require.NoError(t, err) text := string(content) assert.Contains(t, text, `"secure.example.com" = { image = "secure:v1", https = true }`) assert.Contains(t, text, `"insecure.example.com" = { image = "insecure:v1", https = false }`) assert.Contains(t, text, `"new.example.com" = { image = "new:v1", https = false }`) assert.NotContains(t, text, "http://insecure.example.com") assert.Contains(t, text, "[routes]") } func TestService_Reload(t *testing.T) { t.Run("success - picks up config file changes", func(t *testing.T) { // Create temp config file tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") initialConfig := `[server] port = 8080 [routes] "app.example.com" = "myapp:v1" ` err := os.WriteFile(configFile, []byte(initialConfig), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) err = v.ReadInConfig() require.NoError(t, err) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() // Initial load err = svc.Load(ctx) require.NoError(t, err) assert.Equal(t, 8080, svc.GetServerPort()) route := requireRoute(t, svc.GetConfig().Routes, "app.example.com") assert.Equal(t, "myapp:v1", route.Image) assert.True(t, route.HTTPS) // Modify config file on disk updatedConfig := `[server] port = 9090 [routes] "app.example.com" = "myapp:v2" "new.example.com" = "newapp:latest" ` err = os.WriteFile(configFile, []byte(updatedConfig), 0600) require.NoError(t, err) // Reload should pick up new values err = svc.Reload(ctx) require.NoError(t, err) assert.Equal(t, 9090, svc.GetServerPort()) route = requireRoute(t, svc.GetConfig().Routes, "app.example.com") assert.Equal(t, "myapp:v2", route.Image) assert.True(t, route.HTTPS) route = requireRoute(t, svc.GetConfig().Routes, "new.example.com") assert.Equal(t, "newapp:latest", route.Image) assert.True(t, route.HTTPS) }) t.Run("error - config file not found", func(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") // Create config, load it, then delete it err := os.WriteFile(configFile, []byte("[server]\nport = 8080\n"), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) err = v.ReadInConfig() require.NoError(t, err) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() err = svc.Load(ctx) require.NoError(t, err) // Delete the config file err = os.Remove(configFile) require.NoError(t, err) // Reload should fail err = svc.Reload(ctx) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to read config file") }) t.Run("error - invalid config syntax", func(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") // Create valid config initially err := os.WriteFile(configFile, []byte("[server]\nport = 8080\n"), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) err = v.ReadInConfig() require.NoError(t, err) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() err = svc.Load(ctx) require.NoError(t, err) // Write invalid TOML err = os.WriteFile(configFile, []byte("[server\nport = invalid syntax"), 0600) require.NoError(t, err) // Reload should fail err = svc.Reload(ctx) assert.Error(t, err) }) } func TestService_GetRoutes(t *testing.T) { v := viper.New() v.Set("routes", map[string]interface{}{ "app1.example.com": "myapp:latest", "app2.example.com": "otherapp:v2", "http://insecure.example": "insecureapp:latest", }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) routes := svc.GetRoutes(ctx) assert.Len(t, routes, 3) // Check routes (order is not guaranteed) routeMap := make(map[string]domain.Route) for _, r := range routes { routeMap[r.Domain] = r } assert.Equal(t, "myapp:latest", routeMap["app1.example.com"].Image) assert.True(t, routeMap["app1.example.com"].HTTPS) assert.Equal(t, "otherapp:v2", routeMap["app2.example.com"].Image) assert.True(t, routeMap["app2.example.com"].HTTPS) assert.Equal(t, "insecureapp:latest", routeMap["insecure.example"].Image) assert.False(t, routeMap["insecure.example"].HTTPS) } func TestService_GetRoute(t *testing.T) { v := viper.New() v.Set("routes", map[string]interface{}{ "app.example.com": "myapp:latest", "http://insecure.example": "insecureapp:latest", }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) t.Run("existing route", func(t *testing.T) { route, err := svc.GetRoute(ctx, "app.example.com") require.NoError(t, err) assert.Equal(t, "app.example.com", route.Domain) assert.Equal(t, "myapp:latest", route.Image) assert.True(t, route.HTTPS) }) t.Run("legacy http route found via fallback", func(t *testing.T) { route, err := svc.GetRoute(ctx, "insecure.example") require.NoError(t, err) assert.Equal(t, "insecure.example", route.Domain) assert.Equal(t, "insecureapp:latest", route.Image) assert.False(t, route.HTTPS) }) t.Run("non-existent route", func(t *testing.T) { route, err := svc.GetRoute(ctx, "notfound.example.com") assert.ErrorIs(t, err, domain.ErrRouteNotFound) assert.Nil(t, route) }) } func TestService_AddRoute(t *testing.T) { t.Run("success", func(t *testing.T) { // Create temp config file for Save() to work tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") err := os.WriteFile(configFile, []byte("[routes]\n"), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) route := domain.Route{ Domain: "new.example.com", Image: "newapp:latest", HTTPS: true, } err = svc.AddRoute(ctx, route) assert.NoError(t, err) // Verify route was added config := svc.GetConfig() routeCfg := requireRoute(t, config.Routes, "new.example.com") assert.Equal(t, "newapp:latest", routeCfg.Image) assert.True(t, routeCfg.HTTPS) }) t.Run("zero-value route stores HTTPS false in canonical form", func(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") err := os.WriteFile(configFile, []byte("[routes]\n"), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) // Zero-value HTTPS field (false) should be preserved route := domain.Route{ Domain: "app.example.com", Image: "myapp:latest", } err = svc.AddRoute(ctx, route) require.NoError(t, err) config := svc.GetConfig() routeCfg := requireRoute(t, config.Routes, "app.example.com") assert.Equal(t, "myapp:latest", routeCfg.Image, "zero-value route should store under plain domain key") assert.False(t, routeCfg.HTTPS) _, hasHTTPKey := config.Routes["http://app.example.com"] assert.False(t, hasHTTPKey, "zero-value route should NOT get http:// prefix") // Verify it round-trips as HTTPS=false via GetRoutes routes := svc.GetRoutes(ctx) require.Len(t, routes, 1) assert.False(t, routes[0].HTTPS, "zero-value route should be treated as HTTP") }) t.Run("HTTPS explicitly true stores under plain domain key", func(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") err := os.WriteFile(configFile, []byte("[routes]\n"), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) // Explicitly set HTTPS: true — should store under plain domain key route := domain.Route{ Domain: "secure.example.com", Image: "myapp:latest", HTTPS: true, } err = svc.AddRoute(ctx, route) require.NoError(t, err) config := svc.GetConfig() routeCfg := requireRoute(t, config.Routes, "secure.example.com") assert.Equal(t, "myapp:latest", routeCfg.Image, "HTTPS=true route should store under plain domain key") assert.True(t, routeCfg.HTTPS) }) t.Run("idempotent same domain and image saves once", func(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") err := os.WriteFile(configFile, []byte("[routes]\n"), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) route := domain.Route{ Domain: "new.example.com", Image: "newapp:latest", HTTPS: true, } err = svc.AddRoute(ctx, route) require.NoError(t, err) before, err := os.Stat(configFile) require.NoError(t, err) err = svc.AddRoute(ctx, route) require.NoError(t, err) after, err := os.Stat(configFile) require.NoError(t, err) assert.Equal(t, before.ModTime(), after.ModTime()) routeCfg := requireRoute(t, svc.GetConfig().Routes, "new.example.com") assert.Equal(t, "newapp:latest", routeCfg.Image) assert.True(t, routeCfg.HTTPS) }) t.Run("empty domain", func(t *testing.T) { v := viper.New() eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() route := domain.Route{ Domain: "", Image: "myapp:latest", } err := svc.AddRoute(ctx, route) assert.ErrorIs(t, err, domain.ErrRouteDomainEmpty) }) t.Run("empty image", func(t *testing.T) { v := viper.New() eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() route := domain.Route{ Domain: "example.com", Image: "", } err := svc.AddRoute(ctx, route) assert.ErrorIs(t, err, domain.ErrRouteImageEmpty) }) t.Run("invalid domain - IP address", func(t *testing.T) { v := viper.New() eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() route := domain.Route{ Domain: "192.168.1.1", Image: "myapp:latest", } err := svc.AddRoute(ctx, route) assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid) }) t.Run("invalid domain - localhost", func(t *testing.T) { v := viper.New() eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() route := domain.Route{ Domain: "localhost", Image: "myapp:latest", } err := svc.AddRoute(ctx, route) assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid) }) t.Run("invalid domain - internal TLD", func(t *testing.T) { v := viper.New() eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() route := domain.Route{ Domain: "myapp.local", Image: "myapp:latest", } err := svc.AddRoute(ctx, route) assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid) }) t.Run("invalid domain - with port", func(t *testing.T) { v := viper.New() eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() route := domain.Route{ Domain: "example.com:8080", Image: "myapp:latest", } err := svc.AddRoute(ctx, route) assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid) }) t.Run("reconciles coexisting legacy http key", func(t *testing.T) { svc, ctx := setupServiceWithCoexistingLegacyRoute(t, "myapp:v1", "myapp:old") preConfig := svc.GetConfig() requireRoute(t, preConfig.Routes, "app.example.com") requireRoute(t, preConfig.Routes, "http://app.example.com") route := domain.Route{ Domain: "app.example.com", Image: "myapp:v2", HTTPS: true, } err := svc.AddRoute(ctx, route) assert.NoError(t, err) config := svc.GetConfig() routeCfg := requireRoute(t, config.Routes, "app.example.com") assert.Equal(t, "myapp:v2", routeCfg.Image, "canonical route should be updated") assert.True(t, routeCfg.HTTPS) _, exists := config.Routes["http://app.example.com"] assert.False(t, exists, "legacy http route key should be removed during reconciliation") }) } func TestService_UpdateRoute(t *testing.T) { t.Run("success", func(t *testing.T) { // Create temp config file for Save() to work tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") err := os.WriteFile(configFile, []byte("[routes]\n\"app.example.com\" = \"myapp:v1\"\n"), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) v.Set("routes", map[string]any{ "app.example.com": "myapp:v1", }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) route := domain.Route{ Domain: "app.example.com", Image: "myapp:v2", HTTPS: true, } err = svc.UpdateRoute(ctx, route) assert.NoError(t, err) config := svc.GetConfig() routeCfg := requireRoute(t, config.Routes, "app.example.com") assert.Equal(t, "myapp:v2", routeCfg.Image) assert.True(t, routeCfg.HTTPS) }) t.Run("updates https setting", func(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") err := os.WriteFile(configFile, []byte("[routes]\n\"app.example.com\" = \"myapp:v1\"\n"), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) v.Set("routes", map[string]any{ "app.example.com": "myapp:v1", }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) route := domain.Route{ Domain: "app.example.com", Image: "myapp:v2", HTTPS: false, } err = svc.UpdateRoute(ctx, route) require.NoError(t, err) routeCfg := requireRoute(t, svc.GetConfig().Routes, "app.example.com") assert.Equal(t, "myapp:v2", routeCfg.Image) assert.False(t, routeCfg.HTTPS) content, err := os.ReadFile(configFile) require.NoError(t, err) assert.Contains(t, string(content), `"app.example.com" = { image = "myapp:v2", https = false }`) }) t.Run("not found", func(t *testing.T) { v := viper.New() eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) route := domain.Route{ Domain: "nonexistent.example.com", Image: "myapp:latest", } err := svc.UpdateRoute(ctx, route) assert.ErrorIs(t, err, domain.ErrRouteNotFound) }) t.Run("empty domain", func(t *testing.T) { v := viper.New() eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() route := domain.Route{ Domain: "", Image: "myapp:latest", } err := svc.UpdateRoute(ctx, route) assert.ErrorIs(t, err, domain.ErrRouteDomainEmpty) }) t.Run("empty image", func(t *testing.T) { v := viper.New() eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() route := domain.Route{ Domain: "example.com", Image: "", } err := svc.UpdateRoute(ctx, route) assert.ErrorIs(t, err, domain.ErrRouteImageEmpty) }) t.Run("invalid domain - IP address", func(t *testing.T) { v := viper.New() v.Set("routes", map[string]any{ "192.168.1.1": "myapp:v1", }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) route := domain.Route{ Domain: "192.168.1.1", Image: "myapp:v2", } err := svc.UpdateRoute(ctx, route) assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid) }) t.Run("invalid domain - localhost", func(t *testing.T) { v := viper.New() v.Set("routes", map[string]any{ "localhost": "myapp:v1", }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) route := domain.Route{ Domain: "localhost", Image: "myapp:v2", } err := svc.UpdateRoute(ctx, route) assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid) }) t.Run("insecure route found via http prefix fallback", func(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") err := os.WriteFile(configFile, []byte("[routes]\n\"http://insecure.example.com\" = \"myapp:v1\"\n"), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) v.Set("routes", map[string]any{ "http://insecure.example.com": "myapp:v1", }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) // Caller uses clean domain for update route := domain.Route{ Domain: "insecure.example.com", Image: "myapp:v2", HTTPS: false, } err = svc.UpdateRoute(ctx, route) assert.NoError(t, err, "UpdateRoute should find insecure route via http:// fallback") config := svc.GetConfig() routeCfg := requireRoute(t, config.Routes, "insecure.example.com") assert.Equal(t, "myapp:v2", routeCfg.Image, "insecure route update should preserve the plain hostname key") assert.False(t, routeCfg.HTTPS) }) t.Run("reconciles coexisting legacy http key", func(t *testing.T) { svc, ctx := setupServiceWithCoexistingLegacyRoute(t, "myapp:v1", "myapp:old") preConfig := svc.GetConfig() requireRoute(t, preConfig.Routes, "app.example.com") requireRoute(t, preConfig.Routes, "http://app.example.com") route := domain.Route{ Domain: "app.example.com", Image: "myapp:v2", HTTPS: true, } err := svc.UpdateRoute(ctx, route) assert.NoError(t, err) config := svc.GetConfig() routeCfg := requireRoute(t, config.Routes, "app.example.com") assert.Equal(t, "myapp:v2", routeCfg.Image, "canonical route should be updated") assert.True(t, routeCfg.HTTPS) _, exists := config.Routes["http://app.example.com"] assert.False(t, exists, "legacy http route key should be removed during reconciliation") }) } func TestService_RemoveRoute(t *testing.T) { // Create temp config file for Save() to work tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") err := os.WriteFile(configFile, []byte("[routes]\n\"app.example.com\" = \"myapp:latest\"\n"), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) v.Set("routes", map[string]interface{}{ "app.example.com": "myapp:latest", }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) err = svc.RemoveRoute(ctx, "app.example.com") assert.NoError(t, err) config := svc.GetConfig() _, exists := config.Routes["app.example.com"] assert.False(t, exists) } func TestService_RemoveRoute_InvalidDomain(t *testing.T) { v := viper.New() eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() err := svc.RemoveRoute(ctx, "localhost") assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid) } func TestService_RemoveRoute_NotFound(t *testing.T) { v := viper.New() eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) err := svc.RemoveRoute(ctx, "nonexistent.example.com") assert.ErrorIs(t, err, domain.ErrRouteNotFound) } func TestService_RemoveRoute_InsecureFallback(t *testing.T) { // Setup: insecure route stored with http:// prefix tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") err := os.WriteFile(configFile, []byte("[routes]\n\"http://insecure.example.com\" = \"myapp:latest\"\n"), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) v.Set("routes", map[string]interface{}{ "http://insecure.example.com": "myapp:latest", }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) // Caller passes plain domain (not the storage key) err = svc.RemoveRoute(ctx, "insecure.example.com") assert.NoError(t, err, "RemoveRoute should find insecure route via http:// fallback") config := svc.GetConfig() _, exists := config.Routes["insecure.example.com"] assert.False(t, exists, "insecure route should be removed") } func TestService_RemoveRoute_ReconcilesCoexistingLegacyKey(t *testing.T) { svc, ctx := setupServiceWithCoexistingLegacyRoute(t, "myapp:latest", "myapp:old") err := svc.RemoveRoute(ctx, "app.example.com") assert.NoError(t, err) config := svc.GetConfig() _, exists := config.Routes["app.example.com"] assert.False(t, exists, "canonical route should be removed") _, exists = config.Routes["http://app.example.com"] assert.False(t, exists, "legacy http route key should be removed during reconciliation") } func TestService_Save_WrapsInvalidRouteDomainError(t *testing.T) { t.Run("canonical key", func(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") err := os.WriteFile(configFile, []byte("[routes]\n\"app.example.com\" = \"myapp:latest\"\n"), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) require.NoError(t, v.ReadInConfig()) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() require.NoError(t, svc.Load(ctx)) svc.mu.Lock() svc.config.Routes["localhost"] = routeConfig{Image: "myapp:latest", HTTPS: true} svc.mu.Unlock() err = svc.Save(ctx) assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid) }) t.Run("legacy key", func(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") err := os.WriteFile(configFile, []byte("[routes]\n\"app.example.com\" = \"myapp:latest\"\n"), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) require.NoError(t, v.ReadInConfig()) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() require.NoError(t, svc.Load(ctx)) svc.mu.Lock() svc.config.Routes["http://localhost"] = routeConfig{Image: "myapp:latest", HTTPS: false} svc.mu.Unlock() err = svc.Save(ctx) assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid) }) } func TestService_GetNetworkGroups(t *testing.T) { v := viper.New() v.Set("network_groups", map[string]interface{}{ "frontend": []interface{}{"app1.example.com", "app2.example.com"}, "backend": []interface{}{"api.example.com", "db.example.com"}, }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) groups := svc.GetNetworkGroups() assert.Len(t, groups, 2) assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, groups["frontend"]) assert.ElementsMatch(t, []string{"api.example.com", "db.example.com"}, groups["backend"]) } func TestService_GetAttachmentConfig(t *testing.T) { v := viper.New() v.Set("attachments", map[string]interface{}{ "app.example.com": []interface{}{"redis:latest", "postgres:18"}, }) v.Set("network_groups", map[string]interface{}{ "shared": []interface{}{"app.example.com", "api.example.com"}, }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) snapshot := svc.GetAttachmentConfig() assert.Len(t, snapshot.Attachments, 1) assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, snapshot.Attachments["app.example.com"]) assert.Len(t, snapshot.NetworkGroups, 1) assert.ElementsMatch(t, []string{"app.example.com", "api.example.com"}, snapshot.NetworkGroups["shared"]) snapshot.Attachments["app.example.com"][0] = "mutated" snapshot.NetworkGroups["shared"][0] = "mutated.example.com" freshSnapshot := svc.GetAttachmentConfig() assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, freshSnapshot.Attachments["app.example.com"]) assert.ElementsMatch(t, []string{"app.example.com", "api.example.com"}, freshSnapshot.NetworkGroups["shared"]) } func TestService_GetAllAttachments(t *testing.T) { t.Run("returns all attachments", func(t *testing.T) { v := viper.New() v.Set("attachments", map[string]interface{}{ "app.example.com": []interface{}{"redis:latest", "postgres:18"}, "api.example.com": []interface{}{"rabbitmq:3"}, }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) attachments := svc.GetAllAttachments(ctx) assert.Len(t, attachments, 2) assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, attachments["app.example.com"]) assert.ElementsMatch(t, []string{"rabbitmq:3"}, attachments["api.example.com"]) }) t.Run("returns empty map when no attachments", func(t *testing.T) { v := viper.New() eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) attachments := svc.GetAllAttachments(ctx) assert.Empty(t, attachments) }) t.Run("returns copy not reference", func(t *testing.T) { v := viper.New() v.Set("attachments", map[string]interface{}{ "app.example.com": []interface{}{"redis:latest"}, }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) attachments := svc.GetAllAttachments(ctx) // Modify the returned map attachments["app.example.com"] = append(attachments["app.example.com"], "postgres:18") // Original should be unchanged original := svc.GetAllAttachments(ctx) assert.Len(t, original["app.example.com"], 1) }) } func TestService_GetAttachmentsFor(t *testing.T) { t.Run("existing domain", func(t *testing.T) { v := viper.New() v.Set("attachments", map[string]interface{}{ "app.example.com": []interface{}{"redis:latest", "postgres:18"}, }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) images, err := svc.GetAttachmentsFor(ctx, "app.example.com") require.NoError(t, err) assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, images) }) t.Run("existing network group", func(t *testing.T) { v := viper.New() v.Set("attachments", map[string]interface{}{ "backend": []interface{}{"rabbitmq:3", "redis:latest"}, }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) images, err := svc.GetAttachmentsFor(ctx, "backend") require.NoError(t, err) assert.ElementsMatch(t, []string{"rabbitmq:3", "redis:latest"}, images) }) t.Run("non-existent target", func(t *testing.T) { v := viper.New() eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) images, err := svc.GetAttachmentsFor(ctx, "notfound.example.com") assert.ErrorIs(t, err, domain.ErrAttachmentNotFound) assert.Nil(t, images) }) t.Run("returns copy not reference", func(t *testing.T) { v := viper.New() v.Set("attachments", map[string]interface{}{ "app.example.com": []interface{}{"redis:latest"}, }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) images, err := svc.GetAttachmentsFor(ctx, "app.example.com") require.NoError(t, err) // Modify the returned slice (use _ to satisfy linter) _ = append(images, "postgres:18") // Original should be unchanged original, _ := svc.GetAttachmentsFor(ctx, "app.example.com") assert.Len(t, original, 1) }) } func TestService_AddAttachment(t *testing.T) { t.Run("success - new target", func(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") err := os.WriteFile(configFile, []byte("[attachments]\n"), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) err = svc.AddAttachment(ctx, "app.example.com", "postgres:18") assert.NoError(t, err) // Verify attachment was added config := svc.GetConfig() assert.Contains(t, config.Attachments["app.example.com"], "postgres:18") }) t.Run("success - existing target", func(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") err := os.WriteFile(configFile, []byte("[attachments]\n\"app.example.com\" = [\"redis:latest\"]\n"), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) v.Set("attachments", map[string]interface{}{ "app.example.com": []interface{}{"redis:latest"}, }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) err = svc.AddAttachment(ctx, "app.example.com", "postgres:18") assert.NoError(t, err) // Verify attachment was added alongside existing config := svc.GetConfig() assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, config.Attachments["app.example.com"]) }) t.Run("duplicate attachment", func(t *testing.T) { v := viper.New() v.Set("attachments", map[string]interface{}{ "app.example.com": []interface{}{"redis:latest"}, }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) err := svc.AddAttachment(ctx, "app.example.com", "redis:latest") assert.ErrorIs(t, err, domain.ErrAttachmentExists) }) t.Run("empty target", func(t *testing.T) { v := viper.New() eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() err := svc.AddAttachment(ctx, "", "postgres:18") assert.ErrorIs(t, err, domain.ErrAttachmentTargetEmpty) }) t.Run("empty image", func(t *testing.T) { v := viper.New() eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() err := svc.AddAttachment(ctx, "app.example.com", "") assert.ErrorIs(t, err, domain.ErrAttachmentImageEmpty) }) t.Run("network group target", func(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") err := os.WriteFile(configFile, []byte("[attachments]\n"), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) err = svc.AddAttachment(ctx, "backend", "rabbitmq:3") assert.NoError(t, err) config := svc.GetConfig() assert.Contains(t, config.Attachments["backend"], "rabbitmq:3") }) } func TestService_RemoveAttachment(t *testing.T) { t.Run("success - single attachment", func(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") err := os.WriteFile(configFile, []byte("[attachments]\n\"app.example.com\" = [\"redis:latest\"]\n"), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) v.Set("attachments", map[string]interface{}{ "app.example.com": []interface{}{"redis:latest"}, }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) err = svc.RemoveAttachment(ctx, "app.example.com", "redis:latest") assert.NoError(t, err) // Verify target was removed (no attachments left) config := svc.GetConfig() _, exists := config.Attachments["app.example.com"] assert.False(t, exists) }) t.Run("success - multiple attachments", func(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") err := os.WriteFile(configFile, []byte("[attachments]\n\"app.example.com\" = [\"redis:latest\", \"postgres:18\"]\n"), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) v.Set("attachments", map[string]interface{}{ "app.example.com": []interface{}{"redis:latest", "postgres:18"}, }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) err = svc.RemoveAttachment(ctx, "app.example.com", "redis:latest") assert.NoError(t, err) // Verify only redis was removed config := svc.GetConfig() assert.Equal(t, []string{"postgres:18"}, config.Attachments["app.example.com"]) }) t.Run("target not found", func(t *testing.T) { v := viper.New() eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) err := svc.RemoveAttachment(ctx, "notfound.example.com", "redis:latest") assert.ErrorIs(t, err, domain.ErrAttachmentNotFound) }) t.Run("image not found in target", func(t *testing.T) { v := viper.New() v.Set("attachments", map[string]interface{}{ "app.example.com": []interface{}{"redis:latest"}, }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) err := svc.RemoveAttachment(ctx, "app.example.com", "postgres:18") assert.ErrorIs(t, err, domain.ErrAttachmentNotFound) }) t.Run("empty target", func(t *testing.T) { v := viper.New() eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() err := svc.RemoveAttachment(ctx, "", "redis:latest") assert.ErrorIs(t, err, domain.ErrAttachmentTargetEmpty) }) t.Run("empty image", func(t *testing.T) { v := viper.New() eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() err := svc.RemoveAttachment(ctx, "app.example.com", "") assert.ErrorIs(t, err, domain.ErrAttachmentImageEmpty) }) t.Run("network group target", func(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") err := os.WriteFile(configFile, []byte("[attachments]\n\"backend\" = [\"rabbitmq:3\"]\n"), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) v.Set("attachments", map[string]interface{}{ "backend": []interface{}{"rabbitmq:3"}, }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) err = svc.RemoveAttachment(ctx, "backend", "rabbitmq:3") assert.NoError(t, err) config := svc.GetConfig() _, exists := config.Attachments["backend"] assert.False(t, exists) }) } func TestService_AutoRouteAllowedDomains(t *testing.T) { t.Run("get returns copy", func(t *testing.T) { v := viper.New() v.Set("auto_route_allowed_domains", []string{"example.com", "*.example.com"}) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() require.NoError(t, svc.Load(ctx)) domains, err := svc.GetAutoRouteAllowedDomains(ctx) require.NoError(t, err) domains[0] = "changed.com" again, err := svc.GetAutoRouteAllowedDomains(ctx) require.NoError(t, err) assert.Equal(t, []string{"example.com", "*.example.com"}, again) }) t.Run("add and remove persist idempotently", func(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") require.NoError(t, os.WriteFile(configFile, []byte("auto_route_allowed_domains = [\"example.com\"]\n"), 0600)) v := viper.New() v.SetConfigFile(configFile) v.Set("auto_route_allowed_domains", []interface{}{"example.com"}) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() require.NoError(t, svc.Load(ctx)) require.NoError(t, svc.AddAutoRouteAllowedDomain(ctx, "*.example.com")) require.NoError(t, svc.AddAutoRouteAllowedDomain(ctx, "*.example.com")) domains, err := svc.GetAutoRouteAllowedDomains(ctx) require.NoError(t, err) assert.Equal(t, []string{"example.com", "*.example.com"}, domains) require.NoError(t, svc.RemoveAutoRouteAllowedDomain(ctx, "example.com")) require.NoError(t, svc.RemoveAutoRouteAllowedDomain(ctx, "missing.com")) domains, err = svc.GetAutoRouteAllowedDomains(ctx) require.NoError(t, err) assert.Equal(t, []string{"*.example.com"}, domains) }) t.Run("add validates patterns", func(t *testing.T) { v := viper.New() eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() require.NoError(t, svc.Load(ctx)) invalid := []string{"", "EXAMPLE.com", "example.com.", "**.example.com", "foo.*.example.com", "*.example.com."} for _, pattern := range invalid { err := svc.AddAutoRouteAllowedDomain(ctx, pattern) assert.ErrorIs(t, err, domain.ErrInvalidDomainPattern, "pattern: %s", pattern) } }) } func TestService_GetExternalRoutes(t *testing.T) { v := viper.New() v.Set("external_routes", map[string]interface{}{ "reg.example.com": "localhost:5000", "cache.example.com": "127.0.0.1:6379", }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) routes := svc.GetExternalRoutes() assert.Len(t, routes, 2) assert.Equal(t, "localhost:5000", routes["reg.example.com"]) assert.Equal(t, "127.0.0.1:6379", routes["cache.example.com"]) } func TestService_GetExternalRoutes_Empty(t *testing.T) { v := viper.New() eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) routes := svc.GetExternalRoutes() assert.Empty(t, routes) } func TestSplitImageNameTag(t *testing.T) { tests := []struct { name string image string wantName string wantHasTag bool }{ {"with tag", "myapp:latest", "myapp", true}, {"with version tag", "myapp:v1.2.0", "myapp", true}, {"no tag", "myapp", "myapp", false}, {"empty string", "", "", false}, {"tag only colon", "myapp:", "myapp", true}, {"registry prefixed with tag", "reg.example.com/myapp:latest", "reg.example.com/myapp", true}, {"registry prefixed no tag", "reg.example.com/myapp", "reg.example.com/myapp", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { name, hasTag := splitImageNameTag(tt.image) assert.Equal(t, tt.wantName, name) assert.Equal(t, tt.wantHasTag, hasTag) }) } } func TestNormalizeRegistryImage(t *testing.T) { tests := []struct { name string imageName string registryDomain string expected string }{ {"strips registry prefix", "reg.example.com/myapp:latest", "reg.example.com", "myapp:latest"}, {"no prefix to strip", "myapp:latest", "reg.example.com", "myapp:latest"}, {"empty registry domain", "myapp:latest", "", "myapp:latest"}, {"registry with trailing slash", "reg.example.com/myapp:latest", "reg.example.com/", "myapp:latest"}, {"partial match is not stripped", "reg.example.com.evil/myapp:latest", "reg.example.com", "reg.example.com.evil/myapp:latest"}, {"bare name no registry", "myapp", "reg.example.com", "myapp"}, {"exact registry without image", "reg.example.com/", "reg.example.com", ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := NormalizeRegistryImage(tt.imageName, tt.registryDomain) assert.Equal(t, tt.expected, result) }) } } func TestNormalizeBootstrapImage(t *testing.T) { tests := []struct { name string image string registryDomain string expected string wantErr string }{ {name: "bare name prepends registry", image: "myapp", registryDomain: "reg.example.com", expected: "reg.example.com/myapp"}, {name: "bare name strips tag", image: "myapp:v1", registryDomain: "reg.example.com", expected: "reg.example.com/myapp"}, {name: "qualified image unchanged", image: "reg.example.com/myapp", registryDomain: "reg.example.com", expected: "reg.example.com/myapp"}, {name: "qualified image strips tag", image: "reg.example.com/myapp:abc123", registryDomain: "reg.example.com", expected: "reg.example.com/myapp"}, {name: "other registry strips tag", image: "other.registry.com/myapp:v2", registryDomain: "reg.example.com", expected: "other.registry.com/myapp"}, {name: "empty image", image: "", registryDomain: "reg.example.com", wantErr: "image is required"}, {name: "empty registry domain", image: "pitlane", registryDomain: "", wantErr: "registry domain is not configured"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := NormalizeBootstrapImage(tt.image, tt.registryDomain) if tt.wantErr != "" { require.Error(t, err) assert.Equal(t, tt.wantErr, err.Error()) assert.Empty(t, result) return } require.NoError(t, err) assert.Equal(t, tt.expected, result) }) } } func TestService_FindRoutesByImage(t *testing.T) { t.Run("exact match with tag", func(t *testing.T) { v := viper.New() v.Set("routes", map[string]interface{}{ "app.example.com": "myapp:latest", }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) routes := svc.FindRoutesByImage(ctx, "myapp:latest") assert.Len(t, routes, 1) assert.Equal(t, "app.example.com", routes[0].Domain) assert.Equal(t, "myapp:latest", routes[0].Image) }) t.Run("bare name matches route with tag", func(t *testing.T) { v := viper.New() v.Set("routes", map[string]interface{}{ "app.example.com": "myapp:latest", }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) routes := svc.FindRoutesByImage(ctx, "myapp") assert.Len(t, routes, 1) assert.Equal(t, "app.example.com", routes[0].Domain) }) t.Run("bare name matches route with version tag", func(t *testing.T) { v := viper.New() v.Set("routes", map[string]interface{}{ "app.example.com": "myapp:v2.0.0", }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) routes := svc.FindRoutesByImage(ctx, "myapp") assert.Len(t, routes, 1) assert.Equal(t, "app.example.com", routes[0].Domain) }) t.Run("tag mismatch does not match", func(t *testing.T) { v := viper.New() v.Set("routes", map[string]interface{}{ "app.example.com": "myapp:v1", }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) routes := svc.FindRoutesByImage(ctx, "myapp:v2") assert.Empty(t, routes) }) t.Run("no match", func(t *testing.T) { v := viper.New() v.Set("routes", map[string]interface{}{ "app.example.com": "myapp:latest", }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) routes := svc.FindRoutesByImage(ctx, "otherapp") assert.Empty(t, routes) }) t.Run("strips registry prefix before matching", func(t *testing.T) { v := viper.New() v.Set("server.registry_domain", "reg.example.com") v.Set("routes", map[string]interface{}{ "app.example.com": "reg.example.com/myapp:latest", }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) routes := svc.FindRoutesByImage(ctx, "myapp") assert.Len(t, routes, 1) assert.Equal(t, "app.example.com", routes[0].Domain) }) t.Run("strips registry prefix from input too", func(t *testing.T) { v := viper.New() v.Set("server.registry_domain", "reg.example.com") v.Set("routes", map[string]interface{}{ "app.example.com": "reg.example.com/myapp:latest", }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) routes := svc.FindRoutesByImage(ctx, "reg.example.com/myapp:latest") assert.Len(t, routes, 1) assert.Equal(t, "app.example.com", routes[0].Domain) }) t.Run("multiple routes for same image", func(t *testing.T) { v := viper.New() v.Set("routes", map[string]interface{}{ "app1.example.com": "myapp:latest", "app2.example.com": "myapp:latest", "other.example.com": "otherapp:latest", }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) routes := svc.FindRoutesByImage(ctx, "myapp") assert.Len(t, routes, 2) domains := []string{routes[0].Domain, routes[1].Domain} assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, domains) }) t.Run("case insensitive match", func(t *testing.T) { v := viper.New() v.Set("routes", map[string]interface{}{ "app.example.com": "MyApp:latest", }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) routes := svc.FindRoutesByImage(ctx, "myapp") assert.Len(t, routes, 1) }) t.Run("http prefix route sets HTTPS false", func(t *testing.T) { v := viper.New() v.Set("routes", map[string]interface{}{ "http://insecure.example.com": "myapp:latest", }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) routes := svc.FindRoutesByImage(ctx, "myapp") assert.Len(t, routes, 1) assert.Equal(t, "insecure.example.com", routes[0].Domain) assert.False(t, routes[0].HTTPS) }) t.Run("empty routes", func(t *testing.T) { v := viper.New() eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) routes := svc.FindRoutesByImage(ctx, "myapp") assert.Empty(t, routes) }) } func TestService_FindAttachmentTargetsByImage(t *testing.T) { t.Run("exact tagged matches", func(t *testing.T) { v := viper.New() v.Set("attachments", map[string]interface{}{ "app.example.com": []interface{}{"redis:latest", "postgres:18"}, }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) targets := svc.FindAttachmentTargetsByImage(ctx, "postgres:18") assert.Equal(t, []string{"app.example.com"}, targets) }) t.Run("bare name matches tagged attachments", func(t *testing.T) { v := viper.New() v.Set("attachments", map[string]interface{}{ "app.example.com": []interface{}{"postgres:18"}, }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) targets := svc.FindAttachmentTargetsByImage(ctx, "postgres") assert.Equal(t, []string{"app.example.com"}, targets) }) t.Run("registry qualified input is normalized", func(t *testing.T) { v := viper.New() v.Set("server.registry_domain", "reg.example.com") v.Set("attachments", map[string]interface{}{ "backend": []interface{}{"reg.example.com/postgres:18"}, }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) targets := svc.FindAttachmentTargetsByImage(ctx, "reg.example.com/postgres:18") assert.Equal(t, []string{"backend"}, targets) }) t.Run("shared attachment used by multiple targets", func(t *testing.T) { v := viper.New() v.Set("attachments", map[string]interface{}{ "app.example.com": []interface{}{"redis:latest"}, "backend": []interface{}{"redis:latest", "postgres:18"}, "worker": []interface{}{"rabbitmq:3"}, }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) targets := svc.FindAttachmentTargetsByImage(ctx, "redis") assert.ElementsMatch(t, []string{"app.example.com", "backend"}, targets) }) t.Run("no matches", func(t *testing.T) { v := viper.New() v.Set("attachments", map[string]interface{}{ "app.example.com": []interface{}{"redis:latest"}, }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() _ = svc.Load(ctx) targets := svc.FindAttachmentTargetsByImage(ctx, "postgres") assert.Empty(t, targets) }) } func TestSavePreservesAllConfigFields(t *testing.T) { t.Run("does not write predictable tmp path", func(t *testing.T) { dir := t.TempDir() configFile := filepath.Join(dir, "gordon.toml") err := os.WriteFile(configFile, []byte("[server]\nport = 8080\n\n[routes]\n\"app.example.com\" = \"myapp:latest\"\n"), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) err = v.ReadInConfig() require.NoError(t, err) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() err = svc.Load(ctx) require.NoError(t, err) maliciousTarget := filepath.Join(dir, "pwned.txt") predictableTmp := configFile + ".tmp" err = os.Symlink(maliciousTarget, predictableTmp) require.NoError(t, err) err = svc.Save(ctx) require.NoError(t, err) _, statErr := os.Stat(maliciousTarget) assert.True(t, os.IsNotExist(statErr), "symlink target should not exist - temp file should use unpredictable name") info, err := os.Lstat(predictableTmp) require.NoError(t, err) assert.NotZero(t, info.Mode()&os.ModeSymlink, "predictable .tmp path should still be the original symlink") }) t.Run("with defaults does not corrupt config", func(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") initialConfig := ` [server] port = 8088 registry_port = 5000 registry_domain = "reg.example.com" tls_enabled = true tls_port = 8443 data_dir = "/tmp/test-gordon" runtime = "auto" [auth] enabled = true secrets_backend = "pass" username = "testadmin" password_hash = "test/auth/password_hash" token_secret = "test/auth/token_secret" token_expiry = "720h" type = "password" [auto_route] enabled = true [network_isolation] enabled = true network_prefix = "gordon" dns_suffix = ".internal" [backups] enabled = true schedule = "daily" storage_dir = "~/.gordon/backups" [routes] "app.example.com" = "reg.example.com/myapp:latest" [attachments] "app.example.com" = ["postgres:15"] [network_groups] backend = ["app.example.com"] ` err := os.WriteFile(configFile, []byte(initialConfig), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) v.SetDefault("server.port", 8088) v.SetDefault("server.tls_port", 8443) v.SetDefault("auth.secrets_backend", "") v.SetDefault("auto_route.enabled", false) v.SetDefault("network_isolation.enabled", false) v.SetDefault("backups.enabled", false) err = v.ReadInConfig() require.NoError(t, err) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() err = svc.Load(ctx) require.NoError(t, err) err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"}) require.NoError(t, err) v2 := viper.New() v2.SetConfigFile(configFile) err = v2.ReadInConfig() require.NoError(t, err) assert.Equal(t, 8088, v2.GetInt("server.port")) assert.Equal(t, 8443, v2.GetInt("server.tls_port")) assert.Equal(t, "reg.example.com", v2.GetString("server.registry_domain")) assert.Equal(t, "auto", v2.GetString("server.runtime")) assert.Equal(t, "pass", v2.GetString("auth.secrets_backend")) assert.Equal(t, "testadmin", v2.GetString("auth.username")) assert.Equal(t, "test/auth/password_hash", v2.GetString("auth.password_hash")) assert.Equal(t, "test/auth/token_secret", v2.GetString("auth.token_secret")) assert.Equal(t, "password", v2.GetString("auth.type")) assert.True(t, v2.GetBool("auto_route.enabled")) assert.True(t, v2.GetBool("network_isolation.enabled")) assert.Equal(t, ".internal", v2.GetString("network_isolation.dns_suffix")) assert.True(t, v2.GetBool("backups.enabled")) assert.Equal(t, "~/.gordon/backups", v2.GetString("backups.storage_dir")) savedRoutes, err := loadCanonicalRoutes(v2.Get("routes")) require.NoError(t, err) routeCfg := requireRoute(t, savedRoutes, "new.example.com") assert.Equal(t, "newapp:latest", routeCfg.Image) assert.False(t, routeCfg.HTTPS) routeCfg = requireRoute(t, savedRoutes, "app.example.com") assert.Equal(t, "reg.example.com/myapp:latest", routeCfg.Image) assert.True(t, routeCfg.HTTPS) matches, err := filepath.Glob(configFile + ".bak.*") require.NoError(t, err) assert.NotEmpty(t, matches) }) t.Run("backup created before write", func(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") initialConfig := ` [server] port = 9999 [routes] "app.example.com" = "myapp:latest" ` err := os.WriteFile(configFile, []byte(initialConfig), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) err = v.ReadInConfig() require.NoError(t, err) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() err = svc.Load(ctx) require.NoError(t, err) err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"}) require.NoError(t, err) matches, err := filepath.Glob(configFile + ".bak.*") require.NoError(t, err) require.NotEmpty(t, matches) backupData, err := os.ReadFile(matches[0]) require.NoError(t, err) assert.Contains(t, string(backupData), "port = 9999") assert.NotContains(t, string(backupData), "new.example.com") }) t.Run("old backups are cleaned up keeping 5", func(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") initialConfig := ` [server] port = 9999 [routes] "app.example.com" = "myapp:latest" ` err := os.WriteFile(configFile, []byte(initialConfig), 0600) require.NoError(t, err) for i := 1000000000000; i < 1000000000007; i++ { backupPath := fmt.Sprintf("%s.bak.%d", configFile, i) err := os.WriteFile(backupPath, []byte("old backup"), 0600) require.NoError(t, err) } v := viper.New() v.SetConfigFile(configFile) err = v.ReadInConfig() require.NoError(t, err) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() err = svc.Load(ctx) require.NoError(t, err) err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"}) require.NoError(t, err) matches, err := filepath.Glob(configFile + ".bak.*") require.NoError(t, err) assert.Equal(t, 5, len(matches)) }) t.Run("AddRoute updates existing dotted domain route", func(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") initialConfig := ` [server] port = 8088 [auth] username = "testadmin" token_secret = "testsecret123" [routes] "pitlane.example.com" = "pitlane:latest" ` err := os.WriteFile(configFile, []byte(initialConfig), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) err = v.ReadInConfig() require.NoError(t, err) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() err = svc.Load(ctx) require.NoError(t, err) err = svc.AddRoute(ctx, domain.Route{ Domain: "pitlane.example.com", Image: "reg.example.com/pitlane:latest", }) require.NoError(t, err, "AddRoute should succeed when updating an existing dotted-domain route") routes := svc.GetRoutes(ctx) require.Len(t, routes, 1) assert.Equal(t, "pitlane.example.com", routes[0].Domain) assert.Equal(t, "reg.example.com/pitlane:latest", routes[0].Image) v2 := viper.New() v2.SetConfigFile(configFile) err = v2.ReadInConfig() require.NoError(t, err) assert.Equal(t, 8088, v2.GetInt("server.port")) assert.Equal(t, "testadmin", v2.GetString("auth.username")) assert.Equal(t, "testsecret123", v2.GetString("auth.token_secret")) }) t.Run("RemoveRoute removes dotted domain route", func(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") initialConfig := ` [server] port = 8088 [auth] username = "testadmin" token_secret = "testsecret123" [routes] "pitlane.example.com" = "reg.example.com/pitlane:latest" "other.example.com" = "reg.example.com/other:latest" ` err := os.WriteFile(configFile, []byte(initialConfig), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) err = v.ReadInConfig() require.NoError(t, err) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() err = svc.Load(ctx) require.NoError(t, err) err = svc.RemoveRoute(ctx, "pitlane.example.com") require.NoError(t, err, "RemoveRoute should succeed for a dotted-domain route") routes := svc.GetRoutes(ctx) require.Len(t, routes, 1) assert.Equal(t, "other.example.com", routes[0].Domain, "other routes should be preserved") assert.Equal(t, "reg.example.com/other:latest", routes[0].Image, "other routes should be preserved") v2 := viper.New() v2.SetConfigFile(configFile) err = v2.ReadInConfig() require.NoError(t, err) assert.Equal(t, 8088, v2.GetInt("server.port")) assert.Equal(t, "testadmin", v2.GetString("auth.username")) assert.Equal(t, "testsecret123", v2.GetString("auth.token_secret")) }) t.Run("network_groups are persisted to disk", func(t *testing.T) { // Setup: config file with auth, server, and network_groups tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") initialConfig := ` [server] port = 9999 [auth] username = "admin" token_secret = "supersecretvalue" [routes] "app.example.com" = "myapp:latest" [network_groups] frontend = ["app1.example.com", "app2.example.com"] ` err := os.WriteFile(configFile, []byte(initialConfig), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) err = v.ReadInConfig() require.NoError(t, err) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() err = svc.Load(ctx) require.NoError(t, err) // Verify network_groups loaded correctly in memory groups := svc.GetNetworkGroups() require.Len(t, groups, 1) require.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, groups["frontend"]) // Trigger a Save via AddRoute (which calls Save internally) err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"}) require.NoError(t, err) // Re-read the file to verify network_groups was persisted v2 := viper.New() v2.SetConfigFile(configFile) err = v2.ReadInConfig() require.NoError(t, err) // Verify network_groups is still intact and correct after save savedGroups := loadStringArrayMap(v2.Get("network_groups")) assert.Len(t, savedGroups, 1, "network_groups should still be present in saved config") assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, savedGroups["frontend"]) // Verify auth fields are preserved assert.Equal(t, "admin", v2.GetString("auth.username")) assert.Equal(t, "supersecretvalue", v2.GetString("auth.token_secret")) // Verify server fields are preserved assert.Equal(t, 9999, v2.GetInt("server.port")) }) // Regression test: external_routes keys contain dots (e.g. "reg.example.com"). // Without explicitly calling viper.Set("external_routes", ...) before WriteConfig, // viper splits dotted keys into nested subtrees, corrupting the data on re-read. t.Run("external_routes with dotted domain keys are not corrupted on Save", func(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") initialConfig := ` [server] port = 8080 [routes] "app.example.com" = "myapp:latest" [external_routes] "reg.example.com" = "localhost:5000" ` err := os.WriteFile(configFile, []byte(initialConfig), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) err = v.ReadInConfig() require.NoError(t, err) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() err = svc.Load(ctx) require.NoError(t, err) // Verify external_routes loaded correctly in memory extRoutes := svc.GetExternalRoutes() require.Equal(t, "localhost:5000", extRoutes["reg.example.com"]) // Trigger Save err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"}) require.NoError(t, err) // Re-read the file with a fresh viper instance v2 := viper.New() v2.SetConfigFile(configFile) err = v2.ReadInConfig() require.NoError(t, err) // external_routes must survive Save with correct flat map structure. // Without the fix, viper splits "reg.example.com" into nested TOML subtrees // and GetString("external_routes.reg\\.example\\.com") returns empty string. savedExtRoutes := loadStringMap(v2.Get("external_routes")) assert.Equal(t, "localhost:5000", savedExtRoutes["reg.example.com"], "external_routes must not be corrupted by viper's dot-path splitting on WriteConfig") }) t.Run("network_groups added in memory are written to disk on Save", func(t *testing.T) { // Setup: config file WITHOUT network_groups tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") initialConfig := ` [server] port = 8080 [routes] "app.example.com" = "myapp:latest" ` err := os.WriteFile(configFile, []byte(initialConfig), 0600) require.NoError(t, err) v := viper.New() v.SetConfigFile(configFile) err = v.ReadInConfig() require.NoError(t, err) // Inject network_groups into viper as if they were loaded from some other source // This simulates a case where network_groups exist in memory but not on disk v.Set("network_groups", map[string]interface{}{ "backend": []interface{}{"api.example.com"}, }) eventBus := mocks.NewMockEventPublisher(t) svc := NewService(v, eventBus) ctx := testContext() err = svc.Load(ctx) require.NoError(t, err) // Verify network_groups is loaded in memory groups := svc.GetNetworkGroups() require.Len(t, groups, 1) // Trigger Save err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"}) require.NoError(t, err) // Re-read the file v2 := viper.New() v2.SetConfigFile(configFile) err = v2.ReadInConfig() require.NoError(t, err) // network_groups must be present in the saved file savedGroups := loadStringArrayMap(v2.Get("network_groups")) assert.Len(t, savedGroups, 1, "network_groups added in memory must be written on Save") assert.ElementsMatch(t, []string{"api.example.com"}, savedGroups["backend"]) }) } func TestExtractDomainFromImageName(t *testing.T) { tests := []struct { name string imageName string expectedDom string expectedFound bool }{ { name: "domain like image", imageName: "myapp.example.com:latest", expectedDom: "myapp.example.com", expectedFound: true, }, { name: "domain like image without tag", imageName: "api.backend.io", expectedDom: "api.backend.io", expectedFound: true, }, { name: "simple image name", imageName: "nginx:latest", expectedDom: "", expectedFound: false, }, { name: "registry path", imageName: "gcr.io/project/image:tag", expectedDom: "gcr.io/project/image", expectedFound: true, }, { name: "leading dot", imageName: ".invalid:latest", expectedDom: "", expectedFound: false, }, { name: "trailing dot", imageName: "invalid.:latest", expectedDom: "", expectedFound: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { domain, found := ExtractDomainFromImageName(tt.imageName) assert.Equal(t, tt.expectedDom, domain) assert.Equal(t, tt.expectedFound, found) }) } } func TestLoadStringMap(t *testing.T) { tests := []struct { name string input any expected map[string]string }{ { name: "nil input", input: nil, expected: map[string]string{}, }, { name: "valid map", input: map[string]any{ "key1": "value1", "key2": "value2", }, expected: map[string]string{ "key1": "value1", "key2": "value2", }, }, { name: "invalid type", input: "not a map", expected: map[string]string{}, }, { name: "mixed values", input: map[string]any{ "string": "value", "int": 123, }, expected: map[string]string{ "string": "value", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := loadStringMap(tt.input) assert.Equal(t, tt.expected, result) }) } } func TestLoadStringArrayMap(t *testing.T) { tests := []struct { name string input any expected map[string][]string }{ { name: "nil input", input: nil, expected: map[string][]string{}, }, { name: "valid map", input: map[string]any{ "group1": []any{"a", "b"}, "group2": []any{"c", "d", "e"}, }, expected: map[string][]string{ "group1": {"a", "b"}, "group2": {"c", "d", "e"}, }, }, { name: "invalid type", input: "not a map", expected: map[string][]string{}, }, { name: "non-array value", input: map[string]any{ "array": []any{"a", "b"}, "string": "not an array", }, expected: map[string][]string{ "array": {"a", "b"}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := loadStringArrayMap(tt.input) assert.Equal(t, tt.expected, result) }) } }