fix(back-end): fix load product

This commit is contained in:
2026-03-22 21:11:33 +01:00
parent 653082868a
commit 74d1b16b7c
18 changed files with 261 additions and 122 deletions

View File

@@ -1,27 +1,27 @@
package com.printcalculator.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
public class CorsConfig implements WebMvcConfigurer {
public class CorsConfig {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins(
"http://localhost",
"http://localhost:4200",
"http://localhost:80",
"http://127.0.0.1",
"https://dev.3d-fab.ch",
"https://int.3d-fab.ch",
"https://3d-fab.ch"
)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.allowedHeaders("*")
.allowCredentials(true);
@Bean
public CorsConfigurationSource corsConfigurationSource(AllowedOriginService allowedOriginService) {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(allowedOriginService.getAllowedOrigins());
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}

View File

@@ -1,5 +1,6 @@
package com.printcalculator.config;
import com.printcalculator.security.AdminCsrfProtectionFilter;
import com.printcalculator.security.AdminSessionAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -18,6 +19,7 @@ public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http,
AdminCsrfProtectionFilter adminCsrfProtectionFilter,
AdminSessionAuthenticationFilter adminSessionAuthenticationFilter
) throws Exception {
http
@@ -40,7 +42,8 @@ public class SecurityConfig {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}");
}))
.addFilterBefore(adminSessionAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
.addFilterBefore(adminCsrfProtectionFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(adminSessionAuthenticationFilter, AdminCsrfProtectionFilter.class);
return http.build();
}

View File

@@ -399,6 +399,8 @@ public class PublicShopCatalogService {
Map<String, String> variantColorHexByMaterialAndColor,
String language) {
List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
String normalizedLanguage = normalizeLanguage(language);
String publicPathSegment = ShopPublicPathSupport.buildProductPathSegment(entry.product(), normalizedLanguage);
Map<String, String> localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product());
return new ShopProductSummaryDto(
entry.product().getId(),
@@ -417,7 +419,7 @@ public class PublicShopCatalogService {
toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language),
selectPrimaryMedia(images),
toProductModelDto(entry),
localizedPaths.getOrDefault(normalizeLanguage(language), localizedPaths.get("it")),
publicPathSegment,
localizedPaths
);
}
@@ -429,9 +431,10 @@ public class PublicShopCatalogService {
List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
String localizedSeoTitle = entry.product().getSeoTitleForLanguage(language);
String localizedSeoDescription = entry.product().getSeoDescriptionForLanguage(language);
String normalizedLanguage = normalizeLanguage(language);
String publicPathSegment = ShopPublicPathSupport.buildProductPathSegment(entry.product(), normalizedLanguage);
Map<String, String> localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product());
return new ShopProductDetailDto(
entry.product().getId(),
return new ShopProductDetailDto(entry.product().getId(),
entry.product().getSlug(),
entry.product().getNameForLanguage(language),
entry.product().getExcerptForLanguage(language),
@@ -458,7 +461,7 @@ public class PublicShopCatalogService {
selectPrimaryMedia(images),
images,
toProductModelDto(entry),
localizedPaths.getOrDefault(normalizeLanguage(language), localizedPaths.get("it")),
publicPathSegment,
localizedPaths
);
}

View File

@@ -56,6 +56,7 @@ app.mail.contact-request.admin.enabled=${APP_MAIL_CONTACT_REQUEST_ADMIN_ENABLED:
app.mail.contact-request.admin.address=${APP_MAIL_CONTACT_REQUEST_ADMIN_ADDRESS:info@3d-fab.ch}
app.mail.contact-request.customer.enabled=${APP_MAIL_CONTACT_REQUEST_CUSTOMER_ENABLED:true}
app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200}
app.cors.additional-allowed-origins=${APP_CORS_ADDITIONAL_ALLOWED_ORIGINS:}
app.sitemap.shop.cache-seconds=${APP_SITEMAP_SHOP_CACHE_SECONDS:3600}
openai.translation.api-key=${OPENAI_API_KEY:}
openai.translation.base-url=${OPENAI_BASE_URL:https://api.openai.com/v1}

View File

@@ -1,7 +1,10 @@
package com.printcalculator.controller;
import com.printcalculator.config.AllowedOriginService;
import com.printcalculator.config.CorsConfig;
import com.printcalculator.config.SecurityConfig;
import com.printcalculator.controller.admin.AdminAuthController;
import com.printcalculator.security.AdminCsrfProtectionFilter;
import com.printcalculator.security.AdminLoginThrottleService;
import com.printcalculator.security.AdminSessionAuthenticationFilter;
import com.printcalculator.security.AdminSessionService;
@@ -19,13 +22,18 @@ import org.springframework.test.web.servlet.MvcResult;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(controllers = AdminAuthController.class)
@Import({
CorsConfig.class,
AllowedOriginService.class,
SecurityConfig.class,
AdminCsrfProtectionFilter.class,
AdminSessionAuthenticationFilter.class,
AdminSessionService.class,
AdminLoginThrottleService.class
@@ -37,6 +45,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
})
class AdminAuthSecurityTest {
private static final String ALLOWED_ORIGIN = "http://localhost:4200";
@Autowired
private MockMvc mockMvc;
@@ -47,6 +57,7 @@ class AdminAuthSecurityTest {
req.setRemoteAddr("10.0.0.1");
return req;
})
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"test-admin-password\"}"))
.andExpect(status().isOk())
@@ -69,6 +80,7 @@ class AdminAuthSecurityTest {
req.setRemoteAddr("10.0.0.2");
return req;
})
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"wrong-password\"}"))
.andExpect(status().isUnauthorized())
@@ -83,6 +95,7 @@ class AdminAuthSecurityTest {
req.setRemoteAddr("10.0.0.3");
return req;
})
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"wrong-password\"}"))
.andExpect(status().isUnauthorized())
@@ -93,12 +106,36 @@ class AdminAuthSecurityTest {
req.setRemoteAddr("10.0.0.3");
return req;
})
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"wrong-password\"}"))
.andExpect(status().isTooManyRequests())
.andExpect(jsonPath("$.authenticated").value(false));
}
@Test
void loginWithoutTrustedOrigin_ShouldReturnForbidden() throws Exception {
mockMvc.perform(post("/api/admin/auth/login")
.with(req -> {
req.setRemoteAddr("10.0.0.30");
return req;
})
.contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"test-admin-password\"}"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error").value("CSRF_INVALID"));
}
@Test
void preflightFromAllowedOrigin_ShouldExposeCorsHeaders() throws Exception {
mockMvc.perform(options("/api/admin/auth/login")
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST"))
.andExpect(status().isOk())
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, ALLOWED_ORIGIN))
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"));
}
@Test
void adminAccessWithoutCookie_ShouldReturn401() throws Exception {
mockMvc.perform(get("/api/admin/auth/me"))
@@ -112,6 +149,7 @@ class AdminAuthSecurityTest {
req.setRemoteAddr("10.0.0.4");
return req;
})
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"test-admin-password\"}"))
.andExpect(status().isOk())

