From a689400fcf662223153af253e259843876b2c0a0 Mon Sep 17 00:00:00 2001 From: Pablo Carle Date: Tue, 23 Jun 2026 16:05:51 +0200 Subject: [PATCH 01/15] wip cherry pick Signed-off-by: Pablo Carle --- apiml-package/src/main/resources/bin/start.sh | 2 + .../java/org/zowe/apiml/util/CorsUtils.java | 40 +++++++++++-------- .../gateway/config/ServiceCorsUpdater.java | 4 +- 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/apiml-package/src/main/resources/bin/start.sh b/apiml-package/src/main/resources/bin/start.sh index c6e27ef4c2..d5dff0f4ee 100755 --- a/apiml-package/src/main/resources/bin/start.sh +++ b/apiml-package/src/main/resources/bin/start.sh @@ -58,6 +58,7 @@ # - ZWE_configs_apiml_security_x509_registry_allowedUsers # - ZWE_configs_apiml_service_allowEncodedSlashes # - ZWE_configs_apiml_service_corsEnabled +# - ZWE_configs_apiml_service_corsAllowedMethods # - ZWE_configs_apiml_service_forwardClientCertEnabled # - ZWE_configs_apimlId # - ZWE_configs_certificate_ciphers / ZWE_configs_ciphers @@ -304,6 +305,7 @@ _BPX_JOBNAME=${ZWE_zowe_job_prefix}${APIML_CODE} ${JAVA_BIN_DIR}java \ -Dapiml.service.apimlId=${ZWE_components_gateway_apimlId:-${ZWE_configs_apimlId:-}} \ -Dapiml.service.corsAllowedMethods=${ZWE_components_gateway_apiml_service_corsAllowedMethods:-${ZWE_configs_apiml_service_corsAllowedMethods:-GET,HEAD,POST,PATCH,DELETE,PUT,OPTIONS}} \ -Dapiml.service.corsEnabled=${ZWE_components_gateway_apiml_service_corsEnabled:-${ZWE_configs_apiml_service_corsEnabled:-false}} \ + -Dapiml.service.corsDefaultAllowedOrigins=${ZWE_components_gateway_apiml_service_corsDefaultAllowedOrigins:-${ZWE_configs_apiml_service_corsDefaultAllowedOrigins:-}} \ -Dapiml.service.forwardClientCertEnabled=${ZWE_components_gateway_apiml_security_x509_enabled:-${ZWE_configs_apiml_security_x509_enabled:-false}} \ -Dapiml.service.hostname=${ZWE_haInstance_hostname:-localhost} \ -Dapiml.service.port=${ZWE_components_gateway_port:-${ZWE_configs_port:-7554}} \ diff --git a/common-service-core/src/main/java/org/zowe/apiml/util/CorsUtils.java b/common-service-core/src/main/java/org/zowe/apiml/util/CorsUtils.java index b26f4c8661..8d51a271c0 100644 --- a/common-service-core/src/main/java/org/zowe/apiml/util/CorsUtils.java +++ b/common-service-core/src/main/java/org/zowe/apiml/util/CorsUtils.java @@ -10,9 +10,9 @@ package org.zowe.apiml.util; +import lombok.Builder; import lombok.NonNull; import lombok.extern.slf4j.Slf4j; -import org.apache.logging.log4j.util.TriConsumer; import org.springframework.web.cors.CorsConfiguration; import java.util.Arrays; @@ -22,18 +22,24 @@ import java.util.function.BiConsumer; import java.util.regex.Pattern; +import static org.apache.commons.lang3.StringUtils.isBlank; + @Slf4j +@Builder public class CorsUtils { - private final List allowedCorsHttpMethods; - private final boolean corsEnabled; private static final Pattern gatewayRoutesPattern = Pattern.compile("apiml\\.routes\\.[^.]*\\.gateway\\S*"); + + private final List defaultAllowedCorsHttpMethods; + private final boolean gatewayCorsEnabled; private final List corsAllowedEndpoints; + private final List defaultAllowedCorsOrigins; - public CorsUtils(boolean corsEnabled, List corsAllowedMethods, @NonNull List allowedEndpoints) { - this.corsEnabled = corsEnabled; - this.allowedCorsHttpMethods = corsAllowedMethods; + public CorsUtils(List corsAllowedMethods, boolean corsEnabled, @NonNull List allowedEndpoints, @NonNull List defaultAllowedCorsOrigins) { + this.defaultAllowedCorsHttpMethods = corsAllowedMethods; + this.gatewayCorsEnabled = corsEnabled; this.corsAllowedEndpoints = allowedEndpoints; + this.defaultAllowedCorsOrigins = defaultAllowedCorsOrigins; } public boolean isCorsEnabledForService(Map metadata) { @@ -41,13 +47,15 @@ public boolean isCorsEnabledForService(Map metadata) { return Boolean.parseBoolean(isCorsEnabledForService); } - public void setCorsConfiguration(String serviceId, Map metadata, TriConsumer entryMapper) { - if (corsEnabled) { + public void setCorsConfiguration(String serviceId, Map metadata, BiConsumer entryMapper) { + if (gatewayCorsEnabled) { var corsConfiguration = setAllowedOriginsForService(serviceId, metadata); metadata.entrySet().stream() .filter(entry -> gatewayRoutesPattern.matcher(entry.getKey()).find()) .forEach(entry -> - entryMapper.accept(entry.getValue(), serviceId, corsConfiguration)); + entryMapper.accept(entry.getValue(), corsConfiguration)); + } else { + log.debug("CORS is not enabled in Gateway"); } } @@ -56,10 +64,10 @@ private CorsConfiguration setAllowedOriginsForService(String serviceId, Map pathMapper) { - final CorsConfiguration config = new CorsConfiguration(); + var config = new CorsConfiguration(); List pathsToEnable; - if (corsEnabled) { + config.setAllowedOrigins(defaultAllowedCorsOrigins); + if (gatewayCorsEnabled) { config.setAllowCredentials(true); - config.addAllowedOriginPattern(CorsConfiguration.ALL); //NOSONAR this is a replication of existing code config.setAllowedHeaders(Collections.singletonList(CorsConfiguration.ALL)); - config.setAllowedMethods(allowedCorsHttpMethods); + config.setAllowedMethods(defaultAllowedCorsHttpMethods); pathsToEnable = corsAllowedEndpoints; } else { pathsToEnable = Collections.singletonList("/**"); diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ServiceCorsUpdater.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ServiceCorsUpdater.java index 5d3c932af9..5b290eb706 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ServiceCorsUpdater.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ServiceCorsUpdater.java @@ -56,8 +56,8 @@ public Mono onRefreshRoutesEvent(RefreshRoutesEvent event) { corsUtils.setCorsConfiguration( instance.getServiceId().toLowerCase(), instance.getMetadata(), - (prefix, serviceId, config) -> { - serviceId = instance.getMetadata().getOrDefault(APIML_ID, instance.getServiceId().toLowerCase()); + (prefix, config) -> { + String serviceId = instance.getMetadata().getOrDefault(APIML_ID, instance.getServiceId().toLowerCase()); urlBasedCorsConfigurationSource.registerCorsConfiguration("/" + serviceId + "/**", config); } ); From 211235f9e0ec4a644ca51e4421200abb791c9fc0 Mon Sep 17 00:00:00 2001 From: Pablo Carle Date: Thu, 25 Jun 2026 13:34:01 +0200 Subject: [PATCH 02/15] wip cherry pick Signed-off-by: Pablo Carle --- apiml-package/src/main/resources/bin/start.sh | 3 + build.gradle | 17 +++++ .../java/org/zowe/apiml/util/CorsUtils.java | 29 +++++---- .../org/zowe/apiml/util/CorsUtilsTest.java | 36 +++++++++-- config/local/api-defs/staticclient.yml | 2 + .../src/main/resources/bin/start.sh | 2 + .../gateway/config/ConnectionsConfig.java | 43 +++++++++++-- .../gateway/config/ConnectionsConfigTest.java | 45 +++++++++---- .../config/ServiceCorsUpdaterTest.java | 28 ++++++--- .../integration/proxy/CorsEnabledTest.java | 63 +++++++++++++++++++ .../zowe/apiml/util/requests/Endpoints.java | 2 + .../service/CorsMetadataProcessorTest.java | 40 ++++++++---- 12 files changed, 253 insertions(+), 57 deletions(-) create mode 100644 integration-tests/src/test/java/org/zowe/apiml/integration/proxy/CorsEnabledTest.java diff --git a/apiml-package/src/main/resources/bin/start.sh b/apiml-package/src/main/resources/bin/start.sh index b58f3f5913..d88b771b9f 100755 --- a/apiml-package/src/main/resources/bin/start.sh +++ b/apiml-package/src/main/resources/bin/start.sh @@ -61,6 +61,8 @@ # - ZWE_configs_apiml_service_allowEncodedSlashes # - ZWE_configs_apiml_service_corsEnabled # - ZWE_configs_apiml_service_corsAllowedMethods +# - ZWE_configs_apiml_service_corsDefaultAllowedOrigins +# - ZWE_configs_apiml_service_corsDefaultAllowedHeaders # - ZWE_configs_apiml_service_forwardClientCertEnabled # - ZWE_configs_apimlId # - ZWE_configs_certificate_ciphers / ZWE_configs_ciphers @@ -308,6 +310,7 @@ _BPX_JOBNAME=${ZWE_zowe_job_prefix}${APIML_CODE} ${JAVA_BIN_DIR}java \ -Dapiml.service.corsAllowedMethods=${ZWE_components_gateway_apiml_service_corsAllowedMethods:-${ZWE_configs_apiml_service_corsAllowedMethods:-GET,HEAD,POST,PATCH,DELETE,PUT,OPTIONS}} \ -Dapiml.service.corsEnabled=${ZWE_components_gateway_apiml_service_corsEnabled:-${ZWE_configs_apiml_service_corsEnabled:-false}} \ -Dapiml.service.corsDefaultAllowedOrigins=${ZWE_components_gateway_apiml_service_corsDefaultAllowedOrigins:-${ZWE_configs_apiml_service_corsDefaultAllowedOrigins:-}} \ + -Dapiml.service.corsDefaultAllowedHeaders=${ZWE_components_gateway_apiml_service_corsDefaultAllowedHeaders:-${ZWE_configs_apiml_service_corsDefaultAllowedHeaders:-}} \ -Dapiml.service.forwardClientCertEnabled=${ZWE_components_gateway_apiml_security_x509_enabled:-${ZWE_configs_apiml_security_x509_enabled:-false}} \ -Dapiml.service.hostname=${ZWE_haInstance_hostname:-localhost} \ -Dapiml.service.port=${ZWE_components_gateway_port:-${ZWE_configs_port:-7554}} \ diff --git a/build.gradle b/build.gradle index ac1c23f360..ff2b462715 100644 --- a/build.gradle +++ b/build.gradle @@ -165,6 +165,23 @@ configure(subprojects.findAll {it.name != 'platform'}) { testImplementation libs.junit.platform.commons testImplementation libs.junit.platform.engine } + + sourceSets { + main { + java { srcDirs = ['src/main/java'] } + resources { srcDirs = ['src/main/resources'] } + } + test { + java { srcDirs = ['src/test/java'] } + resources { srcDirs = ['src/test/resources'] } + } + } + + eclipse { + classpath { + plusConfigurations += [configurations.testImplementation] + } + } } subprojects { diff --git a/common-service-core/src/main/java/org/zowe/apiml/util/CorsUtils.java b/common-service-core/src/main/java/org/zowe/apiml/util/CorsUtils.java index 8d51a271c0..88796cceeb 100644 --- a/common-service-core/src/main/java/org/zowe/apiml/util/CorsUtils.java +++ b/common-service-core/src/main/java/org/zowe/apiml/util/CorsUtils.java @@ -22,7 +22,7 @@ import java.util.function.BiConsumer; import java.util.regex.Pattern; -import static org.apache.commons.lang3.StringUtils.isBlank; +import static org.apache.commons.lang3.StringUtils.isNotBlank; @Slf4j @Builder @@ -34,12 +34,14 @@ public class CorsUtils { private final boolean gatewayCorsEnabled; private final List corsAllowedEndpoints; private final List defaultAllowedCorsOrigins; + private final List defaultAllowedCorsHeaders; - public CorsUtils(List corsAllowedMethods, boolean corsEnabled, @NonNull List allowedEndpoints, @NonNull List defaultAllowedCorsOrigins) { + public CorsUtils(List corsAllowedMethods, boolean corsEnabled, @NonNull List allowedEndpoints, @NonNull List defaultAllowedCorsOrigins, @NonNull List defaultAllowedCorsHeaders) { this.defaultAllowedCorsHttpMethods = corsAllowedMethods; this.gatewayCorsEnabled = corsEnabled; this.corsAllowedEndpoints = allowedEndpoints; this.defaultAllowedCorsOrigins = defaultAllowedCorsOrigins; + this.defaultAllowedCorsHeaders = defaultAllowedCorsHeaders; } public boolean isCorsEnabledForService(Map metadata) { @@ -49,7 +51,7 @@ public boolean isCorsEnabledForService(Map metadata) { public void setCorsConfiguration(String serviceId, Map metadata, BiConsumer entryMapper) { if (gatewayCorsEnabled) { - var corsConfiguration = setAllowedOriginsForService(serviceId, metadata); + var corsConfiguration = setCorsHeadersForService(serviceId, metadata); metadata.entrySet().stream() .filter(entry -> gatewayRoutesPattern.matcher(entry.getKey()).find()) .forEach(entry -> @@ -59,25 +61,30 @@ public void setCorsConfiguration(String serviceId, Map metadata, } } - private CorsConfiguration setAllowedOriginsForService(String serviceId, Map metadata) { + private CorsConfiguration setCorsHeadersForService(String serviceId, Map metadata) { // Check if the configuration specifies allowed origins for this service var config = new CorsConfiguration(); if (isCorsEnabledForService(metadata)) { + defaultAllowedCorsOrigins.forEach(config::addAllowedOrigin); var corsAllowedOriginsForService = metadata.get("apiml.corsAllowedOrigins"); - if (isBlank(corsAllowedOriginsForService)) { - // Origins not specified: allow everything - log.debug("For service {}, set all as allowed origins", serviceId); - config.addAllowedOriginPattern(CorsConfiguration.ALL); // FIXME - } else { + if (isNotBlank(corsAllowedOriginsForService)) { // Origins specified: split by comma, add to whitelist log.debug("For service {}, set [{}] as allowed origins", serviceId, Arrays.toString(corsAllowedOriginsForService.split(","))); Arrays.stream(corsAllowedOriginsForService.split(",")) .forEach(config::addAllowedOrigin); } + config.setAllowCredentials(true); - config.setAllowedHeaders(Collections.singletonList(CorsConfiguration.ALL)); + + var allowedHeadersForService = metadata.get("apiml.corsAllowedHeaders"); + if (isNotBlank(allowedHeadersForService)) { + config.setAllowedHeaders(Arrays.asList(allowedHeadersForService.split(","))); + } else { + config.setAllowedHeaders(defaultAllowedCorsHeaders); + } config.setAllowedMethods(defaultAllowedCorsHttpMethods); } else { + config.setAllowedOrigins(defaultAllowedCorsOrigins); log.debug("CORS is not enabled for service {}, using defaults", serviceId); } return config; @@ -90,7 +97,7 @@ public void registerDefaultCorsConfiguration(BiConsumer { + corsUtils.setCorsConfiguration("dclient", metadata, (path, configuration) -> { assertEquals(metadata.get("apiml.routes.v1.gateway"), path); assertNotNull(configuration.getAllowedHeaders()); assertEquals(1, configuration.getAllowedHeaders().size()); @@ -69,7 +81,7 @@ void registerConfigForService() { @Test void registerDefaultConfigForService() { metadata.remove("apiml.corsEnabled"); - corsUtils.setCorsConfiguration("dclient", metadata, (path, serviceId, configuration) -> { + corsUtils.setCorsConfiguration("dclient", metadata, (path, configuration) -> { assertEquals(metadata.get("apiml.routes.v1.gateway"), path); assertNull(configuration.getAllowedMethods()); } @@ -80,7 +92,7 @@ void registerDefaultConfigForService() { void registerConfigForServiceWithCustomOrigins() { Map customMetadata = new HashMap<>(metadata); customMetadata.put("apiml.corsAllowedOrigins", "https://localhost:3000,http://hostname.com,https://anothehostname:3040"); - corsUtils.setCorsConfiguration("dclient", customMetadata, (path, serviceId, configuration) -> { + corsUtils.setCorsConfiguration("dclient", customMetadata, (path, configuration) -> { assertEquals(metadata.get("apiml.routes.v1.gateway"), path); assertNotNull(configuration.getAllowedHeaders()); assertTrue(configuration.getAllowedOrigins().contains("https://localhost:3000")); @@ -96,7 +108,18 @@ void registerConfigForServiceWithCustomOrigins() { @Nested class GivenCorsDisabled { - CorsUtils corsUtils = new CorsUtils(false, null, Arrays.asList("/gateway/**", "/api-docs")); + + private CorsUtils corsUtils; + + @BeforeEach + void setUp() { + corsUtils = CorsUtils.builder() + .gatewayCorsEnabled(false) + .corsAllowedEndpoints(Arrays.asList("/gateway/**", "/api-docs")) + .defaultAllowedCorsOrigins(Collections.emptyList()) + .defaultAllowedCorsHeaders(List.of("*")) + .build(); + } @Test void registerEmptyDefaultConfig() { @@ -109,12 +132,13 @@ void registerEmptyDefaultConfig() { @Test void registerEmptyConfigForService() { - corsUtils.setCorsConfiguration("dcclient", metadata, (path, serviceId, configuration) -> { + corsUtils.setCorsConfiguration("dcclient", metadata, (path, configuration) -> { assertNull(configuration.getAllowedHeaders()); assertNull(configuration.getAllowedMethods()); } ); } + } } diff --git a/config/local/api-defs/staticclient.yml b/config/local/api-defs/staticclient.yml index bb778ce0a8..6765af9bcf 100644 --- a/config/local/api-defs/staticclient.yml +++ b/config/local/api-defs/staticclient.yml @@ -29,6 +29,8 @@ services: customMetadata: apiml: okToRetryOnAllOperations: true + corsEnabled: true + corsAllowedOrigins: https://localhost2:10010 - serviceId: staticclient2 # unique lowercase ID of the service catalogUiTileId: static # ID of the API Catalog UI tile (visual grouping of the services) diff --git a/gateway-package/src/main/resources/bin/start.sh b/gateway-package/src/main/resources/bin/start.sh index 1cf08db74b..a375781740 100755 --- a/gateway-package/src/main/resources/bin/start.sh +++ b/gateway-package/src/main/resources/bin/start.sh @@ -191,6 +191,8 @@ _BPX_JOBNAME=${ZWE_zowe_job_prefix}${GATEWAY_CODE} ${JAVA_BIN_DIR}java \ -Dapiml.service.allowEncodedSlashes=${ZWE_configs_apiml_service_allowEncodedSlashes:-true} \ -Dapiml.service.apimlId=${ZWE_configs_apimlId:-} \ -Dapiml.service.corsAllowedMethods=${ZWE_configs_apiml_service_corsAllowedMethods:-GET,HEAD,POST,PATCH,DELETE,PUT,OPTIONS} \ + -Dapiml.service.corsDefaultAllowedHeaders=${ZWE_configs_apiml_service_corsDefaultAllowedHeaders:-} \ + -Dapiml.service.corsDefaultAllowedOrigins=${ZWE_configs_apiml_service_corsDefaultAllowedOrigins:-} \ -Dapiml.service.corsEnabled=${ZWE_configs_apiml_service_corsEnabled:-false} \ -Dapiml.service.forwardClientCertEnabled=${ZWE_configs_apiml_security_x509_enabled:-false} \ -Dapiml.service.hostname=${ZWE_haInstance_hostname:-localhost} \ diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java index f47bd97702..d4ca1797cc 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java @@ -19,9 +19,11 @@ import lombok.experimental.Delegate; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; import org.springframework.aop.support.AopUtils; import org.springframework.beans.BeanUtils; import org.springframework.beans.BeansException; +import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -46,6 +48,7 @@ import org.springframework.context.annotation.DependsOn; import org.springframework.util.CollectionUtils; import org.springframework.web.client.RestClient; +import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.reactive.CorsWebFilter; import org.springframework.web.server.WebFilter; import org.springframework.web.util.UriComponentsBuilder; @@ -78,7 +81,7 @@ @Configuration @Slf4j @RequiredArgsConstructor -public class ConnectionsConfig { +public class ConnectionsConfig implements InitializingBean { private static final ApimlLogger apimlLog = ApimlLogger.of(ConnectionsConfig.class, YamlMessageServiceInstance.getInstance()); @@ -92,11 +95,23 @@ public class ConnectionsConfig { private char[] discoveryPassword; @Value("${apiml.service.corsEnabled:false}") - private boolean corsEnabled; + private boolean gatewayCorsEnabled; @Value("${apiml.service.corsAllowedMethods:GET,HEAD,POST,PATCH,DELETE,PUT,OPTIONS}") private List corsAllowedMethods; + @Value("${apiml.service.corsDefaultAllowedOrigins:#{null}}") + private String corsDefaultAllowedOrigins; + + @Value("${apiml.service.corsDefaultAllowedHeaders:*}") + private String corsDefaultAllowedHeaders; + + @Value("${apiml.service.hostname:localhost}") + private String hostname; + + @Value("${apiml.service.port}") + private String port; + @Value("${server.attlsClient.enabled:false}") private boolean isClientAttlsEnabled; @@ -109,6 +124,16 @@ public class ConnectionsConfig { @Value("${apiml.service.corsAllowedEndpoints:/gateway/**}") private final List corsEnabledEndpoints; + @Override + public void afterPropertiesSet() throws Exception { + if (corsDefaultAllowedOrigins == null || corsDefaultAllowedOrigins.isEmpty()) { + corsDefaultAllowedOrigins = "https://" + hostname + ":" + port; + } + if (corsDefaultAllowedHeaders == null || corsDefaultAllowedHeaders.isEmpty()) { + corsDefaultAllowedHeaders = CorsConfiguration.ALL; + } + } + /** * @param httpClient default http client * @param headersFiltersProvider header filter for spring gateway router @@ -261,10 +286,10 @@ private String withBasicAuthFallback(String discoveryServiceUrls) { } private boolean isRouteKey(String key) { - return StringUtils.startsWith(key, ROUTES + ".") && + return Strings.CS.startsWith(key, ROUTES + ".") && ( - StringUtils.endsWith(key, "." + ROUTES_GATEWAY_URL) || - StringUtils.endsWith(key, "." + ROUTES_SERVICE_URL) + Strings.CS.startsWith(key, "." + ROUTES_GATEWAY_URL) || + Strings.CS.startsWith(key, "." + ROUTES_SERVICE_URL) ); } @@ -299,7 +324,13 @@ Customizer defaultCustomizer() { @Bean CorsUtils corsUtils() { - return new CorsUtils(corsEnabled, corsAllowedMethods, corsEnabledEndpoints); + return CorsUtils.builder() + .gatewayCorsEnabled(gatewayCorsEnabled) + .corsAllowedEndpoints(corsEnabledEndpoints) + .defaultAllowedCorsHttpMethods(corsAllowedMethods) + .defaultAllowedCorsOrigins(Arrays.asList(corsDefaultAllowedOrigins.split(","))) + .defaultAllowedCorsHeaders(Arrays.asList(corsDefaultAllowedHeaders.split(","))) + .build(); } @Bean diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/config/ConnectionsConfigTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/config/ConnectionsConfigTest.java index a3422e2691..f6397cf042 100644 --- a/gateway-service/src/test/java/org/zowe/apiml/gateway/config/ConnectionsConfigTest.java +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/config/ConnectionsConfigTest.java @@ -39,17 +39,20 @@ import org.zowe.apiml.gateway.GatewayServiceApplication; import org.zowe.apiml.product.web.HttpConfig; import org.zowe.apiml.security.common.util.ConnectionUtil; -import org.zowe.apiml.util.CorsUtils; import reactor.netty.http.client.HttpClient; import reactor.netty.tcp.SslProvider; import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.X509KeyManager; + import java.io.IOException; -import java.lang.reflect.Field; import java.net.MalformedURLException; import java.net.Socket; -import java.security.*; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.Principal; +import java.security.PrivateKey; +import java.security.UnrecoverableKeyException; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.Collections; @@ -59,8 +62,23 @@ import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; class ConnectionsConfigTest { @@ -416,11 +434,10 @@ public class WhenCorsAllowedMethodsIsNotSet { @Test void validateDefaultCorsAllowedMethods() throws NoSuchFieldException, IllegalAccessException { - CorsUtils corsUtils = connectionsConfig.corsUtils(); + var corsUtils = connectionsConfig.corsUtils(); - Field field = corsUtils.getClass().getDeclaredField("allowedCorsHttpMethods"); - field.setAccessible(true); - List corsAllowedMethods = (List) field.get(corsUtils); + @SuppressWarnings("unchecked") + var corsAllowedMethods = (List) ReflectionTestUtils.getField(corsUtils, "defaultAllowedCorsHttpMethods"); assertEquals(7, corsAllowedMethods.size()); } } @@ -437,17 +454,19 @@ public class WhenCorsAllowedMethodsIsSet { @Test void validateCorsAllowedMethods() throws NoSuchFieldException, IllegalAccessException { - CorsUtils corsUtils = connectionsConfig.corsUtils(); + var corsUtils = connectionsConfig.corsUtils(); + + @SuppressWarnings("unchecked") + var corsAllowedMethods = (List) ReflectionTestUtils.getField(corsUtils, "defaultAllowedCorsHttpMethods"); - Field field = corsUtils.getClass().getDeclaredField("allowedCorsHttpMethods"); - field.setAccessible(true); - List corsAllowedMethods = (List) field.get(corsUtils); assertEquals(3, corsAllowedMethods.size()); assertEquals("GET", corsAllowedMethods.get(0)); assertEquals("POST", corsAllowedMethods.get(1)); assertEquals("PATCH", corsAllowedMethods.get(2)); } + } + } @Nested diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/config/ServiceCorsUpdaterTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/config/ServiceCorsUpdaterTest.java index a27bd9e4e6..3544fe036b 100644 --- a/gateway-service/src/test/java/org/zowe/apiml/gateway/config/ServiceCorsUpdaterTest.java +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/config/ServiceCorsUpdaterTest.java @@ -10,7 +10,6 @@ package org.zowe.apiml.gateway.config; -import org.apache.logging.log4j.util.TriConsumer; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -30,14 +29,19 @@ import reactor.core.publisher.Flux; import reactor.test.StepVerifier; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.function.BiConsumer; import java.util.function.Consumer; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class ServiceCorsUpdaterTest { @@ -46,7 +50,13 @@ class ServiceCorsUpdaterTest { private static final String APIML_ID = "apimlid"; List allowedEndpoints = List.of("/gateway/**"); - private CorsUtils corsUtils = spy(new CorsUtils(true, List.of("GET", "HEAD", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"), allowedEndpoints)); + private CorsUtils corsUtils = spy(CorsUtils.builder() + .gatewayCorsEnabled(true) + .defaultAllowedCorsHttpMethods(List.of("GET", "HEAD", "POST", "PATCH", "DELETE", "PUT", "OPTIONS")) + .corsAllowedEndpoints(allowedEndpoints) + .defaultAllowedCorsHeaders(List.of("*")) + .defaultAllowedCorsOrigins(Collections.emptyList()) + .build()); @Mock private ReactiveDiscoveryClient discoveryClient; @@ -79,7 +89,7 @@ private ServiceInstance createServiceInstance(String serviceId) { } @SuppressWarnings("unchecked") - private TriConsumer getCorsLambda(Consumer> metadataProcessor) { + private BiConsumer getCorsLambda(Consumer> metadataProcessor) { var serviceInstance = createServiceInstance(SERVICE_ID); metadataProcessor.accept(serviceInstance.getMetadata()); @@ -87,7 +97,7 @@ private TriConsumer getCorsLambda(Consumer> lambdaCaptor = ArgumentCaptor.forClass(TriConsumer.class); + var lambdaCaptor = ArgumentCaptor.forClass(BiConsumer.class); verify(corsUtils).setCorsConfiguration(anyString(), any(), lambdaCaptor.capture()); return lambdaCaptor.getValue(); @@ -95,19 +105,19 @@ private TriConsumer getCorsLambda(Consumer corsLambda = getCorsLambda(md -> md.put(EurekaMetadataDefinition.APIML_ID, APIML_ID)); + var corsLambda = getCorsLambda(md -> md.put(EurekaMetadataDefinition.APIML_ID, APIML_ID)); - corsLambda.accept(null, SERVICE_ID, null); + corsLambda.accept(null, null); verify(serviceCorsUpdater.getUrlBasedCorsConfigurationSource()).registerCorsConfiguration("/" + APIML_ID + "/**", null); } @Test void givenNoApimlId_whenSetCors_thenServiceIdIsUsed() { - TriConsumer corsLambda = getCorsLambda(md -> { + var corsLambda = getCorsLambda(md -> { }); - corsLambda.accept(null, SERVICE_ID, null); + corsLambda.accept(null, null); verify(serviceCorsUpdater.getUrlBasedCorsConfigurationSource()).registerCorsConfiguration("/" + SERVICE_ID + "/**", null); } diff --git a/integration-tests/src/test/java/org/zowe/apiml/integration/proxy/CorsEnabledTest.java b/integration-tests/src/test/java/org/zowe/apiml/integration/proxy/CorsEnabledTest.java new file mode 100644 index 0000000000..8e8cef73b0 --- /dev/null +++ b/integration-tests/src/test/java/org/zowe/apiml/integration/proxy/CorsEnabledTest.java @@ -0,0 +1,63 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.integration.proxy; + +import io.restassured.RestAssured; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.zowe.apiml.util.TestWithStartedInstances; +import org.zowe.apiml.util.categories.DiscoverableClientDependentTest; +import org.zowe.apiml.util.config.ItSslConfigFactory; +import org.zowe.apiml.util.config.SslContext; +import org.zowe.apiml.util.http.HttpRequestUtils; +import org.zowe.apiml.util.requests.Endpoints; + +import static io.restassured.RestAssured.given; + +@DiscoverableClientDependentTest +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class CorsEnabledTest implements TestWithStartedInstances { + + @BeforeAll + void init() throws Exception { + RestAssured.useRelaxedHTTPSValidation(); + SslContext.prepareSslAuthentication(ItSslConfigFactory.integrationTests()); + } + + @Nested + class WhenCorsIsEnabledInService { + + @ParameterizedTest + @CsvSource({ + "https://localhost2:10010, 200", + "https://localhost:10010, 200", + "https://foo.bar:10010, 403", + "https://localhost:10011, 403" + }) + void test1(String origin, int statusCode) { + given() + .log().all() + .header("Origin", origin) + .header("Access-Control-Request-Method", "GET") + .header("Access-Control-Request-Headers", "Origin") + .when() + .options(HttpRequestUtils.getUriFromGateway(Endpoints.STATIC_CLIENT_1_REQUEST)) + .then() + .log().all() + .statusCode(statusCode); + } + + } + +} diff --git a/integration-tests/src/test/java/org/zowe/apiml/util/requests/Endpoints.java b/integration-tests/src/test/java/org/zowe/apiml/util/requests/Endpoints.java index 2311f82365..de11c1a33b 100644 --- a/integration-tests/src/test/java/org/zowe/apiml/util/requests/Endpoints.java +++ b/integration-tests/src/test/java/org/zowe/apiml/util/requests/Endpoints.java @@ -71,6 +71,8 @@ public class Endpoints { public static final String SAF_IDT_REQUEST = "/dcsafidt/api/v1/request"; public static final String ZOSMF_REQUEST = "/dczosmf/api/v1/request"; public static final String ZOWE_JWT_REQUEST = "/zowejwt/api/v1/request"; + public static final String STATIC_CLIENT_2_REQUEST = "/staticclient2/api/v1/request"; + public static final String STATIC_CLIENT_1_REQUEST = "/staticclient/api/v1/request"; public static final String DISCOVERABLE_CLIENT_CONTAINER_ENDPOINT = "/apicatalog/api/v1/containers/cademoapps"; public static final String DISCOVERABLE_CLIENT_API_DOC_ENDPOINT = "/apicatalog/api/v1/apidoc/discoverableclient/zowe.apiml.discoverableclient.rest v1.0.0"; diff --git a/zaas-service/src/test/java/org/zowe/apiml/zaas/metadata/service/CorsMetadataProcessorTest.java b/zaas-service/src/test/java/org/zowe/apiml/zaas/metadata/service/CorsMetadataProcessorTest.java index 51d62f1b86..00ae327c25 100644 --- a/zaas-service/src/test/java/org/zowe/apiml/zaas/metadata/service/CorsMetadataProcessorTest.java +++ b/zaas-service/src/test/java/org/zowe/apiml/zaas/metadata/service/CorsMetadataProcessorTest.java @@ -11,9 +11,14 @@ package org.zowe.apiml.zaas.metadata.service; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.zowe.apiml.util.CorsUtils; @@ -26,19 +31,27 @@ import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.core.Is.is; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +@ExtendWith(MockitoExtension.class) class CorsMetadataProcessorTest { + private CorsUtils corsUtils; + @Mock private UrlBasedCorsConfigurationSource configurationSource; - private ArgumentCaptor configurationCaptor = ArgumentCaptor.forClass(CorsConfiguration.class); - List allowedEndpoints = List.of("/gateway/**"); + @Captor + private ArgumentCaptor configurationCaptor; + private List allowedEndpoints = List.of("/gateway/**"); @BeforeEach void setUp() { - configurationSource = mock(UrlBasedCorsConfigurationSource.class); - corsUtils = new CorsUtils(true, List.of("GET", "HEAD", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"), allowedEndpoints); + corsUtils = CorsUtils.builder() + .gatewayCorsEnabled(true) + .defaultAllowedCorsHttpMethods(List.of("GET", "HEAD", "POST", "PATCH", "DELETE", "PUT", "OPTIONS")) + .corsAllowedEndpoints(allowedEndpoints) + .defaultAllowedCorsOrigins(List.of("https://localhost:10010")) + .defaultAllowedCorsHeaders(List.of("*")) + .build(); } @Nested @@ -51,24 +64,26 @@ void corsIsEnabledPerService_allowedOriginsAreProvided() { metadata.put("apiml.corsEnabled", "true"); metadata.put("apiml.corsAllowedOrigins", "http://local1,http://local2"); metadata.put("apiml.routes.0.gateway", "gateway"); - corsUtils.setCorsConfiguration("cors-enabled-origins-allowed", metadata, (entry, serviceId, config) -> configurationSource.registerCorsConfiguration("/" + entry + "/" + serviceId + "/**", config)); + corsUtils.setCorsConfiguration("cors-enabled-origins-allowed", metadata, (entry, config) -> configurationSource.registerCorsConfiguration("/" + entry + "/cors-enabled-origins-allowed/**", config)); verify(configurationSource).registerCorsConfiguration(any(), configurationCaptor.capture()); CorsConfiguration provided = configurationCaptor.getValue(); assertDefaultConfiguration(provided); - assertThat(provided.getAllowedOrigins(), hasSize(2)); - assertThat(provided.getAllowedOrigins().get(0), is("http://local1")); - assertThat(provided.getAllowedOrigins().get(1), is("http://local2")); + assertThat(provided.getAllowedOrigins(), hasSize(3)); + assertThat(provided.getAllowedOrigins().get(0), is("https://localhost:10010")); + assertThat(provided.getAllowedOrigins().get(1), is("http://local1")); + assertThat(provided.getAllowedOrigins().get(2), is("http://local2")); } @Test + @Disabled("Not anymore") void corsIsEnabledPerService_allowedOriginsArentProvided() { Map metadata = new HashMap<>(); metadata.put("apiml.corsEnabled", "true"); metadata.put("apiml.routes.0.gateway", "gateway"); - corsUtils.setCorsConfiguration("cors-enabled-all-origins", metadata, (entry, serviceId, config) -> configurationSource.registerCorsConfiguration("/" + entry + "/" + serviceId + "/**", config)); + corsUtils.setCorsConfiguration("cors-enabled-all-origins", metadata, (entry, config) -> configurationSource.registerCorsConfiguration("/" + entry + "/cors-enabled-all-origins/**", config)); verify(configurationSource).registerCorsConfiguration(any(), configurationCaptor.capture()); @@ -95,9 +110,10 @@ void corsIsDisabledPerService() { Map metadata = new HashMap<>(); metadata.put("apiml.corsEnabled", "false"); metadata.put("apiml.routes.0.gateway", "gateway"); - corsUtils.setCorsConfiguration("cors-disabled", metadata, (entry, serviceId, config) -> configurationSource.registerCorsConfiguration("/" + entry + "/" + serviceId + "/**", config)); + corsUtils.setCorsConfiguration("cors-disabled", metadata, (entry, config) -> configurationSource.registerCorsConfiguration("/" + entry + "/cors-disabled/**", config)); verify(configurationSource).registerCorsConfiguration(any(), configurationCaptor.capture()); - } + } + } From 2757a951056e17ea2981195c67f61924ace468e7 Mon Sep 17 00:00:00 2001 From: Pablo Carle Date: Fri, 26 Jun 2026 10:49:06 +0200 Subject: [PATCH 03/15] fix test Signed-off-by: Pablo Carle --- .../apiml/acceptance/AttlsConfigTest.java | 8 +----- apiml/src/test/resources/application.yml | 2 +- build.gradle | 27 ++++++++----------- 3 files changed, 13 insertions(+), 24 deletions(-) diff --git a/apiml/src/test/java/org/zowe/apiml/acceptance/AttlsConfigTest.java b/apiml/src/test/java/org/zowe/apiml/acceptance/AttlsConfigTest.java index a3b6f1f90d..78bc46d676 100644 --- a/apiml/src/test/java/org/zowe/apiml/acceptance/AttlsConfigTest.java +++ b/apiml/src/test/java/org/zowe/apiml/acceptance/AttlsConfigTest.java @@ -215,15 +215,9 @@ void whenNoKeystore_thenStartupSuccess() { @Nested @ActiveProfiles({"attlsClient", "attlsServer", "WhenCorsEnabledService"}) @DirtiesContext - @SpringBootTest(classes = { - ApimlApplication.class, - FreeMarkerConfigurer.class, - TestConfig.class - }, - webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT - ) @AcceptanceTest @TestInstance(Lifecycle.PER_CLASS) + // this test requires a defined port to either match the default allowed origin or set apiml.corsDefaultAllowedOrigins property with the known port class WhenCorsEnabledService extends AcceptanceTestWithMockServices { private static final String VALID_CERT = diff --git a/apiml/src/test/resources/application.yml b/apiml/src/test/resources/application.yml index 28a1fb907b..edcea1724c 100644 --- a/apiml/src/test/resources/application.yml +++ b/apiml/src/test/resources/application.yml @@ -43,7 +43,7 @@ apiml: serviceId: apicatalog discovery: staticApiDefinitionsDirectories: config/local/api-defs - allPeersUrls: http://${apiml.service.hostname}:${apiml.service.port}/eureka/ + allPeersUrls: https://${apiml.service.hostname}:${apiml.service.port}/eureka/ internal-discovery: address: ${server.address} port: 10011 diff --git a/build.gradle b/build.gradle index ff2b462715..a11e47dad1 100644 --- a/build.gradle +++ b/build.gradle @@ -101,6 +101,17 @@ allprojects { } } + sourceSets { + main { + java { srcDirs = ['src/main/java'] } + resources { srcDirs = ['src/main/resources'] } + } + test { + java { srcDirs = ['src/test/java'] } + resources { srcDirs = ['src/test/resources'] } + } + } + configurations.all { resolutionStrategy.dependencySubstitution { substitute(module('javax.servlet:servlet-api')).using(module('javax.servlet:javax.servlet-api:4.0.1')) @@ -166,22 +177,6 @@ configure(subprojects.findAll {it.name != 'platform'}) { testImplementation libs.junit.platform.engine } - sourceSets { - main { - java { srcDirs = ['src/main/java'] } - resources { srcDirs = ['src/main/resources'] } - } - test { - java { srcDirs = ['src/test/java'] } - resources { srcDirs = ['src/test/resources'] } - } - } - - eclipse { - classpath { - plusConfigurations += [configurations.testImplementation] - } - } } subprojects { From 43583c235f3c224977e521c9540fc91d8f00de1b Mon Sep 17 00:00:00 2001 From: Pablo Carle Date: Fri, 26 Jun 2026 11:25:34 +0200 Subject: [PATCH 04/15] fix ITs Signed-off-by: Pablo Carle --- .github/workflows/integration-tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index fedce751f4..0d5bc626c5 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -137,6 +137,7 @@ jobs: APIML_GATEWAY_SERVICESTOLIMITREQUESTRATE: discoverableclient APIML_GATEWAY_SERVICESTODISABLERETRY: discoverableclient APIML_GATEWAY_COOKIENAMEFORRATELIMIT: apimlAuthenticationToken + APIML_SERVICE_CORSDEFAULTALLOWEDORIGINS: https://localhost:10010,https://localhost2:10010 #The memory is constrained to test large file upload memory issues (#4265) #If the container runs oom, it is hard killed without printing any reasonable message in the log options: --memory 640m --memory-swap 640m @@ -476,6 +477,7 @@ jobs: EUREKA_CLIENT_INSTANCEINFOREPLICATIONINTERVALSECONDS: 1 EUREKA_CLIENT_REGISTRYFETCHINTERVALSECONDS: 1 logbackService: ZWEAGW1 + APIML_SERVICE_CORSDEFAULTALLOWEDORIGINS: https://localhost:10010,https://localhost2:10010 options: --memory 640m --memory-swap 640m zaas-service: image: ghcr.io/balhar-jakub/zaas-service:${{ github.run_id }}-${{ github.run_number }} From 9d5c1fc599ef64a53bd6c429666138ac88bda65b Mon Sep 17 00:00:00 2001 From: Pablo Carle Date: Fri, 26 Jun 2026 12:10:55 +0200 Subject: [PATCH 05/15] try fix IT Signed-off-by: Pablo Carle --- .github/workflows/integration-tests.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 0d5bc626c5..7d8970301a 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -137,6 +137,7 @@ jobs: APIML_GATEWAY_SERVICESTOLIMITREQUESTRATE: discoverableclient APIML_GATEWAY_SERVICESTODISABLERETRY: discoverableclient APIML_GATEWAY_COOKIENAMEFORRATELIMIT: apimlAuthenticationToken + APIML_SERVICE_CORSENABLED: true APIML_SERVICE_CORSDEFAULTALLOWEDORIGINS: https://localhost:10010,https://localhost2:10010 #The memory is constrained to test large file upload memory issues (#4265) #If the container runs oom, it is hard killed without printing any reasonable message in the log @@ -477,6 +478,7 @@ jobs: EUREKA_CLIENT_INSTANCEINFOREPLICATIONINTERVALSECONDS: 1 EUREKA_CLIENT_REGISTRYFETCHINTERVALSECONDS: 1 logbackService: ZWEAGW1 + APIML_SERVICE_CORSENABLED: true APIML_SERVICE_CORSDEFAULTALLOWEDORIGINS: https://localhost:10010,https://localhost2:10010 options: --memory 640m --memory-swap 640m zaas-service: @@ -501,6 +503,8 @@ jobs: APIML_SERVICE_HOSTNAME: gateway-service-2 APIML_SERVICE_DISCOVERYSERVICEURLS: https://discovery-service-2:10011/eureka/ logbackService: ZWEAGW2 + APIML_SERVICE_CORSENABLED: true + APIML_SERVICE_CORSDEFAULTALLOWEDORIGINS: https://localhost:10010,https://localhost2:10010 options: --memory 640m --memory-swap 640m zaas-service-2: image: ghcr.io/balhar-jakub/zaas-service:${{ github.run_id }}-${{ github.run_number }} From 18c86639ebacdb85f3623c45b87920f412f840c0 Mon Sep 17 00:00:00 2001 From: Pablo Carle Date: Fri, 26 Jun 2026 12:24:53 +0200 Subject: [PATCH 06/15] try fix IT Signed-off-by: Pablo Carle --- config/docker/api-defs/staticclient.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/config/docker/api-defs/staticclient.yml b/config/docker/api-defs/staticclient.yml index a478cb9f72..23f4d4097d 100644 --- a/config/docker/api-defs/staticclient.yml +++ b/config/docker/api-defs/staticclient.yml @@ -29,6 +29,13 @@ services: customMetadata: apiml: okToRetryOnAllOperations: true + corsEnabled: true + corsAllowedOrigins: https://discoverable-client:10012/discoverableclient + # corsAllowedMethods: GET,HEAD,POST,PATCH,DELETE,PUT,OPTIONS + # corsAllowedHeaders: Content-Type,Authorization + # corsExposedHeaders: Content-Type,Authorization + # corsMaxAge: 3600 + # corsAllowCredentials: true - serviceId: zowejwt # unique lowercase ID of the service catalogUiTileId: static # ID of the API Catalog UI tile (visual grouping of the services) @@ -77,6 +84,9 @@ services: - apiId: zowe.apiml.discoverableclient gatewayUrl: api/v1 version: 1.0.0 + customMetadata: + apiml: + corsEnabled: false - serviceId: dcbypass # unique lowercase ID of the service catalogUiTileId: static # ID of the API Catalog UI tile (visual grouping of the services) From 953f08754c81c5b963da6d4e8278bd888d9047b4 Mon Sep 17 00:00:00 2001 From: Pablo Carle Date: Tue, 30 Jun 2026 10:00:48 +0200 Subject: [PATCH 07/15] pr review 1 Signed-off-by: Pablo Carle --- .../src/main/java/org/zowe/apiml/util/CorsUtils.java | 4 ++-- .../java/org/zowe/apiml/gateway/config/ConnectionsConfig.java | 4 ++-- .../org/zowe/apiml/gateway/config/ServiceCorsUpdater.java | 3 ++- .../org/zowe/apiml/gateway/config/ConnectionsConfigTest.java | 4 ++-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/common-service-core/src/main/java/org/zowe/apiml/util/CorsUtils.java b/common-service-core/src/main/java/org/zowe/apiml/util/CorsUtils.java index 88796cceeb..5217c543ce 100644 --- a/common-service-core/src/main/java/org/zowe/apiml/util/CorsUtils.java +++ b/common-service-core/src/main/java/org/zowe/apiml/util/CorsUtils.java @@ -51,7 +51,7 @@ public boolean isCorsEnabledForService(Map metadata) { public void setCorsConfiguration(String serviceId, Map metadata, BiConsumer entryMapper) { if (gatewayCorsEnabled) { - var corsConfiguration = setCorsHeadersForService(serviceId, metadata); + var corsConfiguration = createCorsConfigurationForService(serviceId, metadata); metadata.entrySet().stream() .filter(entry -> gatewayRoutesPattern.matcher(entry.getKey()).find()) .forEach(entry -> @@ -61,7 +61,7 @@ public void setCorsConfiguration(String serviceId, Map metadata, } } - private CorsConfiguration setCorsHeadersForService(String serviceId, Map metadata) { + private CorsConfiguration createCorsConfigurationForService(String serviceId, Map metadata) { // Check if the configuration specifies allowed origins for this service var config = new CorsConfiguration(); if (isCorsEnabledForService(metadata)) { diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java index d4ca1797cc..5e7ac8c7d6 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java @@ -288,8 +288,8 @@ private String withBasicAuthFallback(String discoveryServiceUrls) { private boolean isRouteKey(String key) { return Strings.CS.startsWith(key, ROUTES + ".") && ( - Strings.CS.startsWith(key, "." + ROUTES_GATEWAY_URL) || - Strings.CS.startsWith(key, "." + ROUTES_SERVICE_URL) + Strings.CS.endsWith(key, "." + ROUTES_GATEWAY_URL) || + Strings.CS.endsWith(key, "." + ROUTES_SERVICE_URL) ); } diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ServiceCorsUpdater.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ServiceCorsUpdater.java index 5b290eb706..c67b13312f 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ServiceCorsUpdater.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ServiceCorsUpdater.java @@ -13,6 +13,7 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.InitializingBean; import org.springframework.cloud.client.discovery.ReactiveDiscoveryClient; import org.springframework.cloud.gateway.config.GlobalCorsProperties; @@ -57,7 +58,7 @@ public Mono onRefreshRoutesEvent(RefreshRoutesEvent event) { instance.getServiceId().toLowerCase(), instance.getMetadata(), (prefix, config) -> { - String serviceId = instance.getMetadata().getOrDefault(APIML_ID, instance.getServiceId().toLowerCase()); + String serviceId = StringUtils.lowerCase(instance.getMetadata().getOrDefault(APIML_ID, instance.getServiceId())); urlBasedCorsConfigurationSource.registerCorsConfiguration("/" + serviceId + "/**", config); } ); diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/config/ConnectionsConfigTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/config/ConnectionsConfigTest.java index f6397cf042..bec5d51354 100644 --- a/gateway-service/src/test/java/org/zowe/apiml/gateway/config/ConnectionsConfigTest.java +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/config/ConnectionsConfigTest.java @@ -433,7 +433,7 @@ public class WhenCorsAllowedMethodsIsNotSet { private ConnectionsConfig connectionsConfig; @Test - void validateDefaultCorsAllowedMethods() throws NoSuchFieldException, IllegalAccessException { + void validateDefaultCorsAllowedMethods() { var corsUtils = connectionsConfig.corsUtils(); @SuppressWarnings("unchecked") @@ -453,7 +453,7 @@ public class WhenCorsAllowedMethodsIsSet { private ConnectionsConfig connectionsConfig; @Test - void validateCorsAllowedMethods() throws NoSuchFieldException, IllegalAccessException { + void validateCorsAllowedMethods() { var corsUtils = connectionsConfig.corsUtils(); @SuppressWarnings("unchecked") From b6de052b3f6c9f439907ddff563ca23c3ea9307e Mon Sep 17 00:00:00 2001 From: Pablo Carle Date: Wed, 1 Jul 2026 14:36:05 +0200 Subject: [PATCH 08/15] add acceptance tests from v2 (unverified) Signed-off-by: Pablo Carle --- .../java/org/zowe/apiml/util/CorsUtils.java | 34 ++- .../org/zowe/apiml/util/CorsUtilsTest.java | 11 +- config/docker/api-defs/staticclient.yml | 8 +- .../gateway/config/ConnectionsConfig.java | 34 +-- .../acceptance/CorsPerServiceTest.java | 155 ++++++++++++ .../acceptance/corsTests/GatewayCorsTest.java | 235 ++++++++++++++++++ 6 files changed, 446 insertions(+), 31 deletions(-) create mode 100644 gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/GatewayCorsTest.java diff --git a/common-service-core/src/main/java/org/zowe/apiml/util/CorsUtils.java b/common-service-core/src/main/java/org/zowe/apiml/util/CorsUtils.java index 5217c543ce..28cef74088 100644 --- a/common-service-core/src/main/java/org/zowe/apiml/util/CorsUtils.java +++ b/common-service-core/src/main/java/org/zowe/apiml/util/CorsUtils.java @@ -35,17 +35,25 @@ public class CorsUtils { private final List corsAllowedEndpoints; private final List defaultAllowedCorsOrigins; private final List defaultAllowedCorsHeaders; - - public CorsUtils(List corsAllowedMethods, boolean corsEnabled, @NonNull List allowedEndpoints, @NonNull List defaultAllowedCorsOrigins, @NonNull List defaultAllowedCorsHeaders) { + private final boolean defaultAllowCredentials; + + public CorsUtils( + List corsAllowedMethods, + boolean corsEnabled, + @NonNull List allowedEndpoints, + @NonNull List defaultAllowedCorsOrigins, + @NonNull List defaultAllowedCorsHeaders, + boolean defaultAllowCredentials) { this.defaultAllowedCorsHttpMethods = corsAllowedMethods; this.gatewayCorsEnabled = corsEnabled; this.corsAllowedEndpoints = allowedEndpoints; this.defaultAllowedCorsOrigins = defaultAllowedCorsOrigins; this.defaultAllowedCorsHeaders = defaultAllowedCorsHeaders; + this.defaultAllowCredentials = defaultAllowCredentials; } public boolean isCorsEnabledForService(Map metadata) { - String isCorsEnabledForService = metadata.get("apiml.corsEnabled"); + var isCorsEnabledForService = metadata.get("apiml.corsEnabled"); return Boolean.parseBoolean(isCorsEnabledForService); } @@ -65,8 +73,14 @@ private CorsConfiguration createCorsConfigurationForService(String serviceId, Ma // Check if the configuration specifies allowed origins for this service var config = new CorsConfiguration(); if (isCorsEnabledForService(metadata)) { + defaultAllowedCorsOrigins.forEach(config::addAllowedOrigin); + var corsAllowedOriginsForService = metadata.get("apiml.corsAllowedOrigins"); + var allowedHeadersForService = metadata.get("apiml.corsAllowedHeaders"); + var allowedCredentialsForService = metadata.get(""); + var allowedMethodsForService = metadata.get(""); + if (isNotBlank(corsAllowedOriginsForService)) { // Origins specified: split by comma, add to whitelist log.debug("For service {}, set [{}] as allowed origins", serviceId, Arrays.toString(corsAllowedOriginsForService.split(","))); @@ -74,15 +88,23 @@ private CorsConfiguration createCorsConfigurationForService(String serviceId, Ma .forEach(config::addAllowedOrigin); } - config.setAllowCredentials(true); + if (isNotBlank(allowedCredentialsForService)) { + config.setAllowCredentials(Boolean.parseBoolean(allowedCredentialsForService)); + } else { + config.setAllowCredentials(defaultAllowCredentials); + } + + if (isNotBlank(allowedMethodsForService)) { + config.setAllowedMethods(Arrays.asList(allowedMethodsForService.split(","))); + } else { + config.setAllowedMethods(defaultAllowedCorsHttpMethods); + } - var allowedHeadersForService = metadata.get("apiml.corsAllowedHeaders"); if (isNotBlank(allowedHeadersForService)) { config.setAllowedHeaders(Arrays.asList(allowedHeadersForService.split(","))); } else { config.setAllowedHeaders(defaultAllowedCorsHeaders); } - config.setAllowedMethods(defaultAllowedCorsHttpMethods); } else { config.setAllowedOrigins(defaultAllowedCorsOrigins); log.debug("CORS is not enabled for service {}, using defaults", serviceId); diff --git a/common-service-core/src/test/java/org/zowe/apiml/util/CorsUtilsTest.java b/common-service-core/src/test/java/org/zowe/apiml/util/CorsUtilsTest.java index 8fd6c110b4..b9d84ca4e1 100644 --- a/common-service-core/src/test/java/org/zowe/apiml/util/CorsUtilsTest.java +++ b/common-service-core/src/test/java/org/zowe/apiml/util/CorsUtilsTest.java @@ -13,6 +13,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; import java.util.Arrays; import java.util.Collections; @@ -22,15 +24,17 @@ import static org.junit.jupiter.api.Assertions.*; +@ExtendWith(MockitoExtension.class) class CorsUtilsTest { - Map metadata = new HashMap<>(); - List defaultCorsMethods = List.of("GET", "HEAD", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"); + private Map metadata = new HashMap<>(); + private List defaultCorsMethods = List.of("GET", "HEAD", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"); - List allowedEndpoints = List.of("/gateway/**"); + private List allowedEndpoints = List.of("/gateway/**"); @BeforeEach void setup() { + metadata.clear(); metadata.put("apiml.routes.v1.gateway", "api/v1"); metadata.put("apiml.corsEnabled", "true"); } @@ -102,6 +106,7 @@ void registerConfigForServiceWithCustomOrigins() { } ); } + } } diff --git a/config/docker/api-defs/staticclient.yml b/config/docker/api-defs/staticclient.yml index 23f4d4097d..cd37472336 100644 --- a/config/docker/api-defs/staticclient.yml +++ b/config/docker/api-defs/staticclient.yml @@ -31,11 +31,9 @@ services: okToRetryOnAllOperations: true corsEnabled: true corsAllowedOrigins: https://discoverable-client:10012/discoverableclient - # corsAllowedMethods: GET,HEAD,POST,PATCH,DELETE,PUT,OPTIONS - # corsAllowedHeaders: Content-Type,Authorization - # corsExposedHeaders: Content-Type,Authorization - # corsMaxAge: 3600 - # corsAllowCredentials: true + corsAllowedMethods: GET,POST,OPTIONS,HEAD + corsAllowedHeaders: Content-Type,Authorization + corsAllowCredentials: true - serviceId: zowejwt # unique lowercase ID of the service catalogUiTileId: static # ID of the API Catalog UI tile (visual grouping of the services) diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java index 5e7ac8c7d6..bb053b644a 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java @@ -10,7 +10,11 @@ package org.zowe.apiml.gateway.config; -import com.netflix.appinfo.*; +import com.netflix.appinfo.ApplicationInfoManager; +import com.netflix.appinfo.EurekaInstanceConfig; +import com.netflix.appinfo.HealthCheckHandler; +import com.netflix.appinfo.InstanceInfo; +import com.netflix.appinfo.LeaseInfo; import com.netflix.discovery.EurekaClient; import com.netflix.discovery.EurekaClientConfig; import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; @@ -23,7 +27,6 @@ import org.springframework.aop.support.AopUtils; import org.springframework.beans.BeanUtils; import org.springframework.beans.BeansException; -import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -48,7 +51,6 @@ import org.springframework.context.annotation.DependsOn; import org.springframework.util.CollectionUtils; import org.springframework.web.client.RestClient; -import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.reactive.CorsWebFilter; import org.springframework.web.server.WebFilter; import org.springframework.web.util.UriComponentsBuilder; @@ -71,17 +73,25 @@ import java.net.MalformedURLException; import java.net.URL; import java.time.Duration; -import java.util.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.stream.Collectors; import static org.springframework.cloud.netflix.eureka.EurekaClientConfigBean.DEFAULT_ZONE; -import static org.zowe.apiml.constants.EurekaMetadataDefinition.*; +import static org.zowe.apiml.constants.EurekaMetadataDefinition.REGISTRATION_TYPE; +import static org.zowe.apiml.constants.EurekaMetadataDefinition.ROUTES; +import static org.zowe.apiml.constants.EurekaMetadataDefinition.ROUTES_GATEWAY_URL; +import static org.zowe.apiml.constants.EurekaMetadataDefinition.ROUTES_SERVICE_URL; //TODO this configuration should be removed as redundancy of the HttpConfig in the apiml-common @Configuration @Slf4j @RequiredArgsConstructor -public class ConnectionsConfig implements InitializingBean { +public class ConnectionsConfig { private static final ApimlLogger apimlLog = ApimlLogger.of(ConnectionsConfig.class, YamlMessageServiceInstance.getInstance()); @@ -100,7 +110,7 @@ public class ConnectionsConfig implements InitializingBean { @Value("${apiml.service.corsAllowedMethods:GET,HEAD,POST,PATCH,DELETE,PUT,OPTIONS}") private List corsAllowedMethods; - @Value("${apiml.service.corsDefaultAllowedOrigins:#{null}}") + @Value("${apiml.service.corsDefaultAllowedOrigins:#{'https://${apiml.service.hostname:localhost}:${apiml.service.port}'}}") private String corsDefaultAllowedOrigins; @Value("${apiml.service.corsDefaultAllowedHeaders:*}") @@ -124,16 +134,6 @@ public class ConnectionsConfig implements InitializingBean { @Value("${apiml.service.corsAllowedEndpoints:/gateway/**}") private final List corsEnabledEndpoints; - @Override - public void afterPropertiesSet() throws Exception { - if (corsDefaultAllowedOrigins == null || corsDefaultAllowedOrigins.isEmpty()) { - corsDefaultAllowedOrigins = "https://" + hostname + ":" + port; - } - if (corsDefaultAllowedHeaders == null || corsDefaultAllowedHeaders.isEmpty()) { - corsDefaultAllowedHeaders = CorsConfiguration.ALL; - } - } - /** * @param httpClient default http client * @param headersFiltersProvider header filter for spring gateway router diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/CorsPerServiceTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/CorsPerServiceTest.java index 33fa1f44a4..9b5941fc97 100644 --- a/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/CorsPerServiceTest.java +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/CorsPerServiceTest.java @@ -10,18 +10,31 @@ package org.zowe.apiml.gateway.acceptance; +import io.restassured.http.Header; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.NestedTestConfiguration; +import org.springframework.test.context.NestedTestConfiguration.EnclosingConfiguration; +import org.springframework.test.context.TestPropertySource; import org.zowe.apiml.gateway.acceptance.common.AcceptanceTestWithMockServices; import org.zowe.apiml.gateway.acceptance.common.MicroservicesAcceptanceTest; import java.io.IOException; import static io.restassured.RestAssured.given; +import static org.apache.http.HttpStatus.SC_FORBIDDEN; import static org.apache.http.HttpStatus.SC_OK; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; import static org.junit.jupiter.api.Assertions.assertNull; @MicroservicesAcceptanceTest +@TestPropertySource(properties = { + "apiml.service.corsEnabled=true", + "apiml.service.corsDefaultAllowedOrigins=https://foo.bar.org" +}) +@NestedTestConfiguration(EnclosingConfiguration.OVERRIDE) class CorsPerServiceTest extends AcceptanceTestWithMockServices { private static final String HEADER_X_FORWARD_TO = "X-Forward-To"; @@ -42,4 +55,146 @@ void routeToServiceWithCorsEnabled() throws IOException { .statusCode(is(SC_OK)); } + @Test + // Verify the header to allow CORS isn't set + // Verify there was no call to southbound service + void givenCorsIsDelegatedToGatewayButServiceDoesntAllowCors_whenPreflightRequestArrives_thenNoAccessControlAllowOriginIsSet() throws Exception { + // applicationRegistry.setCurrentApplication(serviceWithDefaultConfiguration.getId()); + // mockValid200HttpResponse(); + // discoveryClient.createRefreshCacheEvent(); + + given() + .header(new Header("Origin", "https://foo.bar.org")) + .header(new Header("Access-Control-Request-Method", "POST")) + .header(new Header("Access-Control-Request-Headers", "origin, x-requested-with")) + .when() + .options(basePath /*+ serviceWithDefaultConfiguration.getPath()*/) + .then() + .statusCode(is(SC_FORBIDDEN)) + .header("Access-Control-Allow-Origin", is(nullValue())); + + // verify(mockClient, never()).execute(ArgumentMatchers.any(HttpUriRequest.class)); + } + + @Test + // Verify the header to allow CORS isn't set + // Verify there was no call to southbound service + void givenCorsIsDelegatedToGatewayButServiceDoesntAllowCors_whenSimpleCorsRequestArrives_thenNoAccessControlAllowOriginIsSet() throws Exception { + // applicationRegistry.setCurrentApplication(serviceWithDefaultConfiguration.getId()); + // mockValid200HttpResponse(); + // discoveryClient.createRefreshCacheEvent(); + + given() + .header(new Header("Origin", "https://foo.bar.org")) + .header(new Header("Access-Control-Request-Method", "POST")) + .header(new Header("Access-Control-Request-Headers", "origin, x-requested-with")) + .when() + .post(basePath /*+ serviceWithDefaultConfiguration.getPath()*/) + .then() + .statusCode(is(SC_FORBIDDEN)) + .header("Access-Control-Allow-Origin", is(nullValue())); + + // verify(mockClient, never()).execute(ArgumentMatchers.any(HttpUriRequest.class)); + } + + @Test + // There is no request to the southbound server for preflight + // There is request to the southbound server for the second request + void givenCorsIsAllowedForSpecificService_whenPreFlightRequestArrives_thenCorsHeadersAreSet() throws Exception { + // mockValid200HttpResponse(); + // applicationRegistry.setCurrentApplication(serviceWithCustomConfiguration.getId()); + // discoveryClient.createRefreshCacheEvent(); + + // Preflight request + given() + .header(new Header("Origin", "https://foo.bar.org")) + .header(new Header("Access-Control-Request-Method", "POST")) + .header(new Header("Access-Control-Request-Headers", "origin, x-requested-with")) + .when() + .options(basePath /*+ serviceWithCustomConfiguration.getPath()*/) + .then() + .statusCode(is(SC_OK)) + .header("Access-Control-Allow-Origin", is("https://foo.bar.org")) + .header("Access-Control-Allow-Methods", is("GET,HEAD,POST,PATCH,DELETE,PUT,OPTIONS")) + .header("Access-Control-Allow-Headers", is("origin, x-requested-with")); + + // The preflight request isn't passed to the southbound service + // verify(mockClient, never()).execute(ArgumentMatchers.any(HttpUriRequest.class)); + + // Actual request + given() + .header(new Header("Origin", "https://foo.bar.org")) + .when() + .post(basePath /*+ serviceWithCustomConfiguration.getPath()*/) + .then() + .statusCode(is(SC_OK)) + .header("Access-Control-Allow-Origin", is("https://foo.bar.org")); + + // The actual request is passed to the southbound service + // verify(mockClient, times(1)).execute(ArgumentMatchers.any(HttpUriRequest.class)); + } + + @Test + // There is request to the southbound server for the request + // The CORS header is properly set. + void givenCorsIsAllowedForSpecificService_whenSimpleRequestArrives_thenCorsHeadersAreSet() throws Exception { + // There is request to the southbound server and the CORS headers are properly set on the response + // mockValid200HttpResponse(); + // applicationRegistry.setCurrentApplication(serviceWithCustomConfiguration.getId()); + // discoveryClient.createRefreshCacheEvent(); + + // Preflight request + given() + .header(new Header("Origin", "https://foo.bar.org")) // This can't work anymore with the defaults (cors enabled on gateway + service with cors enabled + default list of origins) + .when() + .get(basePath /*+ serviceWithCustomConfiguration.getPath()*/) + .then() + .statusCode(is(SC_OK)) + .header("Access-Control-Allow-Origin", is("https://foo.bar.org")); + + // The actual request is passed to the southbound service + // verify(mockClient, times(1)).execute(ArgumentMatchers.any(HttpUriRequest.class)); + } + + @Nested + @MicroservicesAcceptanceTest + @ActiveProfiles({"CorsPerServiceTestWithDefaults", "test"}) + @TestPropertySource(properties = { + "apiml.service.corsEnabled=true" + }) + class CorsPerServiceTestWithDefaults { + + @Test + void givenCorsIsDelegatedToGatewayButServiceDoesntAllowCors_whenSimpleCorsRequestArrives_thenNoAccessControlAllowOriginIsSet() throws Exception { + // applicationRegistry.setCurrentApplication(serviceWithDefaultConfiguration.getId()); + // mockValid200HttpResponse(); + // discoveryClient.createRefreshCacheEvent(); + + given() + .header(new Header("Origin", "https://foo.bar.org")) + .header(new Header("Access-Control-Request-Method", "POST")) + .header(new Header("Access-Control-Request-Headers", "origin, x-requested-with")) + .when() + .post(basePath /*+ serviceWithDefaultConfiguration.getPath()*/) + .then() + .statusCode(is(SC_FORBIDDEN)) + .header("Access-Control-Allow-Origin", is(nullValue())); + + // verify(mockClient, never()).execute(ArgumentMatchers.any(HttpUriRequest.class)); + } + + } + + @Nested + @MicroservicesAcceptanceTest + @ActiveProfiles({"CorsPerServiceTestWithDefaults", "test"}) + @TestPropertySource(properties = { + "apiml.service.corsEnabled=false" + }) + class CorsPerServiceTestWithDisabled { + + // the service should not receive the Origin header + + } + } diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/GatewayCorsTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/GatewayCorsTest.java new file mode 100644 index 0000000000..11ae41db9b --- /dev/null +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/GatewayCorsTest.java @@ -0,0 +1,235 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.gateway.acceptance.corsTests; + +import io.restassured.http.Header; +import org.apache.http.client.methods.HttpUriRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.NestedTestConfiguration; +import org.springframework.test.context.NestedTestConfiguration.EnclosingConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.zowe.apiml.gateway.MockService; +import org.zowe.apiml.gateway.MockService.Scope; +import org.zowe.apiml.gateway.acceptance.common.AcceptanceTestWithMockServices; +import org.zowe.apiml.gateway.acceptance.common.MicroservicesAcceptanceTest; +import com.sun.net.httpserver.Headers; + + +import static io.restassured.RestAssured.given; +import static org.apache.http.HttpStatus.SC_FORBIDDEN; +import static org.apache.http.HttpStatus.SC_OK; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.arrayWithSize; +import static org.hamcrest.Matchers.is; + +@NestedTestConfiguration(EnclosingConfiguration.OVERRIDE) +class GatewayCorsTest { + + @Nested + @MicroservicesAcceptanceTest + @ActiveProfiles({"GatewayCorsEnabledWithProvidedDefaultTest"}) + @TestPropertySource(properties = { + "apiml.service.corsDefaultAllowedOrigins=https://foo.bar.org", + "apiml.service.corsEnabled=true" + }) + class GatewayCorsEnabledWithProvidedDefaultTest extends AcceptanceTestWithMockServices { + + private MockService mockService; + + @BeforeEach + void setUp() { + var responseHeaders = new Headers(); + responseHeaders.add("Access-Control-Allow-Origin", "test"); + responseHeaders.add("Access-Control-Allow-Methods", "RANDOM"); + responseHeaders.add("Access-Control-Allow-Headers", "origin,x-test"); + responseHeaders.add("Access-Control-Allow-Credentials", "true"); + + mockService = mockService("servicewithcors") + .addEndpoint("/servicewithcors/fullheaders") + .headers(responseHeaders) + .and() + .scope(Scope.TEST) + .start(); + } + + @Test + // The CORS headers are properly set on the request + void givenCorsIsAllowedForSpecificService_whenPreFlightRequestArrives_thenCorsHeadersAreSet() { + // Preflight request + given() + .header(new Header("Origin", "https://foo.bar.org")) + .header(new Header("Access-Control-Request-Method", "POST")) + .header(new Header("Access-Control-Request-Headers", "origin, x-requested-with")) + .when() + .options(basePath + "/gateway/version") + .then() + .statusCode(is(SC_OK)) + .header("Access-Control-Allow-Origin","https://foo.bar.org") + .header("Access-Control-Allow-Methods", "GET,HEAD,POST,PATCH,DELETE,PUT,OPTIONS") + .header("Access-Control-Allow-Headers", "origin, x-requested-with"); + + // Actual request + given() + .header(new Header("Origin", "https://foo.bar.org")) + .when() + .get(basePath + "/gateway/version") + .then() + .statusCode(is(SC_OK)) + .header("Access-Control-Allow-Origin", "https://foo.bar.org"); + } + + @Test + void givenCorsOriginIsNotAllowed_whenPreFlightRequestArrives_thenCorsHeadersAreNotSet() throws Exception { + // Preflight request with disallowed origin + given() + .header(new Header("Origin", "https://malicious.example.com")) + .header(new Header("Access-Control-Request-Method", "POST")) + .header(new Header("Access-Control-Request-Headers", "origin, x-requested-with")) + .when() + .options(basePath + "/servicewithcors/api/v1/fullheaders") + .then() + .statusCode(is(SC_FORBIDDEN)) + .header("Access-Control-Allow-Origin", is((String) null)); + } + + @Test + // There is request to the southbound server for the request + // The CORS header is properly set. + void givenCorsIsAllowedForSpecificService_whenSimpleRequestArrives_thenCorsHeadersAreSetAndOnlyTheOnesByGateway() throws Exception { + // There is request to the southbound server and the CORS headers are properly set on the response + // Preflight request + given() + .header(new Header("Origin", "https://foo.bar.org")) + .when() + .get(basePath /*+ serviceWithCustomConfiguration.getPath()*/) + .then() + .statusCode(is(SC_OK)) + .header("Access-Control-Allow-Origin", is("https://foo.bar.org")); + + // The actual request is passed to the southbound service + // verify(mockClient, times(1)).execute(ArgumentMatchers.any(HttpUriRequest.class)); USE MOCK SERVICE ASSERTION? + } + + @Test + // There is no request to the southbound server for preflight + // There is request to the southbound server for the second request + void givenCorsIsAllowedForSpecificService_whenTheServiceIsSet_thenCorsHeadersAreSetAndOnlyTheOnesByGateway() throws Exception { + // mockValid200HttpResponseWithAddedCors(); + + // Preflight request + given() + .header(new Header("Origin", "https://foo.bar.org")) + .header(new Header("Access-Control-Request-Method", "POST")) + .header(new Header("Access-Control-Request-Headers", "origin, x-requested-with")) + .when() + .options(basePath /*+ serviceWithCustomConfiguration.getPath()*/) + .then() + .statusCode(is(SC_OK)) + .header("Access-Control-Allow-Origin", is("https://foo.bar.org")) + .header("Access-Control-Allow-Methods", is("GET,HEAD,POST,PATCH,DELETE,PUT,OPTIONS")) + .header("Access-Control-Allow-Headers", is("origin, x-requested-with")); + + // The preflight request isn't passed to the southbound service + // verify(mockClient, never()).execute(ArgumentMatchers.any(HttpUriRequest.class)); + + // Actual request + given() + .header(new Header("Origin", "https://foo.bar.org")) + .when() + .post(basePath /*+ serviceWithCustomConfiguration.getPath()*/) + .then() + .statusCode(is(SC_OK)) + .header("Access-Control-Allow-Origin", is("https://foo.bar.org")); + + // The actual request is passed to the southbound service + // verify(mockClient, times(1)).execute(ArgumentMatchers.any(HttpUriRequest.class)); + } + + @Test + void givenCorsIsEnabled_whenRequestWithOriginComes_thenOriginIsntPassedToSouthbound() throws Exception { + // There is request to the southbound server and the CORS headers are properly set on the response + // mockValid200HttpResponseWithAddedCors(); + + // Simple request + given() + .header(new Header("Origin", "https://foo.bar.org")) + .when() + .get(basePath /*+ serviceWithCustomConfiguration.getPath()*/) + .then() + .statusCode(is(SC_OK)) + .header("Access-Control-Allow-Origin", is("https://foo.bar.org")); + + // The actual request is passed to the southbound service + // verify(mockClient, times(1)).execute(ArgumentMatchers.any(HttpUriRequest.class)); + + var captor = ArgumentCaptor.forClass(HttpUriRequest.class); + // verify(mockClient, times(1)).execute(captor.capture()); + + HttpUriRequest toVerify = captor.getValue(); + org.apache.http.Header[] originHeaders = toVerify.getHeaders("Origin"); + assertThat(originHeaders, arrayWithSize(0)); + } + + } + + @Nested + @MicroservicesAcceptanceTest + @ActiveProfiles({"GatewayCorsEnabledWithDefaultsTest"}) + @TestPropertySource(properties = { + "apiml.service.corsEnabled=true" + }) + class GatewayCorsEnabledWithDefaultsTest extends AcceptanceTestWithMockServices { + // Gateway uses a default list of origins, does not accept any (*) + + @Test + void givenCorsIsEnabledWithDefaults_whenPreflightRequestComes_thenPreflightIsRejected() throws Exception { + // Preflight request with origin that should be rejected by default CORS policy + given() + .log().all() + .header(new Header("Origin", "https://foo.bar.org")) + .header(new Header("Access-Control-Request-Method", "POST")) + .header(new Header("Access-Control-Request-Headers", "Content-Type")) + .when() + .options(basePath /*+ serviceWithCustomConfiguration.getPath()*/) + .then() + .log().all() + .statusCode(is(SC_FORBIDDEN)); + + // No request should be passed to the southbound service for preflight + // verify(mockClient, times(0)).execute(ArgumentMatchers.any(HttpUriRequest.class)); + } + + @Test + void givenCorsIsEnabledWithDefaults_whenPreflightRequestWithLocalhostOriginComes_thenPreflightIsAccepted() throws Exception { + // Preflight request with localhost origin that should be accepted by default CORS policy + given() + .log().all() + .header(new Header("Origin", "https://localhost:" + port)) + .header(new Header("Access-Control-Request-Method", "POST")) + .header(new Header("Access-Control-Request-Headers", "Content-Type")) + .when() + .options(basePath /*+ serviceWithCustomConfiguration.getPath()*/) + .then() + .log().all() + .statusCode(is(SC_OK)); + + // No request should be passed to the southbound service for preflight + // verify(mockClient, times(0)).execute(ArgumentMatchers.any(HttpUriRequest.class)); + } + + } + +} + From 3dabb0d20cd8b7dbee38ac0b0c17898e4a3aa5cd Mon Sep 17 00:00:00 2001 From: Pablo Carle Date: Wed, 1 Jul 2026 17:46:10 +0200 Subject: [PATCH 09/15] wip adapt cors acceptance tests Signed-off-by: Pablo Carle --- .../acceptance/corsTests/GatewayCorsTest.java | 112 +++++++++++------- 1 file changed, 71 insertions(+), 41 deletions(-) diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/GatewayCorsTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/GatewayCorsTest.java index 11ae41db9b..e06ac12fa3 100644 --- a/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/GatewayCorsTest.java +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/GatewayCorsTest.java @@ -10,29 +10,33 @@ package org.zowe.apiml.gateway.acceptance.corsTests; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; import io.restassured.http.Header; -import org.apache.http.client.methods.HttpUriRequest; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.NestedTestConfiguration; import org.springframework.test.context.NestedTestConfiguration.EnclosingConfiguration; import org.springframework.test.context.TestPropertySource; -import org.zowe.apiml.gateway.MockService; +import org.zowe.apiml.gateway.MockService.MockServiceBuilder; import org.zowe.apiml.gateway.MockService.Scope; import org.zowe.apiml.gateway.acceptance.common.AcceptanceTestWithMockServices; import org.zowe.apiml.gateway.acceptance.common.MicroservicesAcceptanceTest; -import com.sun.net.httpserver.Headers; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; import static io.restassured.RestAssured.given; import static org.apache.http.HttpStatus.SC_FORBIDDEN; import static org.apache.http.HttpStatus.SC_OK; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.arrayWithSize; import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; @NestedTestConfiguration(EnclosingConfiguration.OVERRIDE) class GatewayCorsTest { @@ -46,22 +50,23 @@ class GatewayCorsTest { }) class GatewayCorsEnabledWithProvidedDefaultTest extends AcceptanceTestWithMockServices { - private MockService mockService; - - @BeforeEach - void setUp() { - var responseHeaders = new Headers(); - responseHeaders.add("Access-Control-Allow-Origin", "test"); - responseHeaders.add("Access-Control-Allow-Methods", "RANDOM"); - responseHeaders.add("Access-Control-Allow-Headers", "origin,x-test"); - responseHeaders.add("Access-Control-Allow-Credentials", "true"); - - mockService = mockService("servicewithcors") - .addEndpoint("/servicewithcors/fullheaders") - .headers(responseHeaders) - .and() - .scope(Scope.TEST) - .start(); + private MockServiceBuilder mockCorsService(String serviceId, Headers responseHeaders, Map metadata, Collection> assertions) { + var builder = mockService(serviceId); + var endpointBuilder = builder.addEndpoint("/" + serviceId + "/fullheaders"); + + if (responseHeaders != null) { + endpointBuilder.headers(responseHeaders); + } + if (metadata != null) { + builder.additionalMetadata(metadata); + } + if (assertions != null) { + endpointBuilder.assertions(assertions); + } + + return endpointBuilder + .and() + .scope(Scope.TEST); } @Test @@ -92,13 +97,15 @@ void givenCorsIsAllowedForSpecificService_whenPreFlightRequestArrives_thenCorsHe @Test void givenCorsOriginIsNotAllowed_whenPreFlightRequestArrives_thenCorsHeadersAreNotSet() throws Exception { + mockCorsService("servicecors1", null, Map.of("apiml.corsEnabled", "true"), null).start(); + // Preflight request with disallowed origin given() .header(new Header("Origin", "https://malicious.example.com")) .header(new Header("Access-Control-Request-Method", "POST")) .header(new Header("Access-Control-Request-Headers", "origin, x-requested-with")) .when() - .options(basePath + "/servicewithcors/api/v1/fullheaders") + .options(basePath + "/servicecors1/api/v1/fullheaders") .then() .statusCode(is(SC_FORBIDDEN)) .header("Access-Control-Allow-Origin", is((String) null)); @@ -108,25 +115,43 @@ void givenCorsOriginIsNotAllowed_whenPreFlightRequestArrives_thenCorsHeadersAreN // There is request to the southbound server for the request // The CORS header is properly set. void givenCorsIsAllowedForSpecificService_whenSimpleRequestArrives_thenCorsHeadersAreSetAndOnlyTheOnesByGateway() throws Exception { + var headers = new Headers(); + var called = new AtomicBoolean(false); + List> assertions = List.of( + httpExchange -> { + called.set(true); + } + ); + + mockCorsService("servicecors2", headers, Map.of("apiml.corsEnabled", "true"), assertions).start(); // There is request to the southbound server and the CORS headers are properly set on the response // Preflight request given() .header(new Header("Origin", "https://foo.bar.org")) .when() - .get(basePath /*+ serviceWithCustomConfiguration.getPath()*/) + .get(basePath + "/servicecors2/api/v1/fullheaders") .then() .statusCode(is(SC_OK)) .header("Access-Control-Allow-Origin", is("https://foo.bar.org")); // The actual request is passed to the southbound service - // verify(mockClient, times(1)).execute(ArgumentMatchers.any(HttpUriRequest.class)); USE MOCK SERVICE ASSERTION? + assertTrue(called.get()); } @Test // There is no request to the southbound server for preflight // There is request to the southbound server for the second request void givenCorsIsAllowedForSpecificService_whenTheServiceIsSet_thenCorsHeadersAreSetAndOnlyTheOnesByGateway() throws Exception { - // mockValid200HttpResponseWithAddedCors(); + var headers = new Headers(); + + var called = new AtomicBoolean(false); + List> assertions = List.of( + httpExchange -> { + called.set(true); + } + ); + + mockCorsService("servicecors3", headers, Map.of("apiml.corsEnabled", "true"), assertions).start(); // Preflight request given() @@ -134,7 +159,7 @@ void givenCorsIsAllowedForSpecificService_whenTheServiceIsSet_thenCorsHeadersAre .header(new Header("Access-Control-Request-Method", "POST")) .header(new Header("Access-Control-Request-Headers", "origin, x-requested-with")) .when() - .options(basePath /*+ serviceWithCustomConfiguration.getPath()*/) + .options(basePath + "/servicecors3/api/v1/fullheaders") .then() .statusCode(is(SC_OK)) .header("Access-Control-Allow-Origin", is("https://foo.bar.org")) @@ -142,44 +167,47 @@ void givenCorsIsAllowedForSpecificService_whenTheServiceIsSet_thenCorsHeadersAre .header("Access-Control-Allow-Headers", is("origin, x-requested-with")); // The preflight request isn't passed to the southbound service - // verify(mockClient, never()).execute(ArgumentMatchers.any(HttpUriRequest.class)); + assertFalse(called.get()); // Actual request given() .header(new Header("Origin", "https://foo.bar.org")) .when() - .post(basePath /*+ serviceWithCustomConfiguration.getPath()*/) + .post(basePath + "/servicecors3/api/v1/fullheaders") .then() .statusCode(is(SC_OK)) .header("Access-Control-Allow-Origin", is("https://foo.bar.org")); // The actual request is passed to the southbound service - // verify(mockClient, times(1)).execute(ArgumentMatchers.any(HttpUriRequest.class)); + assertTrue(called.get()); } @Test void givenCorsIsEnabled_whenRequestWithOriginComes_thenOriginIsntPassedToSouthbound() throws Exception { // There is request to the southbound server and the CORS headers are properly set on the response - // mockValid200HttpResponseWithAddedCors(); + var headers = new Headers(); + + var called = new AtomicBoolean(false); + List> assertions = List.of( + httpExchange -> { + called.set(true); + assertNull(httpExchange.getRequestHeaders().get("Origin")); + } + ); + + mockCorsService("servicecors4", headers, Map.of("apiml.corsEnabled", "true"), assertions).start(); // Simple request given() .header(new Header("Origin", "https://foo.bar.org")) .when() - .get(basePath /*+ serviceWithCustomConfiguration.getPath()*/) + .get(basePath + "/servicecors4/api/v1/fullheaders") .then() .statusCode(is(SC_OK)) .header("Access-Control-Allow-Origin", is("https://foo.bar.org")); // The actual request is passed to the southbound service - // verify(mockClient, times(1)).execute(ArgumentMatchers.any(HttpUriRequest.class)); - - var captor = ArgumentCaptor.forClass(HttpUriRequest.class); - // verify(mockClient, times(1)).execute(captor.capture()); - - HttpUriRequest toVerify = captor.getValue(); - org.apache.http.Header[] originHeaders = toVerify.getHeaders("Origin"); - assertThat(originHeaders, arrayWithSize(0)); + assertTrue(called.get()); } } @@ -193,6 +221,8 @@ void givenCorsIsEnabled_whenRequestWithOriginComes_thenOriginIsntPassedToSouthbo class GatewayCorsEnabledWithDefaultsTest extends AcceptanceTestWithMockServices { // Gateway uses a default list of origins, does not accept any (*) + // TODO finish adapting these tests + @Test void givenCorsIsEnabledWithDefaults_whenPreflightRequestComes_thenPreflightIsRejected() throws Exception { // Preflight request with origin that should be rejected by default CORS policy From a1698e1d75ef7de443902957b765ee0b4011e3ef Mon Sep 17 00:00:00 2001 From: Pablo Carle Date: Tue, 7 Jul 2026 08:45:31 +0200 Subject: [PATCH 10/15] wip Signed-off-by: Pablo Carle --- apiml/src/main/resources/application.yml | 2 +- apiml/src/test/resources/application.yml | 6 +++--- .../src/main/java/org/zowe/apiml/util/CorsUtils.java | 4 ++-- gateway-service/src/main/resources/application.yml | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apiml/src/main/resources/application.yml b/apiml/src/main/resources/application.yml index b896566c1e..27eddc5d40 100644 --- a/apiml/src/main/resources/application.yml +++ b/apiml/src/main/resources/application.yml @@ -197,7 +197,7 @@ apiml: forwardClientCertEnabled: false hostname: localhost id: ${spring.application.name} - ignoredHeadersWhenCorsEnabled: Access-Control-Request-Method,Access-Control-Request-Headers,Access-Control-Allow-Origin,Access-Control-Allow-Methods,Access-Control-Allow-Headers,Access-Control-Allow-Credentials,Origin + ignoredHeadersWhenCorsEnabled: Access-Control-Request-Method,Access-Control-Request-Headers,Origin port: 10010 scheme: https # "https" or "http" preferIpAddress: false diff --git a/apiml/src/test/resources/application.yml b/apiml/src/test/resources/application.yml index edcea1724c..1b3e0f3e35 100644 --- a/apiml/src/test/resources/application.yml +++ b/apiml/src/test/resources/application.yml @@ -1,8 +1,8 @@ logging: level: - ROOT: INFO - #org.springframework: DEBUG - #org.springframework.boot.autoconfigure.web: DEBUG + ROOT: DEBUG + org.springframework: DEBUG + org.springframework.boot.autoconfigure.web: DEBUG org.springframework.cloud.gateway: DEBUG org.springframework.http.server.reactive: DEBUG org.springframework.web.reactive: DEBUG diff --git a/common-service-core/src/main/java/org/zowe/apiml/util/CorsUtils.java b/common-service-core/src/main/java/org/zowe/apiml/util/CorsUtils.java index 28cef74088..5f758eb3fd 100644 --- a/common-service-core/src/main/java/org/zowe/apiml/util/CorsUtils.java +++ b/common-service-core/src/main/java/org/zowe/apiml/util/CorsUtils.java @@ -78,8 +78,8 @@ private CorsConfiguration createCorsConfigurationForService(String serviceId, Ma var corsAllowedOriginsForService = metadata.get("apiml.corsAllowedOrigins"); var allowedHeadersForService = metadata.get("apiml.corsAllowedHeaders"); - var allowedCredentialsForService = metadata.get(""); - var allowedMethodsForService = metadata.get(""); + var allowedCredentialsForService = metadata.get("apiml.corsAllowedCredentials"); + var allowedMethodsForService = metadata.get("apiml.corsAllowedMethods"); if (isNotBlank(corsAllowedOriginsForService)) { // Origins specified: split by comma, add to whitelist diff --git a/gateway-service/src/main/resources/application.yml b/gateway-service/src/main/resources/application.yml index adcea536e4..ee4bbd68dc 100644 --- a/gateway-service/src/main/resources/application.yml +++ b/gateway-service/src/main/resources/application.yml @@ -105,7 +105,7 @@ apiml: forwardClientCertEnabled: true hostname: localhost id: ${spring.application.name} - ignoredHeadersWhenCorsEnabled: Access-Control-Request-Method,Access-Control-Request-Headers,Access-Control-Allow-Origin,Access-Control-Allow-Methods,Access-Control-Allow-Headers,Access-Control-Allow-Credentials,Origin + ignoredHeadersWhenCorsEnabled: Access-Control-Request-Method,Access-Control-Request-Headers,Origin port: 10010 scheme: https # "https" or "http" preferIpAddress: false From 725d2454e63d11225c2fa72262f2dabd2eb620c1 Mon Sep 17 00:00:00 2001 From: Pablo Carle Date: Tue, 7 Jul 2026 12:15:03 +0200 Subject: [PATCH 11/15] wip acceptance tests Signed-off-by: Pablo Carle --- .../acceptance/CorsPerServiceTest.java | 163 ++++++++++++------ .../acceptance/corsTests/GatewayCorsTest.java | 115 ++++++++---- 2 files changed, 190 insertions(+), 88 deletions(-) diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/CorsPerServiceTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/CorsPerServiceTest.java index 9b5941fc97..89adbadf45 100644 --- a/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/CorsPerServiceTest.java +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/CorsPerServiceTest.java @@ -10,6 +10,9 @@ package org.zowe.apiml.gateway.acceptance; +import com.google.common.net.HttpHeaders; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; import io.restassured.http.Header; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -17,17 +20,26 @@ import org.springframework.test.context.NestedTestConfiguration; import org.springframework.test.context.NestedTestConfiguration.EnclosingConfiguration; import org.springframework.test.context.TestPropertySource; +import org.zowe.apiml.gateway.MockService.MockServiceBuilder; +import org.zowe.apiml.gateway.MockService.Scope; import org.zowe.apiml.gateway.acceptance.common.AcceptanceTestWithMockServices; import org.zowe.apiml.gateway.acceptance.common.MicroservicesAcceptanceTest; import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; import static io.restassured.RestAssured.given; import static org.apache.http.HttpStatus.SC_FORBIDDEN; import static org.apache.http.HttpStatus.SC_OK; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; @MicroservicesAcceptanceTest @TestPropertySource(properties = { @@ -39,15 +51,34 @@ class CorsPerServiceTest extends AcceptanceTestWithMockServices { private static final String HEADER_X_FORWARD_TO = "X-Forward-To"; + private MockServiceBuilder mockCorsService(String serviceId, Headers responseHeaders, Map metadata, Collection> assertions) { + var builder = mockService(serviceId); + var endpointBuilder = builder.addEndpoint("/" + serviceId + "/fullheaders"); + + if (responseHeaders != null) { + endpointBuilder.headers(responseHeaders); + } + if (metadata != null) { + builder.additionalMetadata(metadata); + } + if (assertions != null) { + endpointBuilder.assertions(assertions); + } + + return endpointBuilder + .and() + .scope(Scope.TEST); + } + @Test void routeToServiceWithCorsEnabled() throws IOException { mockService("serviceid1") .addEndpoint("/test") - .assertion(he -> assertNull(he.getRequestHeaders().getFirst("Origin"))) + .assertion(he -> assertNull(he.getRequestHeaders().getFirst(HttpHeaders.ORIGIN))) .and().start(); given() - .header("Origin", "https://localhost:3000") + .header(HttpHeaders.ORIGIN, "https://localhost:3000") .header(HEADER_X_FORWARD_TO, "serviceid1") .when() .get(basePath + "/test") @@ -59,79 +90,97 @@ void routeToServiceWithCorsEnabled() throws IOException { // Verify the header to allow CORS isn't set // Verify there was no call to southbound service void givenCorsIsDelegatedToGatewayButServiceDoesntAllowCors_whenPreflightRequestArrives_thenNoAccessControlAllowOriginIsSet() throws Exception { - // applicationRegistry.setCurrentApplication(serviceWithDefaultConfiguration.getId()); - // mockValid200HttpResponse(); - // discoveryClient.createRefreshCacheEvent(); + var headers = new Headers(); + var called = new AtomicBoolean(false); + List> assertions = List.of( + httpExchange -> { + assertNull(httpExchange.getRequestHeaders().get(HttpHeaders.ORIGIN)); + called.set(true); + } + ); + mockCorsService("servicecors1", headers, Map.of("apiml.corsEnabled", "false"), assertions).start(); given() - .header(new Header("Origin", "https://foo.bar.org")) - .header(new Header("Access-Control-Request-Method", "POST")) - .header(new Header("Access-Control-Request-Headers", "origin, x-requested-with")) + .header(new Header(HttpHeaders.ORIGIN, "https://foo.bar.org")) + .header(new Header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")) + .header(new Header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "origin, x-requested-with")) .when() - .options(basePath /*+ serviceWithDefaultConfiguration.getPath()*/) + .options(basePath + "/servicecors1/api/v1/fullheaders") .then() .statusCode(is(SC_FORBIDDEN)) - .header("Access-Control-Allow-Origin", is(nullValue())); + .header(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, is(nullValue())); - // verify(mockClient, never()).execute(ArgumentMatchers.any(HttpUriRequest.class)); + assertFalse(called.get()); } @Test // Verify the header to allow CORS isn't set // Verify there was no call to southbound service void givenCorsIsDelegatedToGatewayButServiceDoesntAllowCors_whenSimpleCorsRequestArrives_thenNoAccessControlAllowOriginIsSet() throws Exception { - // applicationRegistry.setCurrentApplication(serviceWithDefaultConfiguration.getId()); - // mockValid200HttpResponse(); - // discoveryClient.createRefreshCacheEvent(); + var headers = new Headers(); + var called = new AtomicBoolean(false); + List> assertions = List.of( + httpExchange -> { + assertNull(httpExchange.getRequestHeaders().get(HttpHeaders.ORIGIN)); + called.set(true); + } + ); + mockCorsService("servicecors2", headers, Map.of("apiml.corsEnabled", "false"), assertions).start(); given() - .header(new Header("Origin", "https://foo.bar.org")) - .header(new Header("Access-Control-Request-Method", "POST")) - .header(new Header("Access-Control-Request-Headers", "origin, x-requested-with")) + .header(new Header(HttpHeaders.ORIGIN, "https://foo.bar.org")) + .header(new Header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")) + .header(new Header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "origin, x-requested-with")) .when() - .post(basePath /*+ serviceWithDefaultConfiguration.getPath()*/) + .post(basePath + "/servicecors2/api/v1/fullheaders") .then() .statusCode(is(SC_FORBIDDEN)) - .header("Access-Control-Allow-Origin", is(nullValue())); + .header(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, is(nullValue())); - // verify(mockClient, never()).execute(ArgumentMatchers.any(HttpUriRequest.class)); + assertFalse(called.get()); } @Test // There is no request to the southbound server for preflight // There is request to the southbound server for the second request void givenCorsIsAllowedForSpecificService_whenPreFlightRequestArrives_thenCorsHeadersAreSet() throws Exception { - // mockValid200HttpResponse(); - // applicationRegistry.setCurrentApplication(serviceWithCustomConfiguration.getId()); - // discoveryClient.createRefreshCacheEvent(); + var headers = new Headers(); + var called = new AtomicBoolean(false); + List> assertions = List.of( + httpExchange -> { + assertNull(httpExchange.getRequestHeaders().get(HttpHeaders.ORIGIN)); + called.set(true); + } + ); + mockCorsService("servicecors3", headers, Map.of("apiml.corsEnabled", "false"), assertions).start(); // Preflight request given() - .header(new Header("Origin", "https://foo.bar.org")) - .header(new Header("Access-Control-Request-Method", "POST")) - .header(new Header("Access-Control-Request-Headers", "origin, x-requested-with")) + .header(new Header(HttpHeaders.ORIGIN, "https://foo.bar.org")) + .header(new Header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")) + .header(new Header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "origin, x-requested-with")) .when() - .options(basePath /*+ serviceWithCustomConfiguration.getPath()*/) + .options(basePath + "/servicecors3/api/v1/fullheaders") .then() .statusCode(is(SC_OK)) - .header("Access-Control-Allow-Origin", is("https://foo.bar.org")) - .header("Access-Control-Allow-Methods", is("GET,HEAD,POST,PATCH,DELETE,PUT,OPTIONS")) - .header("Access-Control-Allow-Headers", is("origin, x-requested-with")); + .header(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, is("https://foo.bar.org")) + .header(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, is("GET,HEAD,POST,PATCH,DELETE,PUT,OPTIONS")) + .header(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, is("origin, x-requested-with")); // The preflight request isn't passed to the southbound service - // verify(mockClient, never()).execute(ArgumentMatchers.any(HttpUriRequest.class)); + assertFalse(called.get()); // Actual request given() - .header(new Header("Origin", "https://foo.bar.org")) + .header(new Header(HttpHeaders.ORIGIN, "https://foo.bar.org")) .when() - .post(basePath /*+ serviceWithCustomConfiguration.getPath()*/) + .post(basePath + "/servicecors3/api/v1/fullheaders") .then() .statusCode(is(SC_OK)) - .header("Access-Control-Allow-Origin", is("https://foo.bar.org")); + .header(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, is("https://foo.bar.org")); // The actual request is passed to the southbound service - // verify(mockClient, times(1)).execute(ArgumentMatchers.any(HttpUriRequest.class)); + assertTrue(called.get()); } @Test @@ -139,21 +188,27 @@ void givenCorsIsAllowedForSpecificService_whenPreFlightRequestArrives_thenCorsHe // The CORS header is properly set. void givenCorsIsAllowedForSpecificService_whenSimpleRequestArrives_thenCorsHeadersAreSet() throws Exception { // There is request to the southbound server and the CORS headers are properly set on the response - // mockValid200HttpResponse(); - // applicationRegistry.setCurrentApplication(serviceWithCustomConfiguration.getId()); - // discoveryClient.createRefreshCacheEvent(); + var headers = new Headers(); + var called = new AtomicBoolean(false); + List> assertions = List.of( + httpExchange -> { + assertNull(httpExchange.getRequestHeaders().get(HttpHeaders.ORIGIN)); + called.set(true); + } + ); + mockCorsService("servicecors4", headers, Map.of("apiml.corsEnabled", "false"), assertions).start(); // Preflight request given() - .header(new Header("Origin", "https://foo.bar.org")) // This can't work anymore with the defaults (cors enabled on gateway + service with cors enabled + default list of origins) + .header(new Header(HttpHeaders.ORIGIN, "https://foo.bar.org")) // This can't work anymore with the defaults (cors enabled on gateway + service with cors enabled + default list of origins) .when() - .get(basePath /*+ serviceWithCustomConfiguration.getPath()*/) + .get(basePath + "/servicecors4/api/v1/fullheaders") .then() .statusCode(is(SC_OK)) - .header("Access-Control-Allow-Origin", is("https://foo.bar.org")); + .header(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, is("https://foo.bar.org")); // The actual request is passed to the southbound service - // verify(mockClient, times(1)).execute(ArgumentMatchers.any(HttpUriRequest.class)); + assertTrue(called.get() ); } @Nested @@ -166,21 +221,27 @@ class CorsPerServiceTestWithDefaults { @Test void givenCorsIsDelegatedToGatewayButServiceDoesntAllowCors_whenSimpleCorsRequestArrives_thenNoAccessControlAllowOriginIsSet() throws Exception { - // applicationRegistry.setCurrentApplication(serviceWithDefaultConfiguration.getId()); - // mockValid200HttpResponse(); - // discoveryClient.createRefreshCacheEvent(); + var headers = new Headers(); + var called = new AtomicBoolean(false); + List> assertions = List.of( + httpExchange -> { + assertNull(httpExchange.getRequestHeaders().get(HttpHeaders.ORIGIN)); + called.set(true); + } + ); + mockCorsService("servicecors5", headers, Map.of("apiml.corsEnabled", "false"), assertions).start(); given() - .header(new Header("Origin", "https://foo.bar.org")) - .header(new Header("Access-Control-Request-Method", "POST")) - .header(new Header("Access-Control-Request-Headers", "origin, x-requested-with")) + .header(new Header(HttpHeaders.ORIGIN, "https://foo.bar.org")) + .header(new Header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")) + .header(new Header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "origin, x-requested-with")) .when() - .post(basePath /*+ serviceWithDefaultConfiguration.getPath()*/) + .post(basePath + "/servicecors5/api/v1/fullheaders") .then() .statusCode(is(SC_FORBIDDEN)) - .header("Access-Control-Allow-Origin", is(nullValue())); + .header(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, is(nullValue())); - // verify(mockClient, never()).execute(ArgumentMatchers.any(HttpUriRequest.class)); + assertFalse(called.get()); } } diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/GatewayCorsTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/GatewayCorsTest.java index e06ac12fa3..3b25bb9f02 100644 --- a/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/GatewayCorsTest.java +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/GatewayCorsTest.java @@ -10,6 +10,7 @@ package org.zowe.apiml.gateway.acceptance.corsTests; +import com.google.common.net.HttpHeaders; import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.HttpExchange; import io.restassured.http.Header; @@ -74,25 +75,25 @@ private MockServiceBuilder mockCorsService(String serviceId, Headers responseHea void givenCorsIsAllowedForSpecificService_whenPreFlightRequestArrives_thenCorsHeadersAreSet() { // Preflight request given() - .header(new Header("Origin", "https://foo.bar.org")) - .header(new Header("Access-Control-Request-Method", "POST")) - .header(new Header("Access-Control-Request-Headers", "origin, x-requested-with")) + .header(new Header(HttpHeaders.ORIGIN, "https://foo.bar.org")) + .header(new Header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")) + .header(new Header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "origin, x-requested-with")) .when() .options(basePath + "/gateway/version") .then() .statusCode(is(SC_OK)) - .header("Access-Control-Allow-Origin","https://foo.bar.org") - .header("Access-Control-Allow-Methods", "GET,HEAD,POST,PATCH,DELETE,PUT,OPTIONS") - .header("Access-Control-Allow-Headers", "origin, x-requested-with"); + .header(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN,"https://foo.bar.org") + .header(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, "GET,HEAD,POST,PATCH,DELETE,PUT,OPTIONS") + .header(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "origin, x-requested-with"); // Actual request given() - .header(new Header("Origin", "https://foo.bar.org")) + .header(new Header(HttpHeaders.ORIGIN, "https://foo.bar.org")) .when() .get(basePath + "/gateway/version") .then() .statusCode(is(SC_OK)) - .header("Access-Control-Allow-Origin", "https://foo.bar.org"); + .header(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "https://foo.bar.org"); } @Test @@ -101,14 +102,14 @@ void givenCorsOriginIsNotAllowed_whenPreFlightRequestArrives_thenCorsHeadersAreN // Preflight request with disallowed origin given() - .header(new Header("Origin", "https://malicious.example.com")) - .header(new Header("Access-Control-Request-Method", "POST")) - .header(new Header("Access-Control-Request-Headers", "origin, x-requested-with")) + .header(new Header(HttpHeaders.ORIGIN, "https://malicious.example.com")) + .header(new Header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")) + .header(new Header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "origin, x-requested-with")) .when() .options(basePath + "/servicecors1/api/v1/fullheaders") .then() .statusCode(is(SC_FORBIDDEN)) - .header("Access-Control-Allow-Origin", is((String) null)); + .header(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, is((String) null)); } @Test @@ -127,12 +128,12 @@ void givenCorsIsAllowedForSpecificService_whenSimpleRequestArrives_thenCorsHeade // There is request to the southbound server and the CORS headers are properly set on the response // Preflight request given() - .header(new Header("Origin", "https://foo.bar.org")) + .header(new Header(HttpHeaders.ORIGIN, "https://foo.bar.org")) .when() .get(basePath + "/servicecors2/api/v1/fullheaders") .then() .statusCode(is(SC_OK)) - .header("Access-Control-Allow-Origin", is("https://foo.bar.org")); + .header(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, is("https://foo.bar.org")); // The actual request is passed to the southbound service assertTrue(called.get()); @@ -155,28 +156,28 @@ void givenCorsIsAllowedForSpecificService_whenTheServiceIsSet_thenCorsHeadersAre // Preflight request given() - .header(new Header("Origin", "https://foo.bar.org")) - .header(new Header("Access-Control-Request-Method", "POST")) - .header(new Header("Access-Control-Request-Headers", "origin, x-requested-with")) + .header(new Header(HttpHeaders.ORIGIN, "https://foo.bar.org")) + .header(new Header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")) + .header(new Header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "origin, x-requested-with")) .when() .options(basePath + "/servicecors3/api/v1/fullheaders") .then() .statusCode(is(SC_OK)) - .header("Access-Control-Allow-Origin", is("https://foo.bar.org")) - .header("Access-Control-Allow-Methods", is("GET,HEAD,POST,PATCH,DELETE,PUT,OPTIONS")) - .header("Access-Control-Allow-Headers", is("origin, x-requested-with")); + .header(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, is("https://foo.bar.org")) + .header(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, is("GET,HEAD,POST,PATCH,DELETE,PUT,OPTIONS")) + .header(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, is("origin, x-requested-with")); // The preflight request isn't passed to the southbound service assertFalse(called.get()); // Actual request given() - .header(new Header("Origin", "https://foo.bar.org")) + .header(new Header(HttpHeaders.ORIGIN, "https://foo.bar.org")) .when() .post(basePath + "/servicecors3/api/v1/fullheaders") .then() .statusCode(is(SC_OK)) - .header("Access-Control-Allow-Origin", is("https://foo.bar.org")); + .header(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, is("https://foo.bar.org")); // The actual request is passed to the southbound service assertTrue(called.get()); @@ -191,7 +192,7 @@ void givenCorsIsEnabled_whenRequestWithOriginComes_thenOriginIsntPassedToSouthbo List> assertions = List.of( httpExchange -> { called.set(true); - assertNull(httpExchange.getRequestHeaders().get("Origin")); + assertNull(httpExchange.getRequestHeaders().get(HttpHeaders.ORIGIN)); } ); @@ -199,12 +200,12 @@ void givenCorsIsEnabled_whenRequestWithOriginComes_thenOriginIsntPassedToSouthbo // Simple request given() - .header(new Header("Origin", "https://foo.bar.org")) + .header(new Header(HttpHeaders.ORIGIN, "https://foo.bar.org")) .when() .get(basePath + "/servicecors4/api/v1/fullheaders") .then() .statusCode(is(SC_OK)) - .header("Access-Control-Allow-Origin", is("https://foo.bar.org")); + .header(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, is("https://foo.bar.org")); // The actual request is passed to the southbound service assertTrue(called.get()); @@ -219,44 +220,84 @@ void givenCorsIsEnabled_whenRequestWithOriginComes_thenOriginIsntPassedToSouthbo "apiml.service.corsEnabled=true" }) class GatewayCorsEnabledWithDefaultsTest extends AcceptanceTestWithMockServices { - // Gateway uses a default list of origins, does not accept any (*) - // TODO finish adapting these tests + private MockServiceBuilder mockCorsService(String serviceId, Headers responseHeaders, Map metadata, Collection> assertions) { + var builder = mockService(serviceId); + var endpointBuilder = builder.addEndpoint("/" + serviceId + "/fullheaders"); + + if (responseHeaders != null) { + endpointBuilder.headers(responseHeaders); + } + if (metadata != null) { + builder.additionalMetadata(metadata); + } + if (assertions != null) { + endpointBuilder.assertions(assertions); + } + + return endpointBuilder + .and() + .scope(Scope.TEST); + } + // Gateway uses a default list of origins, does not accept any (*) @Test void givenCorsIsEnabledWithDefaults_whenPreflightRequestComes_thenPreflightIsRejected() throws Exception { + var headers = new Headers(); + + var called = new AtomicBoolean(false); + List> assertions = List.of( + httpExchange -> { + assertNull(httpExchange.getRequestHeaders().get(HttpHeaders.ORIGIN)); + called.set(true); + } + ); + + mockCorsService("servicecors5", headers, Map.of("apiml.corsEnabled", "true"), assertions).start(); + // Preflight request with origin that should be rejected by default CORS policy given() .log().all() - .header(new Header("Origin", "https://foo.bar.org")) - .header(new Header("Access-Control-Request-Method", "POST")) - .header(new Header("Access-Control-Request-Headers", "Content-Type")) + .header(new Header(HttpHeaders.ORIGIN, "https://foo.bar.org")) + .header(new Header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")) + .header(new Header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "Content-Type")) .when() - .options(basePath /*+ serviceWithCustomConfiguration.getPath()*/) + .options(basePath + "/servicecors5/api/v1/fullheaders") .then() .log().all() .statusCode(is(SC_FORBIDDEN)); // No request should be passed to the southbound service for preflight - // verify(mockClient, times(0)).execute(ArgumentMatchers.any(HttpUriRequest.class)); + assertFalse(called.get()); } @Test void givenCorsIsEnabledWithDefaults_whenPreflightRequestWithLocalhostOriginComes_thenPreflightIsAccepted() throws Exception { + var headers = new Headers(); + + var called = new AtomicBoolean(false); + List> assertions = List.of( + httpExchange -> { + assertNull(httpExchange.getRequestHeaders().get(HttpHeaders.ORIGIN)); + called.set(true); + } + ); + + mockCorsService("servicecors6", headers, Map.of("apiml.corsEnabled", "true"), assertions).start(); // Preflight request with localhost origin that should be accepted by default CORS policy given() .log().all() - .header(new Header("Origin", "https://localhost:" + port)) - .header(new Header("Access-Control-Request-Method", "POST")) - .header(new Header("Access-Control-Request-Headers", "Content-Type")) + .header(new Header(HttpHeaders.ORIGIN, "https://localhost:" + port)) + .header(new Header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")) + .header(new Header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "Content-Type")) .when() - .options(basePath /*+ serviceWithCustomConfiguration.getPath()*/) + .options(basePath + "/servicecors6/api/v1/fullheaders") .then() .log().all() .statusCode(is(SC_OK)); // No request should be passed to the southbound service for preflight - // verify(mockClient, times(0)).execute(ArgumentMatchers.any(HttpUriRequest.class)); + assertFalse(called.get()); } } From 7737d0338a7357b6b19b4d52e7e095d1362e6f12 Mon Sep 17 00:00:00 2001 From: Pablo Carle Date: Tue, 7 Jul 2026 14:01:51 +0200 Subject: [PATCH 12/15] wip tests Signed-off-by: Pablo Carle --- .../java/org/zowe/apiml/util/CorsUtils.java | 5 +- .../org/zowe/apiml/util/CorsUtilsTest.java | 4 +- .../common/MicroservicesAcceptanceTest.java | 9 +- .../acceptance/corsTests/CorsDefaultTest.java | 92 +++++++++++++++++++ .../corsTests/CorsDisabledTest.java | 92 +++++++++++++++++++ .../{ => corsTests}/CorsPerServiceTest.java | 75 +++------------ 6 files changed, 211 insertions(+), 66 deletions(-) create mode 100644 gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/CorsDefaultTest.java create mode 100644 gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/CorsDisabledTest.java rename gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/{ => corsTests}/CorsPerServiceTest.java (75%) diff --git a/common-service-core/src/main/java/org/zowe/apiml/util/CorsUtils.java b/common-service-core/src/main/java/org/zowe/apiml/util/CorsUtils.java index 5f758eb3fd..5fd750eaa6 100644 --- a/common-service-core/src/main/java/org/zowe/apiml/util/CorsUtils.java +++ b/common-service-core/src/main/java/org/zowe/apiml/util/CorsUtils.java @@ -78,7 +78,7 @@ private CorsConfiguration createCorsConfigurationForService(String serviceId, Ma var corsAllowedOriginsForService = metadata.get("apiml.corsAllowedOrigins"); var allowedHeadersForService = metadata.get("apiml.corsAllowedHeaders"); - var allowedCredentialsForService = metadata.get("apiml.corsAllowedCredentials"); + var allowedCredentialsForService = metadata.get("apiml.corsAllowCredentials"); var allowedMethodsForService = metadata.get("apiml.corsAllowedMethods"); if (isNotBlank(corsAllowedOriginsForService)) { @@ -107,6 +107,9 @@ private CorsConfiguration createCorsConfigurationForService(String serviceId, Ma } } else { config.setAllowedOrigins(defaultAllowedCorsOrigins); + config.setAllowedHeaders(defaultAllowedCorsHeaders); + config.setAllowCredentials(defaultAllowCredentials); + config.setAllowedMethods(defaultAllowedCorsHttpMethods); log.debug("CORS is not enabled for service {}, using defaults", serviceId); } return config; diff --git a/common-service-core/src/test/java/org/zowe/apiml/util/CorsUtilsTest.java b/common-service-core/src/test/java/org/zowe/apiml/util/CorsUtilsTest.java index b9d84ca4e1..3033103c3f 100644 --- a/common-service-core/src/test/java/org/zowe/apiml/util/CorsUtilsTest.java +++ b/common-service-core/src/test/java/org/zowe/apiml/util/CorsUtilsTest.java @@ -55,6 +55,7 @@ void setUp() { .defaultAllowedCorsHttpMethods(defaultCorsMethods) .defaultAllowedCorsOrigins(Collections.emptyList()) .defaultAllowedCorsHeaders(List.of("*")) + .defaultAllowCredentials(true) .build(); } @@ -87,7 +88,8 @@ void registerDefaultConfigForService() { metadata.remove("apiml.corsEnabled"); corsUtils.setCorsConfiguration("dclient", metadata, (path, configuration) -> { assertEquals(metadata.get("apiml.routes.v1.gateway"), path); - assertNull(configuration.getAllowedMethods()); + assertTrue(configuration.getAllowCredentials()); + assertEquals(List.of("GET", "HEAD", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"), configuration.getAllowedMethods()); } ); } diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/common/MicroservicesAcceptanceTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/common/MicroservicesAcceptanceTest.java index 75cff83b7e..6d66f09c62 100644 --- a/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/common/MicroservicesAcceptanceTest.java +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/common/MicroservicesAcceptanceTest.java @@ -25,8 +25,13 @@ @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @ComponentScan(basePackages = "org.zowe.apiml.gateway") -@SpringBootTest(classes = GatewayServiceApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, - properties = {"management.port=-1"}) +@SpringBootTest( + classes = GatewayServiceApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "management.port=-1" + } +) @Import(DiscoveryClientTestConfig.class) @DirtiesContext public @interface MicroservicesAcceptanceTest { diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/CorsDefaultTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/CorsDefaultTest.java new file mode 100644 index 0000000000..e0a16e8201 --- /dev/null +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/CorsDefaultTest.java @@ -0,0 +1,92 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.gateway.acceptance.corsTests; + +import com.google.common.net.HttpHeaders; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import io.restassured.http.Header; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.zowe.apiml.gateway.MockService.MockServiceBuilder; +import org.zowe.apiml.gateway.MockService.Scope; +import org.zowe.apiml.gateway.acceptance.common.AcceptanceTestWithMockServices; +import org.zowe.apiml.gateway.acceptance.common.MicroservicesAcceptanceTest; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +import static io.restassured.RestAssured.given; +import static org.apache.http.HttpStatus.SC_FORBIDDEN; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; + +@MicroservicesAcceptanceTest +@ActiveProfiles({"CorsPerServiceTestWithDefaults", "test"}) +@TestPropertySource(properties = { + "apiml.service.corsEnabled=true", + "apiml.service.corsDefaultAllowedOrigins=" +}) +class CorsPerServiceTestWithDefaults extends AcceptanceTestWithMockServices { + + private MockServiceBuilder mockCorsService(String serviceId, Headers responseHeaders, Map metadata, Collection> assertions) { + var builder = mockService(serviceId); + var endpointBuilder = builder.addEndpoint("/" + serviceId + "/fullheaders"); + + if (responseHeaders != null) { + endpointBuilder.headers(responseHeaders); + } + if (metadata != null) { + builder.additionalMetadata(metadata); + } + if (assertions != null) { + endpointBuilder.assertions(assertions); + } + + return endpointBuilder + .and() + .scope(Scope.TEST); + } + + @Test + void givenCorsIsDelegatedToGatewayButServiceDoesntAllowCors_whenSimpleCorsRequestArrives_thenReject() throws Exception { + var headers = new Headers(); + var called = new AtomicBoolean(false); + List> assertions = List.of( + httpExchange -> { + assertNull(httpExchange.getRequestHeaders().get(HttpHeaders.ORIGIN)); + called.set(true); + } + ); + mockCorsService("servicecors5", headers, Map.of("apiml.corsEnabled", "false"), assertions).start(); + + given() + .header(new Header(HttpHeaders.ORIGIN, "https://foo.bar.org")) + .header(new Header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")) + .header(new Header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "origin, x-requested-with")) + .log().all() + .when() + .post(basePath + "/servicecors5/api/v1/fullheaders") + .then() + .log().all() + .statusCode(is(SC_FORBIDDEN)) + .header(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, is(nullValue())); + + assertFalse(called.get()); + } + +} diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/CorsDisabledTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/CorsDisabledTest.java new file mode 100644 index 0000000000..d5e4e8c0c4 --- /dev/null +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/CorsDisabledTest.java @@ -0,0 +1,92 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.gateway.acceptance.corsTests; + +import com.google.common.net.HttpHeaders; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import io.restassured.http.Header; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.zowe.apiml.gateway.MockService.MockServiceBuilder; +import org.zowe.apiml.gateway.MockService.Scope; +import org.zowe.apiml.gateway.acceptance.common.AcceptanceTestWithMockServices; +import org.zowe.apiml.gateway.acceptance.common.MicroservicesAcceptanceTest; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +import static io.restassured.RestAssured.given; +import static org.apache.http.HttpStatus.SC_FORBIDDEN; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; + +@MicroservicesAcceptanceTest +@ActiveProfiles({"CorsPerServiceTestWithDisabled", "test"}) +@TestPropertySource(properties = { + "apiml.service.corsEnabled=false", + "apiml.service.corsDefaultAllowedOrigins=" +}) +class CorsPerServiceTestWithDisabled extends AcceptanceTestWithMockServices { + + private MockServiceBuilder mockCorsService(String serviceId, Headers responseHeaders, Map metadata, Collection> assertions) { + var builder = mockService(serviceId); + var endpointBuilder = builder.addEndpoint("/" + serviceId + "/fullheaders"); + + if (responseHeaders != null) { + endpointBuilder.headers(responseHeaders); + } + if (metadata != null) { + builder.additionalMetadata(metadata); + } + if (assertions != null) { + endpointBuilder.assertions(assertions); + } + + return endpointBuilder + .and() + .scope(Scope.TEST); + } + + @Test + void givenCorsIsDisabledInGateway_whenSimpleCorsRequestArrives_thenNoAccessControlAllowOriginIsSet() throws Exception { + var headers = new Headers(); + var called = new AtomicBoolean(false); + List> assertions = List.of( + httpExchange -> { + assertNull(httpExchange.getRequestHeaders().get(HttpHeaders.ORIGIN)); + called.set(true); + } + ); + mockCorsService("servicecors6", headers, Map.of("apiml.corsEnabled", "false"), assertions).start(); + + given() + .header(new Header(HttpHeaders.ORIGIN, "https://foo.bar.org")) + .header(new Header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")) + .header(new Header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "origin, x-requested-with")) + .log().all() + .when() + .post(basePath + "/servicecors6/api/v1/fullheaders") + .then() + .log().all() + .statusCode(is(SC_FORBIDDEN)) + .header(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, is(nullValue())); + + assertFalse(called.get()); + } + +} diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/CorsPerServiceTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/CorsPerServiceTest.java similarity index 75% rename from gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/CorsPerServiceTest.java rename to gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/CorsPerServiceTest.java index 89adbadf45..1c778d8a85 100644 --- a/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/CorsPerServiceTest.java +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/CorsPerServiceTest.java @@ -8,17 +8,13 @@ * Copyright Contributors to the Zowe Project. */ -package org.zowe.apiml.gateway.acceptance; +package org.zowe.apiml.gateway.acceptance.corsTests; import com.google.common.net.HttpHeaders; import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.HttpExchange; import io.restassured.http.Header; -import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.NestedTestConfiguration; -import org.springframework.test.context.NestedTestConfiguration.EnclosingConfiguration; import org.springframework.test.context.TestPropertySource; import org.zowe.apiml.gateway.MockService.MockServiceBuilder; import org.zowe.apiml.gateway.MockService.Scope; @@ -33,10 +29,8 @@ import java.util.function.Consumer; import static io.restassured.RestAssured.given; -import static org.apache.http.HttpStatus.SC_FORBIDDEN; import static org.apache.http.HttpStatus.SC_OK; import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.nullValue; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -46,7 +40,6 @@ "apiml.service.corsEnabled=true", "apiml.service.corsDefaultAllowedOrigins=https://foo.bar.org" }) -@NestedTestConfiguration(EnclosingConfiguration.OVERRIDE) class CorsPerServiceTest extends AcceptanceTestWithMockServices { private static final String HEADER_X_FORWARD_TO = "X-Forward-To"; @@ -89,7 +82,7 @@ void routeToServiceWithCorsEnabled() throws IOException { @Test // Verify the header to allow CORS isn't set // Verify there was no call to southbound service - void givenCorsIsDelegatedToGatewayButServiceDoesntAllowCors_whenPreflightRequestArrives_thenNoAccessControlAllowOriginIsSet() throws Exception { + void givenCorsIsDelegatedToGatewayButServiceDoesntAllowCors_whenPreflightRequestArrives_thenDefaultCorsHeadersIsSet() throws Exception { var headers = new Headers(); var called = new AtomicBoolean(false); List> assertions = List.of( @@ -107,8 +100,11 @@ void givenCorsIsDelegatedToGatewayButServiceDoesntAllowCors_whenPreflightRequest .when() .options(basePath + "/servicecors1/api/v1/fullheaders") .then() - .statusCode(is(SC_FORBIDDEN)) - .header(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, is(nullValue())); + .statusCode(is(SC_OK)) + .header(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "https://foo.bar.org") + .header(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "origin, x-requested-with") + .header(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, "GET,HEAD,POST,PATCH,DELETE,PUT,OPTIONS"); + assertFalse(called.get()); } @@ -116,7 +112,7 @@ void givenCorsIsDelegatedToGatewayButServiceDoesntAllowCors_whenPreflightRequest @Test // Verify the header to allow CORS isn't set // Verify there was no call to southbound service - void givenCorsIsDelegatedToGatewayButServiceDoesntAllowCors_whenSimpleCorsRequestArrives_thenNoAccessControlAllowOriginIsSet() throws Exception { + void givenCorsIsDelegatedToGatewayButServiceDoesntAllowCors_whenSimpleCorsRequestArrives_thenDefaultCorsHeadersIsSet() throws Exception { var headers = new Headers(); var called = new AtomicBoolean(false); List> assertions = List.of( @@ -134,10 +130,10 @@ void givenCorsIsDelegatedToGatewayButServiceDoesntAllowCors_whenSimpleCorsReques .when() .post(basePath + "/servicecors2/api/v1/fullheaders") .then() - .statusCode(is(SC_FORBIDDEN)) - .header(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, is(nullValue())); + .statusCode(is(SC_OK)) + .header(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "https://foo.bar.org"); - assertFalse(called.get()); + assertTrue(called.get()); } @Test @@ -159,9 +155,11 @@ void givenCorsIsAllowedForSpecificService_whenPreFlightRequestArrives_thenCorsHe .header(new Header(HttpHeaders.ORIGIN, "https://foo.bar.org")) .header(new Header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")) .header(new Header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "origin, x-requested-with")) + .log().all() .when() .options(basePath + "/servicecors3/api/v1/fullheaders") .then() + .log().all() .statusCode(is(SC_OK)) .header(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, is("https://foo.bar.org")) .header(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, is("GET,HEAD,POST,PATCH,DELETE,PUT,OPTIONS")) @@ -211,51 +209,4 @@ void givenCorsIsAllowedForSpecificService_whenSimpleRequestArrives_thenCorsHeade assertTrue(called.get() ); } - @Nested - @MicroservicesAcceptanceTest - @ActiveProfiles({"CorsPerServiceTestWithDefaults", "test"}) - @TestPropertySource(properties = { - "apiml.service.corsEnabled=true" - }) - class CorsPerServiceTestWithDefaults { - - @Test - void givenCorsIsDelegatedToGatewayButServiceDoesntAllowCors_whenSimpleCorsRequestArrives_thenNoAccessControlAllowOriginIsSet() throws Exception { - var headers = new Headers(); - var called = new AtomicBoolean(false); - List> assertions = List.of( - httpExchange -> { - assertNull(httpExchange.getRequestHeaders().get(HttpHeaders.ORIGIN)); - called.set(true); - } - ); - mockCorsService("servicecors5", headers, Map.of("apiml.corsEnabled", "false"), assertions).start(); - - given() - .header(new Header(HttpHeaders.ORIGIN, "https://foo.bar.org")) - .header(new Header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")) - .header(new Header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "origin, x-requested-with")) - .when() - .post(basePath + "/servicecors5/api/v1/fullheaders") - .then() - .statusCode(is(SC_FORBIDDEN)) - .header(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, is(nullValue())); - - assertFalse(called.get()); - } - - } - - @Nested - @MicroservicesAcceptanceTest - @ActiveProfiles({"CorsPerServiceTestWithDefaults", "test"}) - @TestPropertySource(properties = { - "apiml.service.corsEnabled=false" - }) - class CorsPerServiceTestWithDisabled { - - // the service should not receive the Origin header - - } - } From 87054e1468740371f51aee32d1f8064adff3121d Mon Sep 17 00:00:00 2001 From: Pablo Carle Date: Tue, 7 Jul 2026 14:55:20 +0200 Subject: [PATCH 13/15] wip fix IT Signed-off-by: Pablo Carle --- config/docker/api-defs/staticclient.yml | 2 +- .../java/org/zowe/apiml/integration/proxy/CorsEnabledTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/docker/api-defs/staticclient.yml b/config/docker/api-defs/staticclient.yml index cd37472336..b1ec4c90c4 100644 --- a/config/docker/api-defs/staticclient.yml +++ b/config/docker/api-defs/staticclient.yml @@ -30,7 +30,7 @@ services: apiml: okToRetryOnAllOperations: true corsEnabled: true - corsAllowedOrigins: https://discoverable-client:10012/discoverableclient + corsAllowedOrigins: https://discoverable-client:10012 corsAllowedMethods: GET,POST,OPTIONS,HEAD corsAllowedHeaders: Content-Type,Authorization corsAllowCredentials: true diff --git a/integration-tests/src/test/java/org/zowe/apiml/integration/proxy/CorsEnabledTest.java b/integration-tests/src/test/java/org/zowe/apiml/integration/proxy/CorsEnabledTest.java index 8e8cef73b0..c2fa526ee9 100644 --- a/integration-tests/src/test/java/org/zowe/apiml/integration/proxy/CorsEnabledTest.java +++ b/integration-tests/src/test/java/org/zowe/apiml/integration/proxy/CorsEnabledTest.java @@ -50,7 +50,7 @@ void test1(String origin, int statusCode) { .log().all() .header("Origin", origin) .header("Access-Control-Request-Method", "GET") - .header("Access-Control-Request-Headers", "Origin") + .header("Access-Control-Request-Headers", "Content-Type") .when() .options(HttpRequestUtils.getUriFromGateway(Endpoints.STATIC_CLIENT_1_REQUEST)) .then() From 7c8f065c7f6782326835c5362bfa51ce17707d27 Mon Sep 17 00:00:00 2001 From: Pablo Carle Date: Tue, 7 Jul 2026 15:41:52 +0200 Subject: [PATCH 14/15] fix sonar issues Signed-off-by: Pablo Carle --- .../acceptance/corsTests/CorsDefaultTest.java | 6 +++--- .../acceptance/corsTests/CorsDisabledTest.java | 6 +++--- .../acceptance/corsTests/CorsPerServiceTest.java | 8 ++++---- .../acceptance/corsTests/GatewayCorsTest.java | 12 ++++++------ 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/CorsDefaultTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/CorsDefaultTest.java index e0a16e8201..29d65d8a93 100644 --- a/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/CorsDefaultTest.java +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/CorsDefaultTest.java @@ -36,12 +36,12 @@ import static org.junit.jupiter.api.Assertions.assertNull; @MicroservicesAcceptanceTest -@ActiveProfiles({"CorsPerServiceTestWithDefaults", "test"}) +@ActiveProfiles({"CorsPerServiceWithDefaultsTest", "test"}) @TestPropertySource(properties = { "apiml.service.corsEnabled=true", "apiml.service.corsDefaultAllowedOrigins=" }) -class CorsPerServiceTestWithDefaults extends AcceptanceTestWithMockServices { +class CorsPerServiceWithDefaultsTest extends AcceptanceTestWithMockServices { private MockServiceBuilder mockCorsService(String serviceId, Headers responseHeaders, Map metadata, Collection> assertions) { var builder = mockService(serviceId); @@ -63,7 +63,7 @@ private MockServiceBuilder mockCorsService(String serviceId, Headers responseHea } @Test - void givenCorsIsDelegatedToGatewayButServiceDoesntAllowCors_whenSimpleCorsRequestArrives_thenReject() throws Exception { + void givenCorsIsDelegatedToGatewayButServiceDoesntAllowCors_whenSimpleCorsRequestArrives_thenReject() { var headers = new Headers(); var called = new AtomicBoolean(false); List> assertions = List.of( diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/CorsDisabledTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/CorsDisabledTest.java index d5e4e8c0c4..1c5db65931 100644 --- a/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/CorsDisabledTest.java +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/CorsDisabledTest.java @@ -36,12 +36,12 @@ import static org.junit.jupiter.api.Assertions.assertNull; @MicroservicesAcceptanceTest -@ActiveProfiles({"CorsPerServiceTestWithDisabled", "test"}) +@ActiveProfiles({"CorsPerServiceWithDisabledTest", "test"}) @TestPropertySource(properties = { "apiml.service.corsEnabled=false", "apiml.service.corsDefaultAllowedOrigins=" }) -class CorsPerServiceTestWithDisabled extends AcceptanceTestWithMockServices { +class CorsPerServiceWithDisabledTest extends AcceptanceTestWithMockServices { private MockServiceBuilder mockCorsService(String serviceId, Headers responseHeaders, Map metadata, Collection> assertions) { var builder = mockService(serviceId); @@ -63,7 +63,7 @@ private MockServiceBuilder mockCorsService(String serviceId, Headers responseHea } @Test - void givenCorsIsDisabledInGateway_whenSimpleCorsRequestArrives_thenNoAccessControlAllowOriginIsSet() throws Exception { + void givenCorsIsDisabledInGateway_whenSimpleCorsRequestArrives_thenNoAccessControlAllowOriginIsSet() { var headers = new Headers(); var called = new AtomicBoolean(false); List> assertions = List.of( diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/CorsPerServiceTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/CorsPerServiceTest.java index 1c778d8a85..ede7f79b11 100644 --- a/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/CorsPerServiceTest.java +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/CorsPerServiceTest.java @@ -82,7 +82,7 @@ void routeToServiceWithCorsEnabled() throws IOException { @Test // Verify the header to allow CORS isn't set // Verify there was no call to southbound service - void givenCorsIsDelegatedToGatewayButServiceDoesntAllowCors_whenPreflightRequestArrives_thenDefaultCorsHeadersIsSet() throws Exception { + void givenCorsIsDelegatedToGatewayButServiceDoesntAllowCors_whenPreflightRequestArrives_thenDefaultCorsHeadersIsSet() { var headers = new Headers(); var called = new AtomicBoolean(false); List> assertions = List.of( @@ -112,7 +112,7 @@ void givenCorsIsDelegatedToGatewayButServiceDoesntAllowCors_whenPreflightRequest @Test // Verify the header to allow CORS isn't set // Verify there was no call to southbound service - void givenCorsIsDelegatedToGatewayButServiceDoesntAllowCors_whenSimpleCorsRequestArrives_thenDefaultCorsHeadersIsSet() throws Exception { + void givenCorsIsDelegatedToGatewayButServiceDoesntAllowCors_whenSimpleCorsRequestArrives_thenDefaultCorsHeadersIsSet() { var headers = new Headers(); var called = new AtomicBoolean(false); List> assertions = List.of( @@ -139,7 +139,7 @@ void givenCorsIsDelegatedToGatewayButServiceDoesntAllowCors_whenSimpleCorsReques @Test // There is no request to the southbound server for preflight // There is request to the southbound server for the second request - void givenCorsIsAllowedForSpecificService_whenPreFlightRequestArrives_thenCorsHeadersAreSet() throws Exception { + void givenCorsIsAllowedForSpecificService_whenPreFlightRequestArrives_thenCorsHeadersAreSet() { var headers = new Headers(); var called = new AtomicBoolean(false); List> assertions = List.of( @@ -184,7 +184,7 @@ void givenCorsIsAllowedForSpecificService_whenPreFlightRequestArrives_thenCorsHe @Test // There is request to the southbound server for the request // The CORS header is properly set. - void givenCorsIsAllowedForSpecificService_whenSimpleRequestArrives_thenCorsHeadersAreSet() throws Exception { + void givenCorsIsAllowedForSpecificService_whenSimpleRequestArrives_thenCorsHeadersAreSet() { // There is request to the southbound server and the CORS headers are properly set on the response var headers = new Headers(); var called = new AtomicBoolean(false); diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/GatewayCorsTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/GatewayCorsTest.java index 3b25bb9f02..856651f31f 100644 --- a/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/GatewayCorsTest.java +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/GatewayCorsTest.java @@ -97,7 +97,7 @@ void givenCorsIsAllowedForSpecificService_whenPreFlightRequestArrives_thenCorsHe } @Test - void givenCorsOriginIsNotAllowed_whenPreFlightRequestArrives_thenCorsHeadersAreNotSet() throws Exception { + void givenCorsOriginIsNotAllowed_whenPreFlightRequestArrives_thenCorsHeadersAreNotSet() { mockCorsService("servicecors1", null, Map.of("apiml.corsEnabled", "true"), null).start(); // Preflight request with disallowed origin @@ -115,7 +115,7 @@ void givenCorsOriginIsNotAllowed_whenPreFlightRequestArrives_thenCorsHeadersAreN @Test // There is request to the southbound server for the request // The CORS header is properly set. - void givenCorsIsAllowedForSpecificService_whenSimpleRequestArrives_thenCorsHeadersAreSetAndOnlyTheOnesByGateway() throws Exception { + void givenCorsIsAllowedForSpecificService_whenSimpleRequestArrives_thenCorsHeadersAreSetAndOnlyTheOnesByGateway() { var headers = new Headers(); var called = new AtomicBoolean(false); List> assertions = List.of( @@ -142,7 +142,7 @@ void givenCorsIsAllowedForSpecificService_whenSimpleRequestArrives_thenCorsHeade @Test // There is no request to the southbound server for preflight // There is request to the southbound server for the second request - void givenCorsIsAllowedForSpecificService_whenTheServiceIsSet_thenCorsHeadersAreSetAndOnlyTheOnesByGateway() throws Exception { + void givenCorsIsAllowedForSpecificService_whenTheServiceIsSet_thenCorsHeadersAreSetAndOnlyTheOnesByGateway() { var headers = new Headers(); var called = new AtomicBoolean(false); @@ -184,7 +184,7 @@ void givenCorsIsAllowedForSpecificService_whenTheServiceIsSet_thenCorsHeadersAre } @Test - void givenCorsIsEnabled_whenRequestWithOriginComes_thenOriginIsntPassedToSouthbound() throws Exception { + void givenCorsIsEnabled_whenRequestWithOriginComes_thenOriginIsntPassedToSouthbound() { // There is request to the southbound server and the CORS headers are properly set on the response var headers = new Headers(); @@ -242,7 +242,7 @@ private MockServiceBuilder mockCorsService(String serviceId, Headers responseHea // Gateway uses a default list of origins, does not accept any (*) @Test - void givenCorsIsEnabledWithDefaults_whenPreflightRequestComes_thenPreflightIsRejected() throws Exception { + void givenCorsIsEnabledWithDefaults_whenPreflightRequestComes_thenPreflightIsRejected() { var headers = new Headers(); var called = new AtomicBoolean(false); @@ -272,7 +272,7 @@ void givenCorsIsEnabledWithDefaults_whenPreflightRequestComes_thenPreflightIsRej } @Test - void givenCorsIsEnabledWithDefaults_whenPreflightRequestWithLocalhostOriginComes_thenPreflightIsAccepted() throws Exception { + void givenCorsIsEnabledWithDefaults_whenPreflightRequestWithLocalhostOriginComes_thenPreflightIsAccepted() { var headers = new Headers(); var called = new AtomicBoolean(false); From 156506498f4df92fc286538492e3896fd0f291fa Mon Sep 17 00:00:00 2001 From: Pablo Carle Date: Thu, 9 Jul 2026 15:45:30 +0200 Subject: [PATCH 15/15] pr review Signed-off-by: Pablo Carle --- apiml/src/test/resources/application.yml | 2 -- .../java/org/zowe/apiml/util/CorsUtils.java | 9 ++++-- .../org/zowe/apiml/util/CorsUtilsTest.java | 8 +++-- .../gateway/config/ConnectionsConfig.java | 4 +-- .../corsTests/CorsDisabledTest.java | 29 +++++++++++++++++++ 5 files changed, 42 insertions(+), 10 deletions(-) diff --git a/apiml/src/test/resources/application.yml b/apiml/src/test/resources/application.yml index 1b3e0f3e35..d4cccde2c8 100644 --- a/apiml/src/test/resources/application.yml +++ b/apiml/src/test/resources/application.yml @@ -1,8 +1,6 @@ logging: level: ROOT: DEBUG - org.springframework: DEBUG - org.springframework.boot.autoconfigure.web: DEBUG org.springframework.cloud.gateway: DEBUG org.springframework.http.server.reactive: DEBUG org.springframework.web.reactive: DEBUG diff --git a/common-service-core/src/main/java/org/zowe/apiml/util/CorsUtils.java b/common-service-core/src/main/java/org/zowe/apiml/util/CorsUtils.java index 5fd750eaa6..0246f789f9 100644 --- a/common-service-core/src/main/java/org/zowe/apiml/util/CorsUtils.java +++ b/common-service-core/src/main/java/org/zowe/apiml/util/CorsUtils.java @@ -120,12 +120,15 @@ public void registerDefaultCorsConfiguration(BiConsumer pathsToEnable; config.setAllowedOrigins(defaultAllowedCorsOrigins); + config.setAllowCredentials(true); + config.setAllowedHeaders(defaultAllowedCorsHeaders); + config.setAllowedMethods(defaultAllowedCorsHttpMethods); + if (gatewayCorsEnabled) { - config.setAllowCredentials(true); - config.setAllowedHeaders(defaultAllowedCorsHeaders); - config.setAllowedMethods(defaultAllowedCorsHttpMethods); + // When gateway has CORS handling enabled, defaults go to the /gateway/** endpoints plus any routes that southbound services register. If a service does not register its routes with apiml.corsEnabled metadata entry, the behaviour is really not recommended as there is no CORS configuration set for the service (if the service receives requests with Origin header) pathsToEnable = corsAllowedEndpoints; } else { + // When gateway has CORS handling disabled, all endpoints use Gateway defaults. pathsToEnable = Collections.singletonList("/**"); } pathsToEnable.forEach(path -> pathMapper.accept(path, config)); diff --git a/common-service-core/src/test/java/org/zowe/apiml/util/CorsUtilsTest.java b/common-service-core/src/test/java/org/zowe/apiml/util/CorsUtilsTest.java index 3033103c3f..483525884f 100644 --- a/common-service-core/src/test/java/org/zowe/apiml/util/CorsUtilsTest.java +++ b/common-service-core/src/test/java/org/zowe/apiml/util/CorsUtilsTest.java @@ -123,16 +123,18 @@ void setUp() { corsUtils = CorsUtils.builder() .gatewayCorsEnabled(false) .corsAllowedEndpoints(Arrays.asList("/gateway/**", "/api-docs")) - .defaultAllowedCorsOrigins(Collections.emptyList()) + .defaultAllowedCorsOrigins(List.of("https://localhost3:10010")) .defaultAllowedCorsHeaders(List.of("*")) + .defaultAllowedCorsHttpMethods(List.of("GET", "HEAD")) .build(); } @Test void registerEmptyDefaultConfig() { corsUtils.registerDefaultCorsConfiguration((path, configuration) -> { - assertNull(configuration.getAllowedHeaders()); - assertNull(configuration.getAllowedMethods()); + assertEquals(List.of("https://localhost3:10010"), configuration.getAllowedOrigins()); + assertEquals(List.of("*"), configuration.getAllowedHeaders()); + assertEquals(List.of("GET", "HEAD"), configuration.getAllowedMethods()); } ); } diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java index bb053b644a..fcf9c2a336 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java @@ -110,10 +110,10 @@ public class ConnectionsConfig { @Value("${apiml.service.corsAllowedMethods:GET,HEAD,POST,PATCH,DELETE,PUT,OPTIONS}") private List corsAllowedMethods; - @Value("${apiml.service.corsDefaultAllowedOrigins:#{'https://${apiml.service.hostname:localhost}:${apiml.service.port}'}}") + @Value("#{T(org.springframework.util.StringUtils).hasText('${apiml.service.corsDefaultAllowedOrigins:}') ? '${apiml.service.corsDefaultAllowedOrigins:}' : 'https://${apiml.service.hostname:localhost}:${apiml.service.port}'}") private String corsDefaultAllowedOrigins; - @Value("${apiml.service.corsDefaultAllowedHeaders:*}") + @Value("#{T(org.springframework.util.StringUtils).hasText('${apiml.service.corsDefaultAllowedHeaders:}') ? '${apiml.service.corsDefaultAllowedHeaders:}' : '*'}") private String corsDefaultAllowedHeaders; @Value("${apiml.service.hostname:localhost}") diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/CorsDisabledTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/CorsDisabledTest.java index 1c5db65931..efdc3d2493 100644 --- a/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/CorsDisabledTest.java +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/acceptance/corsTests/CorsDisabledTest.java @@ -29,9 +29,11 @@ import java.util.function.Consumer; import static io.restassured.RestAssured.given; +import static org.apache.hc.core5.http.HttpStatus.SC_OK; import static org.apache.http.HttpStatus.SC_FORBIDDEN; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.Matchers.notNullValue; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; @@ -89,4 +91,31 @@ void givenCorsIsDisabledInGateway_whenSimpleCorsRequestArrives_thenNoAccessContr assertFalse(called.get()); } + @Test + void givenCorsIsDisabledInGateway_whenPreflightRequestArrives_thenUseDefaults() { + var headers = new Headers(); + var called = new AtomicBoolean(false); + List> assertions = List.of( + httpExchange -> { + assertNull(httpExchange.getRequestHeaders().get(HttpHeaders.ORIGIN)); + called.set(true); + } + ); + mockCorsService("servicecors7", headers, Map.of("apiml.corsEnabled", "false"), assertions).start(); + + given() + .header(new Header(HttpHeaders.ORIGIN, "https://localhost:10010")) + .header(new Header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")) + .header(new Header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "origin, x-requested-with")) + .log().all() + .when() + .options(basePath + "/servicecors7/api/v1/fullheaders") + .then() + .log().all() + .statusCode(SC_OK) + .header(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, notNullValue()); + + assertFalse(called.get()); + } + }