fix(config): match bare image names in FindRoutesByImage and return 200 for empty results
- commit
- 3554564c8174a8042857f85fe0eca8c29745ebcf
- author
- bnema · · 2026-02-09 20:50 UTC
- parent
- 29899f1
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +249 -5
M internal/adapters/in/http/admin/handler.go +0 -4
| @@ -476,10 +476,6 @@ func (h *Handler) handleRoutesByImage(w http.ResponseWriter, r *http.Request, pa | |||
| 476 | 476 | } | |
| 477 | 477 | ||
| 478 | 478 | routes := h.configSvc.FindRoutesByImage(ctx, imageName) | |
| 479 | - | if len(routes) == 0 { | |
| 480 | - | h.sendError(w, http.StatusNotFound, fmt.Sprintf("no routes found for image '%s'", imageName)) | |
| 481 | - | return | |
| 482 | - | } | |
| 483 | 479 | ||
| 484 | 480 | response := make([]routeResponse, 0, len(routes)) | |
| 485 | 481 | for _, route := range routes { | |
| @@ -476,10 +476,6 @@ func (h *Handler) handleRoutesByImage(w http.ResponseWriter, r *http.Request, pa | |||||
| 476 | } | 476 | } | ||
| 477 | 477 | ||||
| 478 | routes := h.configSvc.FindRoutesByImage(ctx, imageName) | 478 | routes := h.configSvc.FindRoutesByImage(ctx, imageName) | ||
| 479 | - | if len(routes) == 0 { | |||
| 480 | - | h.sendError(w, http.StatusNotFound, fmt.Sprintf("no routes found for image '%s'", imageName)) | |||
| 481 | - | return | |||
| 482 | - | } | |||
| 483 | 479 | ||||
| 484 | response := make([]routeResponse, 0, len(routes)) | 480 | response := make([]routeResponse, 0, len(routes)) | ||
| 485 | for _, route := range routes { | 481 | for _, route := range routes { | ||
M internal/usecase/config/service.go +22 -1
| @@ -228,17 +228,30 @@ func (s *Service) GetRoute(_ context.Context, domainName string) (*domain.Route, | |||
| 228 | 228 | ||
| 229 | 229 | // FindRoutesByImage returns all routes whose image matches the given image name. | |
| 230 | 230 | // Image names are normalized by stripping the registry domain prefix before comparison. | |
| 231 | + | // When the input has no tag, only the name portion is compared (i.e. "myapp" matches "myapp:latest"). | |
| 231 | 232 | func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domain.Route { | |
| 232 | 233 | s.mu.RLock() | |
| 233 | 234 | defer s.mu.RUnlock() | |
| 234 | 235 | ||
| 235 | 236 | registryDomain := s.config.RegistryDomain | |
| 236 | 237 | normalizedImage := NormalizeRegistryImage(imageName, registryDomain) | |
| 238 | + | inputName, inputHasTag := splitImageNameTag(normalizedImage) | |
| 237 | 239 | ||
| 238 | 240 | var routes []domain.Route | |
| 239 | 241 | for domainName, image := range s.config.Routes { | |
| 240 | 242 | normalizedRouteImage := NormalizeRegistryImage(image, registryDomain) | |
| 241 | - | if strings.EqualFold(normalizedRouteImage, normalizedImage) { | |
| 243 | + | ||
| 244 | + | match := false | |
| 245 | + | if inputHasTag { | |
| 246 | + | // Exact match when the caller provided a tag (e.g. event handler). | |
| 247 | + | match = strings.EqualFold(normalizedRouteImage, normalizedImage) | |
| 248 | + | } else { | |
| 249 | + | // Name-only match when no tag was provided (e.g. CLI push). | |
| 250 | + | routeName, _ := splitImageNameTag(normalizedRouteImage) | |
| 251 | + | match = strings.EqualFold(routeName, inputName) | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | if match { | |
| 242 | 255 | route := domain.Route{ | |
| 243 | 256 | Image: image, | |
| 244 | 257 | HTTPS: true, | |
| @@ -256,6 +269,14 @@ func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domai | |||
| 256 | 269 | return routes | |
| 257 | 270 | } | |
| 258 | 271 | ||
| 272 | + | // splitImageNameTag splits "name:tag" into ("name", true) or ("name", false) when no tag is present. | |
| 273 | + | func splitImageNameTag(image string) (name string, hasTag bool) { | |
| 274 | + | if idx := strings.LastIndex(image, ":"); idx != -1 { | |
| 275 | + | return image[:idx], true | |
| 276 | + | } | |
| 277 | + | return image, false | |
| 278 | + | } | |
| 279 | + | ||
| 259 | 280 | // NormalizeRegistryImage strips the registry domain prefix from an image name for comparison. | |
| 260 | 281 | func NormalizeRegistryImage(imageName, registryDomain string) string { | |
| 261 | 282 | registryDomain = strings.TrimSuffix(registryDomain, "/") | |
| @@ -228,17 +228,30 @@ func (s *Service) GetRoute(_ context.Context, domainName string) (*domain.Route, | |||||
| 228 | 228 | ||||
| 229 | // FindRoutesByImage returns all routes whose image matches the given image name. | 229 | // FindRoutesByImage returns all routes whose image matches the given image name. | ||
| 230 | // Image names are normalized by stripping the registry domain prefix before comparison. | 230 | // Image names are normalized by stripping the registry domain prefix before comparison. | ||
| 231 | + | // When the input has no tag, only the name portion is compared (i.e. "myapp" matches "myapp:latest"). | |||
| 231 | func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domain.Route { | 232 | func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domain.Route { | ||
| 232 | s.mu.RLock() | 233 | s.mu.RLock() | ||
| 233 | defer s.mu.RUnlock() | 234 | defer s.mu.RUnlock() | ||
| 234 | 235 | ||||
| 235 | registryDomain := s.config.RegistryDomain | 236 | registryDomain := s.config.RegistryDomain | ||
| 236 | normalizedImage := NormalizeRegistryImage(imageName, registryDomain) | 237 | normalizedImage := NormalizeRegistryImage(imageName, registryDomain) | ||
| 238 | + | inputName, inputHasTag := splitImageNameTag(normalizedImage) | |||
| 237 | 239 | ||||
| 238 | var routes []domain.Route | 240 | var routes []domain.Route | ||
| 239 | for domainName, image := range s.config.Routes { | 241 | for domainName, image := range s.config.Routes { | ||
| 240 | normalizedRouteImage := NormalizeRegistryImage(image, registryDomain) | 242 | normalizedRouteImage := NormalizeRegistryImage(image, registryDomain) | ||
| 241 | - | if strings.EqualFold(normalizedRouteImage, normalizedImage) { | 243 | + | |
| 244 | + | match := false | |||
| 245 | + | if inputHasTag { | |||
| 246 | + | // Exact match when the caller provided a tag (e.g. event handler). | |||
| 247 | + | match = strings.EqualFold(normalizedRouteImage, normalizedImage) | |||
| 248 | + | } else { | |||
| 249 | + | // Name-only match when no tag was provided (e.g. CLI push). | |||
| 250 | + | routeName, _ := splitImageNameTag(normalizedRouteImage) | |||
| 251 | + | match = strings.EqualFold(routeName, inputName) | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | if match { | |||
| 242 | route := domain.Route{ | 255 | route := domain.Route{ | ||
| 243 | Image: image, | 256 | Image: image, | ||
| 244 | HTTPS: true, | 257 | HTTPS: true, | ||
| @@ -256,6 +269,14 @@ func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domai | |||||
| 256 | return routes | 269 | return routes | ||
| 257 | } | 270 | } | ||
| 258 | 271 | ||||
| 272 | + | // splitImageNameTag splits "name:tag" into ("name", true) or ("name", false) when no tag is present. | |||
| 273 | + | func splitImageNameTag(image string) (name string, hasTag bool) { | |||
| 274 | + | if idx := strings.LastIndex(image, ":"); idx != -1 { | |||
| 275 | + | return image[:idx], true | |||
| 276 | + | } | |||
| 277 | + | return image, false | |||
| 278 | + | } | |||
| 279 | + | ||||
| 259 | // NormalizeRegistryImage strips the registry domain prefix from an image name for comparison. | 280 | // NormalizeRegistryImage strips the registry domain prefix from an image name for comparison. | ||
| 260 | func NormalizeRegistryImage(imageName, registryDomain string) string { | 281 | func NormalizeRegistryImage(imageName, registryDomain string) string { | ||
| 261 | registryDomain = strings.TrimSuffix(registryDomain, "/") | 282 | registryDomain = strings.TrimSuffix(registryDomain, "/") | ||
M internal/usecase/config/service_test.go +227 -0
| @@ -864,6 +864,233 @@ func TestService_GetExternalRoutes_Empty(t *testing.T) { | |||
| 864 | 864 | assert.Empty(t, routes) | |
| 865 | 865 | } | |
| 866 | 866 | ||
| 867 | + | func TestSplitImageNameTag(t *testing.T) { | |
| 868 | + | tests := []struct { | |
| 869 | + | name string | |
| 870 | + | image string | |
| 871 | + | wantName string | |
| 872 | + | wantHasTag bool | |
| 873 | + | }{ | |
| 874 | + | {"with tag", "myapp:latest", "myapp", true}, | |
| 875 | + | {"with version tag", "myapp:v1.2.0", "myapp", true}, | |
| 876 | + | {"no tag", "myapp", "myapp", false}, | |
| 877 | + | {"empty string", "", "", false}, | |
| 878 | + | {"tag only colon", "myapp:", "myapp", true}, | |
| 879 | + | {"registry prefixed with tag", "reg.example.com/myapp:latest", "reg.example.com/myapp", true}, | |
| 880 | + | {"registry prefixed no tag", "reg.example.com/myapp", "reg.example.com/myapp", false}, | |
| 881 | + | } | |
| 882 | + | ||
| 883 | + | for _, tt := range tests { | |
| 884 | + | t.Run(tt.name, func(t *testing.T) { | |
| 885 | + | name, hasTag := splitImageNameTag(tt.image) | |
| 886 | + | assert.Equal(t, tt.wantName, name) | |
| 887 | + | assert.Equal(t, tt.wantHasTag, hasTag) | |
| 888 | + | }) | |
| 889 | + | } | |
| 890 | + | } | |
| 891 | + | ||
| 892 | + | func TestNormalizeRegistryImage(t *testing.T) { | |
| 893 | + | tests := []struct { | |
| 894 | + | name string | |
| 895 | + | imageName string | |
| 896 | + | registryDomain string | |
| 897 | + | expected string | |
| 898 | + | }{ | |
| 899 | + | {"strips registry prefix", "reg.example.com/myapp:latest", "reg.example.com", "myapp:latest"}, | |
| 900 | + | {"no prefix to strip", "myapp:latest", "reg.example.com", "myapp:latest"}, | |
| 901 | + | {"empty registry domain", "myapp:latest", "", "myapp:latest"}, | |
| 902 | + | {"registry with trailing slash", "reg.example.com/myapp:latest", "reg.example.com/", "myapp:latest"}, | |
| 903 | + | {"partial match is not stripped", "reg.example.com.evil/myapp:latest", "reg.example.com", "reg.example.com.evil/myapp:latest"}, | |
| 904 | + | {"bare name no registry", "myapp", "reg.example.com", "myapp"}, | |
| 905 | + | {"exact registry without image", "reg.example.com/", "reg.example.com", ""}, | |
| 906 | + | } | |
| 907 | + | ||
| 908 | + | for _, tt := range tests { | |
| 909 | + | t.Run(tt.name, func(t *testing.T) { | |
| 910 | + | result := NormalizeRegistryImage(tt.imageName, tt.registryDomain) | |
| 911 | + | assert.Equal(t, tt.expected, result) | |
| 912 | + | }) | |
| 913 | + | } | |
| 914 | + | } | |
| 915 | + | ||
| 916 | + | func TestService_FindRoutesByImage(t *testing.T) { | |
| 917 | + | t.Run("exact match with tag", func(t *testing.T) { | |
| 918 | + | v := viper.New() | |
| 919 | + | v.Set("routes", map[string]interface{}{ | |
| 920 | + | "app.example.com": "myapp:latest", | |
| 921 | + | }) | |
| 922 | + | ||
| 923 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 924 | + | svc := NewService(v, eventBus) | |
| 925 | + | ctx := testContext() | |
| 926 | + | _ = svc.Load(ctx) | |
| 927 | + | ||
| 928 | + | routes := svc.FindRoutesByImage(ctx, "myapp:latest") | |
| 929 | + | assert.Len(t, routes, 1) | |
| 930 | + | assert.Equal(t, "app.example.com", routes[0].Domain) | |
| 931 | + | assert.Equal(t, "myapp:latest", routes[0].Image) | |
| 932 | + | }) | |
| 933 | + | ||
| 934 | + | t.Run("bare name matches route with tag", func(t *testing.T) { | |
| 935 | + | v := viper.New() | |
| 936 | + | v.Set("routes", map[string]interface{}{ | |
| 937 | + | "app.example.com": "myapp:latest", | |
| 938 | + | }) | |
| 939 | + | ||
| 940 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 941 | + | svc := NewService(v, eventBus) | |
| 942 | + | ctx := testContext() | |
| 943 | + | _ = svc.Load(ctx) | |
| 944 | + | ||
| 945 | + | routes := svc.FindRoutesByImage(ctx, "myapp") | |
| 946 | + | assert.Len(t, routes, 1) | |
| 947 | + | assert.Equal(t, "app.example.com", routes[0].Domain) | |
| 948 | + | }) | |
| 949 | + | ||
| 950 | + | t.Run("bare name matches route with version tag", func(t *testing.T) { | |
| 951 | + | v := viper.New() | |
| 952 | + | v.Set("routes", map[string]interface{}{ | |
| 953 | + | "app.example.com": "myapp:v2.0.0", | |
| 954 | + | }) | |
| 955 | + | ||
| 956 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 957 | + | svc := NewService(v, eventBus) | |
| 958 | + | ctx := testContext() | |
| 959 | + | _ = svc.Load(ctx) | |
| 960 | + | ||
| 961 | + | routes := svc.FindRoutesByImage(ctx, "myapp") | |
| 962 | + | assert.Len(t, routes, 1) | |
| 963 | + | assert.Equal(t, "app.example.com", routes[0].Domain) | |
| 964 | + | }) | |
| 965 | + | ||
| 966 | + | t.Run("tag mismatch does not match", func(t *testing.T) { | |
| 967 | + | v := viper.New() | |
| 968 | + | v.Set("routes", map[string]interface{}{ | |
| 969 | + | "app.example.com": "myapp:v1", | |
| 970 | + | }) | |
| 971 | + | ||
| 972 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 973 | + | svc := NewService(v, eventBus) | |
| 974 | + | ctx := testContext() | |
| 975 | + | _ = svc.Load(ctx) | |
| 976 | + | ||
| 977 | + | routes := svc.FindRoutesByImage(ctx, "myapp:v2") | |
| 978 | + | assert.Empty(t, routes) | |
| 979 | + | }) | |
| 980 | + | ||
| 981 | + | t.Run("no match", func(t *testing.T) { | |
| 982 | + | v := viper.New() | |
| 983 | + | v.Set("routes", map[string]interface{}{ | |
| 984 | + | "app.example.com": "myapp:latest", | |
| 985 | + | }) | |
| 986 | + | ||
| 987 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 988 | + | svc := NewService(v, eventBus) | |
| 989 | + | ctx := testContext() | |
| 990 | + | _ = svc.Load(ctx) | |
| 991 | + | ||
| 992 | + | routes := svc.FindRoutesByImage(ctx, "otherapp") | |
| 993 | + | assert.Empty(t, routes) | |
| 994 | + | }) | |
| 995 | + | ||
| 996 | + | t.Run("strips registry prefix before matching", func(t *testing.T) { | |
| 997 | + | v := viper.New() | |
| 998 | + | v.Set("server.registry_domain", "reg.example.com") | |
| 999 | + | v.Set("routes", map[string]interface{}{ | |
| 1000 | + | "app.example.com": "reg.example.com/myapp:latest", | |
| 1001 | + | }) | |
| 1002 | + | ||
| 1003 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1004 | + | svc := NewService(v, eventBus) | |
| 1005 | + | ctx := testContext() | |
| 1006 | + | _ = svc.Load(ctx) | |
| 1007 | + | ||
| 1008 | + | routes := svc.FindRoutesByImage(ctx, "myapp") | |
| 1009 | + | assert.Len(t, routes, 1) | |
| 1010 | + | assert.Equal(t, "app.example.com", routes[0].Domain) | |
| 1011 | + | }) | |
| 1012 | + | ||
| 1013 | + | t.Run("strips registry prefix from input too", func(t *testing.T) { | |
| 1014 | + | v := viper.New() | |
| 1015 | + | v.Set("server.registry_domain", "reg.example.com") | |
| 1016 | + | v.Set("routes", map[string]interface{}{ | |
| 1017 | + | "app.example.com": "reg.example.com/myapp:latest", | |
| 1018 | + | }) | |
| 1019 | + | ||
| 1020 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1021 | + | svc := NewService(v, eventBus) | |
| 1022 | + | ctx := testContext() | |
| 1023 | + | _ = svc.Load(ctx) | |
| 1024 | + | ||
| 1025 | + | routes := svc.FindRoutesByImage(ctx, "reg.example.com/myapp:latest") | |
| 1026 | + | assert.Len(t, routes, 1) | |
| 1027 | + | assert.Equal(t, "app.example.com", routes[0].Domain) | |
| 1028 | + | }) | |
| 1029 | + | ||
| 1030 | + | t.Run("multiple routes for same image", func(t *testing.T) { | |
| 1031 | + | v := viper.New() | |
| 1032 | + | v.Set("routes", map[string]interface{}{ | |
| 1033 | + | "app1.example.com": "myapp:latest", | |
| 1034 | + | "app2.example.com": "myapp:latest", | |
| 1035 | + | "other.example.com": "otherapp:latest", | |
| 1036 | + | }) | |
| 1037 | + | ||
| 1038 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1039 | + | svc := NewService(v, eventBus) | |
| 1040 | + | ctx := testContext() | |
| 1041 | + | _ = svc.Load(ctx) | |
| 1042 | + | ||
| 1043 | + | routes := svc.FindRoutesByImage(ctx, "myapp") | |
| 1044 | + | assert.Len(t, routes, 2) | |
| 1045 | + | ||
| 1046 | + | domains := []string{routes[0].Domain, routes[1].Domain} | |
| 1047 | + | assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, domains) | |
| 1048 | + | }) | |
| 1049 | + | ||
| 1050 | + | t.Run("case insensitive match", func(t *testing.T) { | |
| 1051 | + | v := viper.New() | |
| 1052 | + | v.Set("routes", map[string]interface{}{ | |
| 1053 | + | "app.example.com": "MyApp:latest", | |
| 1054 | + | }) | |
| 1055 | + | ||
| 1056 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1057 | + | svc := NewService(v, eventBus) | |
| 1058 | + | ctx := testContext() | |
| 1059 | + | _ = svc.Load(ctx) | |
| 1060 | + | ||
| 1061 | + | routes := svc.FindRoutesByImage(ctx, "myapp") | |
| 1062 | + | assert.Len(t, routes, 1) | |
| 1063 | + | }) | |
| 1064 | + | ||
| 1065 | + | t.Run("http prefix route sets HTTPS false", func(t *testing.T) { | |
| 1066 | + | v := viper.New() | |
| 1067 | + | v.Set("routes", map[string]interface{}{ | |
| 1068 | + | "http://insecure.example.com": "myapp:latest", | |
| 1069 | + | }) | |
| 1070 | + | ||
| 1071 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1072 | + | svc := NewService(v, eventBus) | |
| 1073 | + | ctx := testContext() | |
| 1074 | + | _ = svc.Load(ctx) | |
| 1075 | + | ||
| 1076 | + | routes := svc.FindRoutesByImage(ctx, "myapp") | |
| 1077 | + | assert.Len(t, routes, 1) | |
| 1078 | + | assert.Equal(t, "insecure.example.com", routes[0].Domain) | |
| 1079 | + | assert.False(t, routes[0].HTTPS) | |
| 1080 | + | }) | |
| 1081 | + | ||
| 1082 | + | t.Run("empty routes", func(t *testing.T) { | |
| 1083 | + | v := viper.New() | |
| 1084 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1085 | + | svc := NewService(v, eventBus) | |
| 1086 | + | ctx := testContext() | |
| 1087 | + | _ = svc.Load(ctx) | |
| 1088 | + | ||
| 1089 | + | routes := svc.FindRoutesByImage(ctx, "myapp") | |
| 1090 | + | assert.Empty(t, routes) | |
| 1091 | + | }) | |
| 1092 | + | } | |
| 1093 | + | ||
| 867 | 1094 | func TestExtractDomainFromImageName(t *testing.T) { | |
| 868 | 1095 | tests := []struct { | |
| 869 | 1096 | name string | |
| @@ -864,6 +864,233 @@ func TestService_GetExternalRoutes_Empty(t *testing.T) { | |||||
| 864 | assert.Empty(t, routes) | 864 | assert.Empty(t, routes) | ||
| 865 | } | 865 | } | ||
| 866 | 866 | ||||
| 867 | + | func TestSplitImageNameTag(t *testing.T) { | |||
| 868 | + | tests := []struct { | |||
| 869 | + | name string | |||
| 870 | + | image string | |||
| 871 | + | wantName string | |||
| 872 | + | wantHasTag bool | |||
| 873 | + | }{ | |||
| 874 | + | {"with tag", "myapp:latest", "myapp", true}, | |||
| 875 | + | {"with version tag", "myapp:v1.2.0", "myapp", true}, | |||
| 876 | + | {"no tag", "myapp", "myapp", false}, | |||
| 877 | + | {"empty string", "", "", false}, | |||
| 878 | + | {"tag only colon", "myapp:", "myapp", true}, | |||
| 879 | + | {"registry prefixed with tag", "reg.example.com/myapp:latest", "reg.example.com/myapp", true}, | |||
| 880 | + | {"registry prefixed no tag", "reg.example.com/myapp", "reg.example.com/myapp", false}, | |||
| 881 | + | } | |||
| 882 | + | ||||
| 883 | + | for _, tt := range tests { | |||
| 884 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 885 | + | name, hasTag := splitImageNameTag(tt.image) | |||
| 886 | + | assert.Equal(t, tt.wantName, name) | |||
| 887 | + | assert.Equal(t, tt.wantHasTag, hasTag) | |||
| 888 | + | }) | |||
| 889 | + | } | |||
| 890 | + | } | |||
| 891 | + | ||||
| 892 | + | func TestNormalizeRegistryImage(t *testing.T) { | |||
| 893 | + | tests := []struct { | |||
| 894 | + | name string | |||
| 895 | + | imageName string | |||
| 896 | + | registryDomain string | |||
| 897 | + | expected string | |||
| 898 | + | }{ | |||
| 899 | + | {"strips registry prefix", "reg.example.com/myapp:latest", "reg.example.com", "myapp:latest"}, | |||
| 900 | + | {"no prefix to strip", "myapp:latest", "reg.example.com", "myapp:latest"}, | |||
| 901 | + | {"empty registry domain", "myapp:latest", "", "myapp:latest"}, | |||
| 902 | + | {"registry with trailing slash", "reg.example.com/myapp:latest", "reg.example.com/", "myapp:latest"}, | |||
| 903 | + | {"partial match is not stripped", "reg.example.com.evil/myapp:latest", "reg.example.com", "reg.example.com.evil/myapp:latest"}, | |||
| 904 | + | {"bare name no registry", "myapp", "reg.example.com", "myapp"}, | |||
| 905 | + | {"exact registry without image", "reg.example.com/", "reg.example.com", ""}, | |||
| 906 | + | } | |||
| 907 | + | ||||
| 908 | + | for _, tt := range tests { | |||
| 909 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 910 | + | result := NormalizeRegistryImage(tt.imageName, tt.registryDomain) | |||
| 911 | + | assert.Equal(t, tt.expected, result) | |||
| 912 | + | }) | |||
| 913 | + | } | |||
| 914 | + | } | |||
| 915 | + | ||||
| 916 | + | func TestService_FindRoutesByImage(t *testing.T) { | |||
| 917 | + | t.Run("exact match with tag", func(t *testing.T) { | |||
| 918 | + | v := viper.New() | |||
| 919 | + | v.Set("routes", map[string]interface{}{ | |||
| 920 | + | "app.example.com": "myapp:latest", | |||
| 921 | + | }) | |||
| 922 | + | ||||
| 923 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 924 | + | svc := NewService(v, eventBus) | |||
| 925 | + | ctx := testContext() | |||
| 926 | + | _ = svc.Load(ctx) | |||
| 927 | + | ||||
| 928 | + | routes := svc.FindRoutesByImage(ctx, "myapp:latest") | |||
| 929 | + | assert.Len(t, routes, 1) | |||
| 930 | + | assert.Equal(t, "app.example.com", routes[0].Domain) | |||
| 931 | + | assert.Equal(t, "myapp:latest", routes[0].Image) | |||
| 932 | + | }) | |||
| 933 | + | ||||
| 934 | + | t.Run("bare name matches route with tag", func(t *testing.T) { | |||
| 935 | + | v := viper.New() | |||
| 936 | + | v.Set("routes", map[string]interface{}{ | |||
| 937 | + | "app.example.com": "myapp:latest", | |||
| 938 | + | }) | |||
| 939 | + | ||||
| 940 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 941 | + | svc := NewService(v, eventBus) | |||
| 942 | + | ctx := testContext() | |||
| 943 | + | _ = svc.Load(ctx) | |||
| 944 | + | ||||
| 945 | + | routes := svc.FindRoutesByImage(ctx, "myapp") | |||
| 946 | + | assert.Len(t, routes, 1) | |||
| 947 | + | assert.Equal(t, "app.example.com", routes[0].Domain) | |||
| 948 | + | }) | |||
| 949 | + | ||||
| 950 | + | t.Run("bare name matches route with version tag", func(t *testing.T) { | |||
| 951 | + | v := viper.New() | |||
| 952 | + | v.Set("routes", map[string]interface{}{ | |||
| 953 | + | "app.example.com": "myapp:v2.0.0", | |||
| 954 | + | }) | |||
| 955 | + | ||||
| 956 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 957 | + | svc := NewService(v, eventBus) | |||
| 958 | + | ctx := testContext() | |||
| 959 | + | _ = svc.Load(ctx) | |||
| 960 | + | ||||
| 961 | + | routes := svc.FindRoutesByImage(ctx, "myapp") | |||
| 962 | + | assert.Len(t, routes, 1) | |||
| 963 | + | assert.Equal(t, "app.example.com", routes[0].Domain) | |||
| 964 | + | }) | |||
| 965 | + | ||||
| 966 | + | t.Run("tag mismatch does not match", func(t *testing.T) { | |||
| 967 | + | v := viper.New() | |||
| 968 | + | v.Set("routes", map[string]interface{}{ | |||
| 969 | + | "app.example.com": "myapp:v1", | |||
| 970 | + | }) | |||
| 971 | + | ||||
| 972 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 973 | + | svc := NewService(v, eventBus) | |||
| 974 | + | ctx := testContext() | |||
| 975 | + | _ = svc.Load(ctx) | |||
| 976 | + | ||||
| 977 | + | routes := svc.FindRoutesByImage(ctx, "myapp:v2") | |||
| 978 | + | assert.Empty(t, routes) | |||
| 979 | + | }) | |||
| 980 | + | ||||
| 981 | + | t.Run("no match", func(t *testing.T) { | |||
| 982 | + | v := viper.New() | |||
| 983 | + | v.Set("routes", map[string]interface{}{ | |||
| 984 | + | "app.example.com": "myapp:latest", | |||
| 985 | + | }) | |||
| 986 | + | ||||
| 987 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 988 | + | svc := NewService(v, eventBus) | |||
| 989 | + | ctx := testContext() | |||
| 990 | + | _ = svc.Load(ctx) | |||
| 991 | + | ||||
| 992 | + | routes := svc.FindRoutesByImage(ctx, "otherapp") | |||
| 993 | + | assert.Empty(t, routes) | |||
| 994 | + | }) | |||
| 995 | + | ||||
| 996 | + | t.Run("strips registry prefix before matching", func(t *testing.T) { | |||
| 997 | + | v := viper.New() | |||
| 998 | + | v.Set("server.registry_domain", "reg.example.com") | |||
| 999 | + | v.Set("routes", map[string]interface{}{ | |||
| 1000 | + | "app.example.com": "reg.example.com/myapp:latest", | |||
| 1001 | + | }) | |||
| 1002 | + | ||||
| 1003 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1004 | + | svc := NewService(v, eventBus) | |||
| 1005 | + | ctx := testContext() | |||
| 1006 | + | _ = svc.Load(ctx) | |||
| 1007 | + | ||||
| 1008 | + | routes := svc.FindRoutesByImage(ctx, "myapp") | |||
| 1009 | + | assert.Len(t, routes, 1) | |||
| 1010 | + | assert.Equal(t, "app.example.com", routes[0].Domain) | |||
| 1011 | + | }) | |||
| 1012 | + | ||||
| 1013 | + | t.Run("strips registry prefix from input too", func(t *testing.T) { | |||
| 1014 | + | v := viper.New() | |||
| 1015 | + | v.Set("server.registry_domain", "reg.example.com") | |||
| 1016 | + | v.Set("routes", map[string]interface{}{ | |||
| 1017 | + | "app.example.com": "reg.example.com/myapp:latest", | |||
| 1018 | + | }) | |||
| 1019 | + | ||||
| 1020 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1021 | + | svc := NewService(v, eventBus) | |||
| 1022 | + | ctx := testContext() | |||
| 1023 | + | _ = svc.Load(ctx) | |||
| 1024 | + | ||||
| 1025 | + | routes := svc.FindRoutesByImage(ctx, "reg.example.com/myapp:latest") | |||
| 1026 | + | assert.Len(t, routes, 1) | |||
| 1027 | + | assert.Equal(t, "app.example.com", routes[0].Domain) | |||
| 1028 | + | }) | |||
| 1029 | + | ||||
| 1030 | + | t.Run("multiple routes for same image", func(t *testing.T) { | |||
| 1031 | + | v := viper.New() | |||
| 1032 | + | v.Set("routes", map[string]interface{}{ | |||
| 1033 | + | "app1.example.com": "myapp:latest", | |||
| 1034 | + | "app2.example.com": "myapp:latest", | |||
| 1035 | + | "other.example.com": "otherapp:latest", | |||
| 1036 | + | }) | |||
| 1037 | + | ||||
| 1038 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1039 | + | svc := NewService(v, eventBus) | |||
| 1040 | + | ctx := testContext() | |||
| 1041 | + | _ = svc.Load(ctx) | |||
| 1042 | + | ||||
| 1043 | + | routes := svc.FindRoutesByImage(ctx, "myapp") | |||
| 1044 | + | assert.Len(t, routes, 2) | |||
| 1045 | + | ||||
| 1046 | + | domains := []string{routes[0].Domain, routes[1].Domain} | |||
| 1047 | + | assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, domains) | |||
| 1048 | + | }) | |||
| 1049 | + | ||||
| 1050 | + | t.Run("case insensitive match", func(t *testing.T) { | |||
| 1051 | + | v := viper.New() | |||
| 1052 | + | v.Set("routes", map[string]interface{}{ | |||
| 1053 | + | "app.example.com": "MyApp:latest", | |||
| 1054 | + | }) | |||
| 1055 | + | ||||
| 1056 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1057 | + | svc := NewService(v, eventBus) | |||
| 1058 | + | ctx := testContext() | |||
| 1059 | + | _ = svc.Load(ctx) | |||
| 1060 | + | ||||
| 1061 | + | routes := svc.FindRoutesByImage(ctx, "myapp") | |||
| 1062 | + | assert.Len(t, routes, 1) | |||
| 1063 | + | }) | |||
| 1064 | + | ||||
| 1065 | + | t.Run("http prefix route sets HTTPS false", func(t *testing.T) { | |||
| 1066 | + | v := viper.New() | |||
| 1067 | + | v.Set("routes", map[string]interface{}{ | |||
| 1068 | + | "http://insecure.example.com": "myapp:latest", | |||
| 1069 | + | }) | |||
| 1070 | + | ||||
| 1071 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1072 | + | svc := NewService(v, eventBus) | |||
| 1073 | + | ctx := testContext() | |||
| 1074 | + | _ = svc.Load(ctx) | |||
| 1075 | + | ||||
| 1076 | + | routes := svc.FindRoutesByImage(ctx, "myapp") | |||
| 1077 | + | assert.Len(t, routes, 1) | |||
| 1078 | + | assert.Equal(t, "insecure.example.com", routes[0].Domain) | |||
| 1079 | + | assert.False(t, routes[0].HTTPS) | |||
| 1080 | + | }) | |||
| 1081 | + | ||||
| 1082 | + | t.Run("empty routes", func(t *testing.T) { | |||
| 1083 | + | v := viper.New() | |||
| 1084 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1085 | + | svc := NewService(v, eventBus) | |||
| 1086 | + | ctx := testContext() | |||
| 1087 | + | _ = svc.Load(ctx) | |||
| 1088 | + | ||||
| 1089 | + | routes := svc.FindRoutesByImage(ctx, "myapp") | |||
| 1090 | + | assert.Empty(t, routes) | |||
| 1091 | + | }) | |||
| 1092 | + | } | |||
| 1093 | + | ||||
| 867 | func TestExtractDomainFromImageName(t *testing.T) { | 1094 | func TestExtractDomainFromImageName(t *testing.T) { | ||
| 868 | tests := []struct { | 1095 | tests := []struct { | ||
| 869 | name string | 1096 | name string | ||