View File

@@ -1,7 +1,10 @@
package com.printcalculator.controller.admin;
import com.printcalculator.config.AllowedOriginService;
import com.printcalculator.config.CorsConfig;
import com.printcalculator.config.SecurityConfig;
import com.printcalculator.service.order.AdminOrderControllerService;
import com.printcalculator.security.AdminCsrfProtectionFilter;
import com.printcalculator.security.AdminLoginThrottleService;
import com.printcalculator.security.AdminSessionAuthenticationFilter;
import com.printcalculator.security.AdminSessionService;
@@ -35,7 +38,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@WebMvcTest(controllers = {AdminAuthController.class, AdminOrderController.class})
@Import({
CorsConfig.class,
AllowedOriginService.class,
SecurityConfig.class,
AdminCsrfProtectionFilter.class,
AdminSessionAuthenticationFilter.class,
AdminSessionService.class,
AdminLoginThrottleService.class,
@@ -48,6 +54,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
})
class AdminOrderControllerSecurityTest {
private static final String ALLOWED_ORIGIN = "http://localhost:4200";
@Autowired
private MockMvc mockMvc;
@@ -96,6 +104,7 @@ class AdminOrderControllerSecurityTest {
req.setRemoteAddr("10.0.0.44");
return req;
})
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"test-admin-password\"}"))
.andExpect(status().isOk())

View File

@@ -1,9 +1,12 @@
package com.printcalculator.controller.admin;
import com.printcalculator.config.AllowedOriginService;
import com.printcalculator.config.CorsConfig;
import com.printcalculator.config.SecurityConfig;
import com.printcalculator.dto.AdminTranslateShopProductResponse;
import com.printcalculator.service.admin.AdminShopProductControllerService;
import com.printcalculator.service.admin.AdminShopProductTranslationService;
import com.printcalculator.security.AdminCsrfProtectionFilter;
import com.printcalculator.security.AdminLoginThrottleService;
import com.printcalculator.security.AdminSessionAuthenticationFilter;
import com.printcalculator.security.AdminSessionService;
@@ -36,7 +39,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@WebMvcTest(controllers = {AdminAuthController.class, AdminShopProductController.class})
@Import({
CorsConfig.class,
AllowedOriginService.class,
SecurityConfig.class,
AdminCsrfProtectionFilter.class,
AdminSessionAuthenticationFilter.class,
AdminSessionService.class,
AdminLoginThrottleService.class,
@@ -49,6 +55,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
})
class AdminShopProductControllerSecurityTest {
private static final String ALLOWED_ORIGIN = "http://localhost:4200";
@Autowired
private MockMvc mockMvc;
@@ -61,11 +69,22 @@ class AdminShopProductControllerSecurityTest {
@Test
void translateProduct_withoutAdminCookie_shouldReturn401() throws Exception {
mockMvc.perform(post("/api/admin/shop/products/translate")
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"sourceLanguage\":\"it\",\"names\":{\"it\":\"Supporto cavo\"}}"))
.andExpect(status().isUnauthorized());
}
@Test
void translateProduct_withAdminCookieAndMissingOrigin_shouldReturn403() throws Exception {
mockMvc.perform(post("/api/admin/shop/products/translate")
.cookie(loginAndExtractCookie())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"sourceLanguage\":\"it\",\"names\":{\"it\":\"Supporto cavo\"}}"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error").value("CSRF_INVALID"));
}
@Test
void translateProduct_withAdminCookie_shouldReturnTranslations() throws Exception {
AdminTranslateShopProductResponse response = new AdminTranslateShopProductResponse();
@@ -82,6 +101,7 @@ class AdminShopProductControllerSecurityTest {
mockMvc.perform(post("/api/admin/shop/products/translate")
.cookie(loginAndExtractCookie())
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
@@ -107,6 +127,7 @@ class AdminShopProductControllerSecurityTest {
req.setRemoteAddr("10.0.0.44");
return req;
})
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"test-admin-password\"}"))
.andExpect(status().isOk())

View File

@@ -0,0 +1,143 @@
package com.printcalculator.service.shop;
import com.printcalculator.dto.ShopProductCatalogResponseDto;
import com.printcalculator.dto.ShopProductDetailDto;
import com.printcalculator.entity.ShopCategory;
import com.printcalculator.entity.ShopProduct;
import com.printcalculator.entity.ShopProductVariant;
import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.ShopCategoryRepository;
import com.printcalculator.repository.ShopProductModelAssetRepository;
import com.printcalculator.repository.ShopProductRepository;
import com.printcalculator.repository.ShopProductVariantRepository;
import com.printcalculator.service.media.PublicMediaQueryService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PublicShopCatalogServiceTest {
@Mock
private ShopCategoryRepository shopCategoryRepository;
@Mock
private ShopProductRepository shopProductRepository;
@Mock
private ShopProductVariantRepository shopProductVariantRepository;
@Mock
private ShopProductModelAssetRepository shopProductModelAssetRepository;
@Mock
private FilamentVariantRepository filamentVariantRepository;
@Mock
private PublicMediaQueryService publicMediaQueryService;
@Mock
private ShopStorageService shopStorageService;
private PublicShopCatalogService service;
@BeforeEach
void setUp() {
service = new PublicShopCatalogService(
shopCategoryRepository,
shopProductRepository,
shopProductVariantRepository,
shopProductModelAssetRepository,
filamentVariantRepository,
publicMediaQueryService,
shopStorageService
);
}
@Test
void getProductCatalog_shouldExposePublicPathAsSegment() {
ShopCategory category = buildCategory();
ShopProduct product = buildProduct(category);
ShopProductVariant variant = buildVariant(product);
stubPublicCatalog(category, product, variant);
ShopProductCatalogResponseDto response = service.getProductCatalog(null, false, "en");
assertEquals(1, response.products().size());
assertEquals("12345678-bike-wall-hanger", response.products().getFirst().publicPath());
assertEquals("/en/shop/p/12345678-bike-wall-hanger", response.products().getFirst().localizedPaths().get("en"));
assertEquals("/it/shop/p/12345678-supporto-bici", response.products().getFirst().localizedPaths().get("it"));
}
@Test
void getProduct_shouldExposePublicPathAsSegment() {
ShopCategory category = buildCategory();
ShopProduct product = buildProduct(category);
ShopProductVariant variant = buildVariant(product);
stubPublicCatalog(category, product, variant);
ShopProductDetailDto response = service.getProduct("bike-wall-hanger", "en");
assertEquals("12345678-bike-wall-hanger", response.publicPath());
assertEquals("/en/shop/p/12345678-bike-wall-hanger", response.localizedPaths().get("en"));
assertEquals("/it/shop/p/12345678-supporto-bici", response.localizedPaths().get("it"));
}
private void stubPublicCatalog(ShopCategory category, ShopProduct product, ShopProductVariant variant) {
when(shopCategoryRepository.findAllByIsActiveTrueOrderBySortOrderAscNameAsc()).thenReturn(List.of(category));
when(shopProductRepository.findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc()).thenReturn(List.of(product));
when(shopProductVariantRepository.findByProduct_IdInAndIsActiveTrueOrderBySortOrderAscColorNameAsc(anyList()))
.thenReturn(List.of(variant));
when(shopProductModelAssetRepository.findByProduct_IdIn(anyList())).thenReturn(List.of());
when(filamentVariantRepository.findByIsActiveTrue()).thenReturn(List.of());
when(publicMediaQueryService.getUsageMediaMap(anyString(), anyList(), anyString())).thenReturn(Map.of());
}
private ShopCategory buildCategory() {
ShopCategory category = new ShopCategory();
category.setId(UUID.fromString("21111111-1111-1111-1111-111111111111"));
category.setSlug("accessori");
category.setName("Accessori");
category.setNameIt("Accessori");
category.setNameEn("Accessories");
category.setIsActive(true);
category.setSortOrder(0);
return category;
}
private ShopProduct buildProduct(ShopCategory category) {
ShopProduct product = new ShopProduct();
product.setId(UUID.fromString("12345678-abcd-4abc-9abc-1234567890ab"));
product.setCategory(category);
product.setSlug("bike-wall-hanger");
product.setName("Bike Wall-Hanger");
product.setNameIt("Supporto bici");
product.setNameEn("Bike Wall-Hanger");
product.setIsActive(true);
product.setIsFeatured(true);
product.setSortOrder(0);
return product;
}
private ShopProductVariant buildVariant(ShopProduct product) {
ShopProductVariant variant = new ShopProductVariant();
variant.setId(UUID.fromString("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
variant.setProduct(product);
variant.setVariantLabel("PLA");
variant.setColorName("Grigio");
variant.setInternalMaterialCode("PLA");
variant.setPriceChf(new BigDecimal("29.90"));
variant.setIsActive(true);
variant.setIsDefault(true);
variant.setSortOrder(0);
return variant;
}
}