diff --git a/cloud-gateway-service/build.gradle b/cloud-gateway-service/build.gradle index 9492cc6ce2..30654dda32 100644 --- a/cloud-gateway-service/build.gradle +++ b/cloud-gateway-service/build.gradle @@ -96,7 +96,6 @@ dependencies { testImplementation libs.rest.assured testImplementation libs.reactorTest testImplementation libs.mockito.inline - } bootJar { diff --git a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/AbstractAuthSchemeFactory.java b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/AbstractAuthSchemeFactory.java new file mode 100644 index 0000000000..f75b2d78d2 --- /dev/null +++ b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/AbstractAuthSchemeFactory.java @@ -0,0 +1,314 @@ +/* + * 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.cloudgatewayservice.filters; + +import lombok.Data; +import org.apache.commons.lang3.StringUtils; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import org.springframework.web.server.ServerWebExchange; +import org.zowe.apiml.cloudgatewayservice.service.InstanceInfoService; +import org.zowe.apiml.constants.ApimlConstants; +import org.zowe.apiml.message.core.MessageService; +import reactor.core.publisher.Mono; + +import java.net.HttpCookie; +import java.util.*; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.zowe.apiml.constants.ApimlConstants.PAT_COOKIE_AUTH_NAME; +import static org.zowe.apiml.constants.ApimlConstants.PAT_HEADER_NAME; +import static org.zowe.apiml.security.SecurityUtils.COOKIE_AUTH_NAME; + +/** + * This class is responsible for the shared part about decoration of user request with authentication scheme. The + * service defines its own authentication scheme, and it could evaluate a request mutation. The aim is to have as + * small implementation as possible. Therefore, the implemntation itself should construct the request to ZAAS with + * a minimal requirements and process the result. The rest (common values for ZAAS, retrying, HA evaluation and + * sanitation of user request should be done by this class). + * + * To prepare a new implementation of authentication scheme decoration is required to implement those methods: + * - {@link AbstractAuthSchemeFactory#getResponseClass()} - define class of the response body (see T) + * - {@link AbstractAuthSchemeFactory#getResponseFor401()} - construct empty response body for 401 response + * - {@link AbstractAuthSchemeFactory#createRequest(AbstractConfig, ServerHttpRequest.Builder, ServiceInstance, Object)} + * - create the base part of request to the ZAAS. It requires only related request properties to the releated scheme + * - {@link AbstractAuthSchemeFactory#processResponse(ServerWebExchange, GatewayFilterChain, Object)} + * - it is responsible for reading the response from the ZAAS and modifying the clients request to provide new credentials + * + * Example: + * class MyScheme extends AbstractAuthSchemeFactory { + * + * @Override + * public GatewayFilter apply(Config config) { + * try { + * return createGatewayFilter(config, ); + * } catch (Exception e) { + * return ((exchange, chain) -> { + * ServerHttpRequest request = updateHeadersForError(exchange, e.getMessage()); + * return chain.filter(exchange.mutate().request(request).build()); + * }); + * } + * } + * + * @Override + * protected Class getResponseClass() { + * return MyResponse.class; + * } + * + * @Override + * protected MyResponse getResponseFor401() { + * return new MyResponse(); + * } + * + * @Override + * protected WebClient.RequestHeadersSpec createRequest(ServiceInstance instance, Object data) { + * String url = String.format("%s://%s:%d/%s/zaas/myScheme", instance.getScheme(), instance.getHost(), instance.getPort(), instance.getServiceId().toLowerCase()); + * return webClient.post() + * .uri(url); + * } + * + * @Override + * protected Mono processResponse(ServerWebExchange exchange, GatewayFilterChain chain, MyResponse response) { + * ServerHttpRequest request; + * if (response.getToken() != null) { + * request = exchange.getRequest().mutate().headers(headers -> + * headers.add("mySchemeHeader", response.getToken()) + * ).build(); + * } else { + * request = updateHeadersForError(exchange, "Invalid or missing authentication."); + * } + * + * exchange = exchange.mutate().request(request).build(); + * return chain.filter(exchange); + * } + * + * @EqualsAndHashCode(callSuper = true) + * public static class Config extends AbstractAuthSchemeFactory.AbstractConfig { + * + * } + * + * } + * + * @Data + * class MyResponse { + * + * private String token; + * + * } + * + * @param Class of config class. It should extend {@link AbstractAuthSchemeFactory.AbstractConfig} + * @param Class of expended response from the ZAAS + * @param Type of data object that could be constructed before any request, and it is request for creating a request + */ +public abstract class AbstractAuthSchemeFactory extends AbstractGatewayFilterFactory { + + private static final String HEADER_SERVICE_ID = "X-Service-Id"; + + private static final Predicate CREDENTIALS_COOKIE_INPUT = cookie -> + StringUtils.equalsIgnoreCase(cookie.getName(), PAT_COOKIE_AUTH_NAME) || + StringUtils.equalsIgnoreCase(cookie.getName(), COOKIE_AUTH_NAME) || + StringUtils.startsWithIgnoreCase(cookie.getName(), COOKIE_AUTH_NAME + "."); + private static final Predicate CREDENTIALS_COOKIE = cookie -> + CREDENTIALS_COOKIE_INPUT.test(cookie) || + StringUtils.equalsIgnoreCase(cookie.getName(), "jwtToken") || + StringUtils.equalsIgnoreCase(cookie.getName(), "LtpaToken2"); + + private static final Predicate CREDENTIALS_HEADER_INPUT = headerName -> + StringUtils.equalsIgnoreCase(headerName, HttpHeaders.AUTHORIZATION) || + StringUtils.equalsIgnoreCase(headerName, PAT_HEADER_NAME); + private static final Predicate CREDENTIALS_HEADER = headerName -> + CREDENTIALS_HEADER_INPUT.test(headerName) || + StringUtils.equalsIgnoreCase(headerName, "X-SAF-Token") || + StringUtils.equalsIgnoreCase(headerName, "X-Certificate-Public") || + StringUtils.equalsIgnoreCase(headerName, "X-Certificate-DistinguishedName") || + StringUtils.equalsIgnoreCase(headerName, "X-Certificate-CommonName") || + StringUtils.equalsIgnoreCase(headerName, HttpHeaders.COOKIE); + + private static final RobinRoundIterator robinRound = new RobinRoundIterator<>(); + + protected final WebClient webClient; + protected final InstanceInfoService instanceInfoService; + protected final MessageService messageService; + + protected AbstractAuthSchemeFactory(Class configClazz, WebClient webClient, InstanceInfoService instanceInfoService, MessageService messageService) { + super(configClazz); + this.webClient = webClient; + this.instanceInfoService = instanceInfoService; + this.messageService = messageService; + } + + /** + * @return class of response body from ZAAS + */ + protected abstract Class getResponseClass(); + + /** + * @return empty object that is returned in the case of 401 response from ZAAS + */ + protected abstract R getResponseFor401(); + + private Mono> getZaasInstances() { + return instanceInfoService.getServiceInstance("gateway"); + } + + private Mono requestWithHa( + Iterator serviceInstanceIterator, + Function> requestCreator + ) { + return requestCreator.apply(serviceInstanceIterator.next()) + .retrieve() + .onStatus(HttpStatus::is5xxServerError, clientResponse -> Mono.empty()) + .bodyToMono(getResponseClass()) + .onErrorResume(exception -> exception instanceof WebClientResponseException.Unauthorized ? Mono.just(getResponseFor401()) : Mono.error(exception)) + .switchIfEmpty(serviceInstanceIterator.hasNext() ? + requestWithHa(serviceInstanceIterator, requestCreator) : Mono.empty() + ); + } + + protected Mono invoke( + List serviceInstances, + Function> requestCreator, + Function> responseProcessor + ) { + Iterator i = robinRound.getIterator(serviceInstances); + if (!i.hasNext()) { + throw new IllegalArgumentException("No ZAAS is available"); + } + + return requestWithHa(i, requestCreator).flatMap(responseProcessor); + } + + /** + * This method should construct basic request to the ZAAS (related to the authentication scheme). It should define + * URL, body and specific headers / cookies (if they are needed). The rest of values are set by + * {@link AbstractAuthSchemeFactory} + * + * @param instance - instance of the ZAAS instance that will be invoked + * @param data - data object set in the call of {@link AbstractAuthSchemeFactory#createGatewayFilter(AbstractConfig, Object)} + * @return builder of the request + */ + @SuppressWarnings({ + "squid:S1452", // the internal API cannot define generic more specificly + "squid:S2092" // the cookie is used just for internal purposes (off the browser) + }) + protected abstract WebClient.RequestHeadersSpec createRequest(ServiceInstance instance, D data); + + /** + * The method responsible for reading a response from a ZAAS component and decorating of user request (ie. set + * credentials as header, etc.) + * @param clientCallBuilder builder of customer request (to set new credentials) + * @param chain chain of filter to be evaluated. Method should return `return chain.filter(exchange)` + * @param response response body from the ZAAS containing new credentials or and empty object - see {@link AbstractAuthSchemeFactory#getResponseFor401()} + * @return response of chain evaluation (`return chain.filter(exchange)`) + */ + @SuppressWarnings("squid:S2092") // the cookie is used just for internal purposes (off the browser) + protected abstract Mono processResponse(ServerWebExchange clientCallBuilder, GatewayFilterChain chain, R response); + + @SuppressWarnings("squid:S1452") // the internal API cannot define generic more specifically + protected WebClient.RequestHeadersSpec createRequest(AbstractConfig config, ServerHttpRequest.Builder clientRequestbuilder, ServiceInstance instance, D data) { + WebClient.RequestHeadersSpec zaasCallBuilder = createRequest(instance, data); + + clientRequestbuilder + .headers(headers -> { + // get all current cookies + List cookies = readCookies(headers).collect(Collectors.toList()); + + // set in the request to ZAAS all cookies and headers that contain credentials + headers.entrySet().stream() + .filter(e -> CREDENTIALS_HEADER_INPUT.test(e.getKey())) + .forEach(e -> zaasCallBuilder.header(e.getKey(), e.getValue().toArray(new String[0]))); + cookies.stream() + .filter(CREDENTIALS_COOKIE_INPUT) + .forEach(c -> zaasCallBuilder.cookie(c.getName(), c.getValue())); + + // add common headers to ZAAS + zaasCallBuilder.header(HEADER_SERVICE_ID, config.serviceId); + + // update original request - to remove all potential headers and cookies with credentials + Stream> nonCredentialHeaders = headers.entrySet().stream() + .filter(entry -> !CREDENTIALS_HEADER.test(entry.getKey())) + .flatMap(entry -> entry.getValue().stream().map(v -> new AbstractMap.SimpleEntry<>(entry.getKey(), v))); + Stream> nonCredentialCookies = cookies.stream() + .filter(c -> !CREDENTIALS_COOKIE.test(c)) + .map(c -> new AbstractMap.SimpleEntry<>(HttpHeaders.COOKIE, c.toString())); + + List> newHeaders = Stream.concat( + nonCredentialHeaders, + nonCredentialCookies + ).collect(Collectors.toList()); + + headers.clear(); + newHeaders.forEach(newHeader -> headers.add(newHeader.getKey(), newHeader.getValue())); + }); + + return zaasCallBuilder; + } + + protected GatewayFilter createGatewayFilter(AbstractConfig config, D data) { + return (exchange, chain) -> getZaasInstances().flatMap( + instances -> { + ServerHttpRequest.Builder clientCallBuilder = exchange.getRequest().mutate(); + return invoke( + instances, + instance -> createRequest(config, clientCallBuilder, instance, data), + response -> processResponse(exchange.mutate().request(clientCallBuilder.build()).build(), chain, response) + ); + } + ); + } + protected ServerHttpRequest addRequestHeader(ServerWebExchange exchange, String key, String value) { + return exchange.getRequest().mutate() + .headers(headers -> headers.add(key, value)) + .build(); + } + + protected ServerHttpRequest setRequestHeader(ServerWebExchange exchange, String headerName, String headerValue) { + return exchange.getRequest().mutate() + .header(headerName, headerValue) + .build(); + } + + protected ServerHttpRequest updateHeadersForError(ServerWebExchange exchange, String errorMessage) { + ServerHttpRequest request = addRequestHeader(exchange, ApimlConstants.AUTH_FAIL_HEADER, messageService.createMessage("org.zowe.apiml.security.ticket.generateFailed", errorMessage).mapToLogMessage()); + exchange.getResponse().getHeaders().add(ApimlConstants.AUTH_FAIL_HEADER, messageService.createMessage("org.zowe.apiml.security.ticket.generateFailed", errorMessage).mapToLogMessage()); + return request; + } + + protected Stream readCookies(HttpHeaders httpHeaders) { + return Optional.ofNullable(httpHeaders.get(HttpHeaders.COOKIE)) + .orElse(Collections.emptyList()) + .stream() + .map(v -> StringUtils.split(v, ";")) + .flatMap(Arrays::stream) + .map(StringUtils::trim) + .map(HttpCookie::parse) + .flatMap(List::stream); + } + + @Data + protected abstract static class AbstractConfig { + + // service ID of the target service + private String serviceId; + + } + +} diff --git a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/PassticketFilterFactory.java b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/PassticketFilterFactory.java index 3344211dba..2ef091b182 100644 --- a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/PassticketFilterFactory.java +++ b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/PassticketFilterFactory.java @@ -13,18 +13,17 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectWriter; +import lombok.Data; +import lombok.EqualsAndHashCode; import org.apache.http.HttpHeaders; import org.springframework.cloud.client.ServiceInstance; import org.springframework.cloud.gateway.filter.GatewayFilter; import org.springframework.cloud.gateway.filter.GatewayFilterChain; -import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory; -import org.springframework.http.HttpStatus; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.server.ServerWebExchange; import org.zowe.apiml.cloudgatewayservice.service.InstanceInfoService; -import org.zowe.apiml.constants.ApimlConstants; import org.zowe.apiml.message.core.MessageService; import org.zowe.apiml.ticket.TicketRequest; import org.zowe.apiml.ticket.TicketResponse; @@ -34,47 +33,51 @@ import java.util.Base64; @Service -public class PassticketFilterFactory extends AbstractGatewayFilterFactory { - private final WebClient webClient; - private final InstanceInfoService instanceInfoService; - private final MessageService messageService; - private final String ticketUrl = "%s://%s:%s/%s/api/v1/auth/ticket"; - private final ObjectWriter writer = new ObjectMapper().writer(); +public class PassticketFilterFactory extends AbstractAuthSchemeFactory { + + private static final String TICKET_URL = "%s://%s:%s/%s/api/v1/auth/ticket"; + private static final ObjectWriter WRITER = new ObjectMapper().writer(); public PassticketFilterFactory(WebClient webClient, InstanceInfoService instanceInfoService, MessageService messageService) { - super(Config.class); - this.webClient = webClient; - this.instanceInfoService = instanceInfoService; - this.messageService = messageService; + super(Config.class, webClient, instanceInfoService, messageService); + } + + @Override + protected Class getResponseClass() { + return TicketResponse.class; + } + + @Override + protected TicketResponse getResponseFor401() { + return new TicketResponse(); + } + + @Override + protected WebClient.RequestHeadersSpec createRequest(ServiceInstance instance, String requestBody) { + return webClient.post() + .uri(String.format(TICKET_URL, instance.getScheme(), instance.getHost(), instance.getPort(), instance.getServiceId().toLowerCase())) + .bodyValue(requestBody); + } + + @Override + protected Mono processResponse(ServerWebExchange exchange, GatewayFilterChain chain, TicketResponse response) { + ServerHttpRequest request; + if (response.getTicket() != null) { + String encodedCredentials = Base64.getEncoder().encodeToString((response.getUserId() + ":" + response.getTicket()).getBytes(StandardCharsets.UTF_8)); + final String headerValue = "Basic " + encodedCredentials; + request = setRequestHeader(exchange, HttpHeaders.AUTHORIZATION, headerValue); + } else { + request = updateHeadersForError(exchange, "Invalid or missing authentication."); + } + + exchange = exchange.mutate().request(request).build(); + return chain.filter(exchange); } @Override public GatewayFilter apply(Config config) { try { - final String requestBody = writer.writeValueAsString(new TicketRequest(config.getApplicationName())); - return (ServerWebExchange exchange, GatewayFilterChain chain) -> - instanceInfoService.getServiceInstance("gateway").flatMap(instances -> { - for (ServiceInstance instance : instances) { - return webClient.post() - .uri(String.format(ticketUrl, instance.getScheme(), instance.getHost(), instance.getPort(), instance.getServiceId().toLowerCase())) - .headers(headers -> headers.addAll(exchange.getRequest().getHeaders())) - .bodyValue(requestBody) - .retrieve().onStatus(HttpStatus::is4xxClientError, (response) -> Mono.empty()) - .bodyToMono(TicketResponse.class) - .flatMap(response -> { - if (response.getTicket() == null) { - ServerHttpRequest request = updateHeadersForError(exchange, "Invalid or missing authentication."); - return chain.filter(exchange.mutate().request(request).build()); - } - String encodedCredentials = Base64.getEncoder().encodeToString((response.getUserId() + ":" + response.getTicket()).getBytes(StandardCharsets.UTF_8)); - final String headerValue = "Basic " + encodedCredentials; - ServerHttpRequest request = addRequestHeader(exchange, HttpHeaders.AUTHORIZATION, headerValue); - return chain.filter(exchange.mutate().request(request).build()); - }); - } - ServerHttpRequest request = updateHeadersForError(exchange, "All gateway service instances failed to respond."); - return chain.filter(exchange.mutate().request(request).build()); - }); + return createGatewayFilter(config, WRITER.writeValueAsString(new TicketRequest(config.getApplicationName()))); } catch (JsonProcessingException e) { return ((exchange, chain) -> { ServerHttpRequest request = updateHeadersForError(exchange, e.getMessage()); @@ -83,31 +86,11 @@ public GatewayFilter apply(Config config) { } } - private ServerHttpRequest updateHeadersForError(ServerWebExchange exchange, String errorMessage) { - ServerHttpRequest request = addRequestHeader(exchange, ApimlConstants.AUTH_FAIL_HEADER, messageService.createMessage("org.zowe.apiml.security.ticket.generateFailed", errorMessage).mapToLogMessage()); - exchange.getResponse().getHeaders().add(ApimlConstants.AUTH_FAIL_HEADER, messageService.createMessage("org.zowe.apiml.security.ticket.generateFailed", errorMessage).mapToLogMessage()); - return request; - } + @Data + @EqualsAndHashCode(callSuper = true) + public static class Config extends AbstractAuthSchemeFactory.AbstractConfig { - private ServerHttpRequest addRequestHeader(ServerWebExchange exchange, String key, String value) { - return exchange.getRequest().mutate() - .headers(headers -> { - headers.add(key, value); - headers.remove(org.springframework.http.HttpHeaders.COOKIE); - } - ).build(); - } - - - public static class Config { private String applicationName; - public String getApplicationName() { - return applicationName; - } - - public void setApplicationName(String applicationName) { - this.applicationName = applicationName; - } } } diff --git a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/RobinRoundIterator.java b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/RobinRoundIterator.java new file mode 100644 index 0000000000..c270400baf --- /dev/null +++ b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/RobinRoundIterator.java @@ -0,0 +1,62 @@ +/* + * 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.cloudgatewayservice.filters; + +import java.util.Collection; +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.concurrent.atomic.AtomicInteger; + +public class RobinRoundIterator { + + private final AtomicInteger lastIndex = new AtomicInteger(-1); + + public Iterator getIterator(Collection input) { + int offset = lastIndex.updateAndGet(prev -> input.isEmpty() ? 0 : (prev + 1) % input.size()); + + return new RoundIterator(input, offset); + } + + private class RoundIterator implements Iterator { + + private final Collection collection; + private int remaining; + + private Iterator iteratorOriginal; + + private RoundIterator(Collection collection, int offset) { + this.collection = collection; + this.iteratorOriginal = collection.iterator(); + this.remaining = collection.size(); + for (int i = 0; i < offset; i++) { + this.iteratorOriginal.next(); + } + } + + @Override + public boolean hasNext() { + return remaining > 0; + } + + @Override + public T next() { + if (remaining <= 0) throw new NoSuchElementException(); + + remaining--; + if (!iteratorOriginal.hasNext()) { + iteratorOriginal = collection.iterator(); + } + + return iteratorOriginal.next(); + } + } + +} diff --git a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/ZosmfFilterFactory.java b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/ZosmfFilterFactory.java new file mode 100644 index 0000000000..ef64e6e8c0 --- /dev/null +++ b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/ZosmfFilterFactory.java @@ -0,0 +1,89 @@ +/* + * 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.cloudgatewayservice.filters; + +import lombok.EqualsAndHashCode; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.http.HttpHeaders; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.server.ServerWebExchange; +import org.zowe.apiml.cloudgatewayservice.service.InstanceInfoService; +import org.zowe.apiml.message.core.MessageService; +import org.zowe.apiml.zaas.zosmf.ZosmfResponse; +import reactor.core.publisher.Mono; + +import java.net.HttpCookie; + + +@Service +public class ZosmfFilterFactory extends AbstractAuthSchemeFactory { + + private static final String ZOSMF_URL = "%s://%s:%d/%s/zaas/zosmf"; + + public ZosmfFilterFactory(WebClient webClient, InstanceInfoService instanceInfoService, MessageService messageService) { + super(Config.class, webClient, instanceInfoService, messageService); + } + + @Override + public GatewayFilter apply(Config config) { + try { + return createGatewayFilter(config, null); + } catch (Exception e) { + return ((exchange, chain) -> { + ServerHttpRequest request = updateHeadersForError(exchange, e.getMessage()); + return chain.filter(exchange.mutate().request(request).build()); + }); + } + } + + @Override + protected Class getResponseClass() { + return ZosmfResponse.class; + } + + @Override + protected ZosmfResponse getResponseFor401() { + return new ZosmfResponse(); + } + + @Override + protected WebClient.RequestHeadersSpec createRequest(ServiceInstance instance, Object data) { + String zosmfTokensUrl = String.format(ZOSMF_URL, instance.getScheme(), instance.getHost(), instance.getPort(), instance.getServiceId().toLowerCase()); + return webClient.post() + .uri(zosmfTokensUrl); + } + + @Override + @SuppressWarnings("squid:S2092") // the internal API cannot define generic more specifically + protected Mono processResponse(ServerWebExchange exchange, GatewayFilterChain chain, ZosmfResponse response) { + ServerHttpRequest request; + if (response.getToken() != null) { + request = exchange.getRequest().mutate().headers(headers -> + headers.add(HttpHeaders.COOKIE, new HttpCookie(response.getCookieName(), response.getToken()).toString()) + ).build(); + } else { + request = updateHeadersForError(exchange, "Invalid or missing authentication."); + } + + exchange = exchange.mutate().request(request).build(); + return chain.filter(exchange); + } + + @EqualsAndHashCode(callSuper = true) + public static class Config extends AbstractAuthSchemeFactory.AbstractConfig { + + } + +} diff --git a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/RouteLocator.java b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/RouteLocator.java index 2ff178df0a..c2b5e38776 100644 --- a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/RouteLocator.java +++ b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/RouteLocator.java @@ -82,11 +82,11 @@ Flux> getServiceInstances() { .collectList()); } - void setAuth(RouteDefinition routeDefinition, Authentication auth) { + void setAuth(ServiceInstance serviceInstance, RouteDefinition routeDefinition, Authentication auth) { if (auth != null && auth.getScheme() != null) { SchemeHandler schemeHandler = schemeHandlers.get(auth.getScheme()); if (schemeHandler != null) { - schemeHandler.apply(routeDefinition, auth); + schemeHandler.apply(serviceInstance, routeDefinition, auth); } } } @@ -138,7 +138,7 @@ public Flux getRouteDefinitions() { RouteDefinition routeDefinition = rdp.get(serviceInstance, routedService); routeDefinition.setOrder(order.getAndIncrement()); routeDefinition.getFilters().addAll(commonFilters); - setAuth(routeDefinition, auth); + setAuth(serviceInstance, routeDefinition, auth); return routeDefinition; }).collect(Collectors.toList()) diff --git a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/scheme/HttpBasicPassticket.java b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/scheme/HttpBasicPassticket.java index 04843217d3..283ab6c641 100644 --- a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/scheme/HttpBasicPassticket.java +++ b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/scheme/HttpBasicPassticket.java @@ -10,6 +10,8 @@ package org.zowe.apiml.cloudgatewayservice.service.scheme; +import org.apache.commons.lang3.StringUtils; +import org.springframework.cloud.client.ServiceInstance; import org.springframework.cloud.gateway.filter.FilterDefinition; import org.springframework.cloud.gateway.route.RouteDefinition; import org.springframework.stereotype.Component; @@ -25,10 +27,11 @@ public AuthenticationScheme getAuthenticationScheme() { } @Override - public void apply(RouteDefinition routeDefinition, Authentication auth) { + public void apply(ServiceInstance serviceInstance, RouteDefinition routeDefinition, Authentication auth) { FilterDefinition filerDef = new FilterDefinition(); filerDef.setName("PassticketFilterFactory"); filerDef.addArg("applicationName", auth.getApplid()); + filerDef.addArg("serviceId", StringUtils.lowerCase(serviceInstance.getServiceId())); routeDefinition.getFilters().add(filerDef); } diff --git a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/scheme/SchemeHandler.java b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/scheme/SchemeHandler.java index 32cac7f6de..2b385ecf1d 100644 --- a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/scheme/SchemeHandler.java +++ b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/scheme/SchemeHandler.java @@ -10,6 +10,7 @@ package org.zowe.apiml.cloudgatewayservice.service.scheme; +import org.springframework.cloud.client.ServiceInstance; import org.springframework.cloud.gateway.route.RouteDefinition; import org.zowe.apiml.auth.Authentication; import org.zowe.apiml.auth.AuthenticationScheme; @@ -29,6 +30,6 @@ public interface SchemeHandler { * @param routeDefinition rule to be updated * @param auth definition of authentication scheme from the service instance */ - void apply(RouteDefinition routeDefinition, Authentication auth); + void apply(ServiceInstance serviceInstance, RouteDefinition routeDefinition, Authentication auth); } diff --git a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/scheme/X509.java b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/scheme/X509.java index 0613454ee8..1d12ba2578 100644 --- a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/scheme/X509.java +++ b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/scheme/X509.java @@ -10,6 +10,7 @@ package org.zowe.apiml.cloudgatewayservice.service.scheme; +import org.springframework.cloud.client.ServiceInstance; import org.springframework.cloud.gateway.filter.FilterDefinition; import org.springframework.cloud.gateway.route.RouteDefinition; import org.springframework.stereotype.Component; @@ -28,7 +29,7 @@ public AuthenticationScheme getAuthenticationScheme() { } @Override - public void apply(RouteDefinition routeDefinition, Authentication auth) { + public void apply(ServiceInstance serviceInstance, RouteDefinition routeDefinition, Authentication auth) { FilterDefinition x509filter = new FilterDefinition(); x509filter.setName("X509FilterFactory"); Map m = new HashMap<>(); diff --git a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/scheme/Zosmf.java b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/scheme/Zosmf.java new file mode 100644 index 0000000000..fcfc78db26 --- /dev/null +++ b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/scheme/Zosmf.java @@ -0,0 +1,37 @@ +/* + * 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.cloudgatewayservice.service.scheme; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.gateway.filter.FilterDefinition; +import org.springframework.cloud.gateway.route.RouteDefinition; +import org.springframework.stereotype.Component; +import org.zowe.apiml.auth.Authentication; +import org.zowe.apiml.auth.AuthenticationScheme; + +@Component +public class Zosmf implements SchemeHandler { + + @Override + public AuthenticationScheme getAuthenticationScheme() { + return AuthenticationScheme.ZOSMF; + } + + @Override + public void apply(ServiceInstance serviceInstance, RouteDefinition routeDefinition, Authentication auth) { + FilterDefinition filerDef = new FilterDefinition(); + filerDef.setName("ZosmfFilterFactory"); + filerDef.addArg("serviceId", StringUtils.lowerCase(serviceInstance.getServiceId())); + routeDefinition.getFilters().add(filerDef); + } + +} diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/CorsPerServiceTest.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/CorsPerServiceTest.java index 18067aec1b..d93d26ca7e 100644 --- a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/CorsPerServiceTest.java +++ b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/CorsPerServiceTest.java @@ -13,31 +13,33 @@ import org.hamcrest.Matchers; import org.junit.jupiter.api.Test; import org.zowe.apiml.cloudgatewayservice.acceptance.common.AcceptanceTest; -import org.zowe.apiml.cloudgatewayservice.acceptance.common.AcceptanceTestWithTwoServices; +import org.zowe.apiml.cloudgatewayservice.acceptance.common.AcceptanceTestWithMockServices; import java.io.IOException; import static io.restassured.RestAssured.given; import static org.apache.http.HttpStatus.SC_OK; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertNull; @AcceptanceTest -class CorsPerServiceTest extends AcceptanceTestWithTwoServices { +class CorsPerServiceTest extends AcceptanceTestWithMockServices { private static final String HEADER_X_FORWARD_TO = "X-Forward-To"; @Test void routeToServiceWithCorsEnabled() throws IOException { - mockServerWithSpecificHttpResponse(200, "/serviceid2/test", 0, (headers) -> - assertTrue(headers != null && headers.get("Origin") == null), - "".getBytes() - ); + mockService("serviceid1") + .addEndpoint("/test") + .assertion(he -> assertNull(he.getRequestHeaders().getFirst("Origin"))) + .and().start(); + given() .header("Origin", "https://localhost:3000") - .header(HEADER_X_FORWARD_TO, "serviceid2") - .when() - .get(basePath + serviceWithDefaultConfiguration.getPath()) - .then().statusCode(Matchers.is(SC_OK)); + .header(HEADER_X_FORWARD_TO, "serviceid1") + .when() + .get(basePath + "/test") + .then() + .statusCode(Matchers.is(SC_OK)); } } diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/PassticketTest.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/PassticketTest.java index 38c0bd458c..b8384f0418 100644 --- a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/PassticketTest.java +++ b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/PassticketTest.java @@ -10,75 +10,72 @@ package org.zowe.apiml.cloudgatewayservice.acceptance; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.ObjectWriter; -import com.netflix.appinfo.InstanceInfo; import org.apache.http.HttpHeaders; import org.hamcrest.Matchers; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.cloud.client.ServiceInstance; -import org.springframework.cloud.netflix.eureka.EurekaServiceInstance; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; +import org.zowe.apiml.auth.AuthenticationScheme; import org.zowe.apiml.cloudgatewayservice.acceptance.common.AcceptanceTest; -import org.zowe.apiml.cloudgatewayservice.acceptance.common.AcceptanceTestWithTwoServices; -import org.zowe.apiml.cloudgatewayservice.service.InstanceInfoService; +import org.zowe.apiml.cloudgatewayservice.acceptance.common.AcceptanceTestWithMockServices; +import org.zowe.apiml.cloudgatewayservice.acceptance.common.MockService; import org.zowe.apiml.ticket.TicketResponse; -import reactor.core.publisher.Mono; import java.io.IOException; -import java.util.Collections; -import java.util.concurrent.atomic.AtomicBoolean; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import static io.restassured.RestAssured.given; import static org.apache.http.HttpStatus.SC_OK; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; @AcceptanceTest -public class PassticketTest extends AcceptanceTestWithTwoServices { +public class PassticketTest extends AcceptanceTestWithMockServices { - @MockBean - InstanceInfoService instanceInfoService; - - @DynamicPropertySource - static void registerProps(DynamicPropertyRegistry registry) { - registry.add("apiml.service.gateway.proxy.enabled", () -> "false"); - } + private static final String USER_ID = "user"; + private static final String SERVICE_ID = "serviceusingpassticket"; + private static final String COOKIE_NAME = "apimlAuthenticationToken"; + private static final String JWT = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwiaWF0IjoxNjcxNDYxNjIzLCJleHAiOjE2NzE0OTA0MjMsImlzcyI6IkFQSU1MIiwianRpIjoiYmFlMTkyZTYtYTYxMi00MThhLWI2ZGMtN2I0NWI5NzM4ODI3IiwiZG9tIjoiRHVtbXkgcHJvdmlkZXIifQ.Vt5UjJUlbmuzmmEIodAACtj_AOxlsWqkFrFyWh4_MQRRPCj_zMIwnzpqRN-NJvKtUg1zxOCzXv2ypYNsglrXc7cH9wU3leK1gjYxK7IJjn2SBEb0dUL5m7-h4tFq2zNhcGH2GOmTpE2gTQGSTvDIdja-TIj_lAvUtbkiorm1RqrNu2MGC0WfgOGiak3tj2tNJLv_Y1ZMxNjzyHgXBMuNPozQrd4Vtnew3x4yy85LrTYF7jJM3U-e3AD2yImftxwycQvbkjNb-lWadejTVH0MgHMr04wVdDd8Nq5q7yrZf7YPzhias8ehNbew5CHiKut9SseZ1sO2WwgfhpEfsN4okg"; + private static final String PASSTICKET = "ZOWE_DUMMY_PASS_TICKET"; @BeforeEach - void setup() { - InstanceInfo info = InstanceInfo.Builder.newBuilder().setInstanceId("gateway").setHostName("localhost").setPort(getApplicationRegistry().findFreePort() + 1).setAppName("gateway").setStatus(InstanceInfo.InstanceStatus.UP).build(); - ServiceInstance instance = new EurekaServiceInstance(info); - Mockito.when(instanceInfoService.getServiceInstance("gateway")).thenReturn(Mono.just(Collections.singletonList(instance))); + void setup() throws IOException { + TicketResponse response = new TicketResponse(); + response.setToken(JWT); + response.setUserId(USER_ID); + response.setApplicationName("IZUDFLT"); + response.setTicket(PASSTICKET); + + mockService("gateway").scope(MockService.Scope.CLASS) + .addEndpoint("/gateway/api/v1/auth/ticket") + .assertion(he -> assertEquals(SERVICE_ID, he.getRequestHeaders().getFirst("X-Service-Id"))) + .assertion(he -> assertEquals(COOKIE_NAME + "=" + JWT, he.getRequestHeaders().getFirst("Cookie"))) + .bodyJson(response) + .and().start(); } @Nested class GivenValidAuthentication { + @Test void whenRequestingPassticketForAllowedAPPLID_thenTranslate() throws IOException { - AtomicBoolean result = new AtomicBoolean(false); - mockServerWithSpecificHttpResponse(200, "/serviceid2/test", 0, (headers) -> { - result.set(headers != null && headers.get(HttpHeaders.AUTHORIZATION) != null); - }, "".getBytes() - ); - TicketResponse response = new TicketResponse(); - response.setToken("eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwiaWF0IjoxNjcxNDYxNjIzLCJleHAiOjE2NzE0OTA0MjMsImlzcyI6IkFQSU1MIiwianRpIjoiYmFlMTkyZTYtYTYxMi00MThhLWI2ZGMtN2I0NWI5NzM4ODI3IiwiZG9tIjoiRHVtbXkgcHJvdmlkZXIifQ.Vt5UjJUlbmuzmmEIodAACtj_AOxlsWqkFrFyWh4_MQRRPCj_zMIwnzpqRN-NJvKtUg1zxOCzXv2ypYNsglrXc7cH9wU3leK1gjYxK7IJjn2SBEb0dUL5m7-h4tFq2zNhcGH2GOmTpE2gTQGSTvDIdja-TIj_lAvUtbkiorm1RqrNu2MGC0WfgOGiak3tj2tNJLv_Y1ZMxNjzyHgXBMuNPozQrd4Vtnew3x4yy85LrTYF7jJM3U-e3AD2yImftxwycQvbkjNb-lWadejTVH0MgHMr04wVdDd8Nq5q7yrZf7YPzhias8ehNbew5CHiKut9SseZ1sO2WwgfhpEfsN4okg"); - response.setUserId("user"); - response.setApplicationName("IZUDFLT"); - response.setTicket("ZOWE_DUMMY_PASS_TICKET"); - ObjectWriter writer = new ObjectMapper().writer(); - mockServerWithSpecificHttpResponse(200, "/gateway/api/v1/auth/ticket", getApplicationRegistry().findFreePort() + 1, (headers) -> { - }, writer.writeValueAsString(response).getBytes() - ); + String expectedAuthHeader = "Basic " + Base64.getEncoder().encodeToString((USER_ID + ":" + PASSTICKET).getBytes(StandardCharsets.UTF_8)); + MockService mockService = mockService(SERVICE_ID) + .authenticationScheme(AuthenticationScheme.HTTP_BASIC_PASSTICKET).applid("IZUDFLT") + .addEndpoint("/" + SERVICE_ID + "/test") + .assertion(he -> assertEquals(expectedAuthHeader, he.getRequestHeaders().getFirst(HttpHeaders.AUTHORIZATION))) + .and().start(); + given() - .when() - .get(basePath + "/serviceid2/api/v1/test") - .then().statusCode(Matchers.is(SC_OK)); - assertTrue(result.get()); + .cookie(COOKIE_NAME, JWT) + .when() + .get(basePath + "/" + SERVICE_ID + "/api/v1/test") + .then() + .statusCode(Matchers.is(SC_OK)); + + assertEquals(1, mockService.getEndpoint().getCounter()); } + } + } diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/RequestInstanceTest.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/RequestInstanceTest.java index 9d208c4845..4df5279b54 100644 --- a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/RequestInstanceTest.java +++ b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/RequestInstanceTest.java @@ -11,13 +11,13 @@ package org.zowe.apiml.cloudgatewayservice.acceptance; import org.hamcrest.Matchers; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.TestPropertySource; import org.zowe.apiml.cloudgatewayservice.acceptance.common.AcceptanceTest; -import org.zowe.apiml.cloudgatewayservice.acceptance.common.AcceptanceTestWithTwoServices; +import org.zowe.apiml.cloudgatewayservice.acceptance.common.AcceptanceTestWithMockServices; +import org.zowe.apiml.cloudgatewayservice.acceptance.common.MockService; import java.io.IOException; @@ -30,50 +30,46 @@ @TestPropertySource(properties = { "apiml.service.corsEnabled=false" }) -class RequestInstanceTest extends AcceptanceTestWithTwoServices { +class RequestInstanceTest extends AcceptanceTestWithMockServices { private static final String HEADER_X_FORWARD_TO = "X-Forward-To"; - @BeforeEach + @BeforeAll void setUp() throws IOException { - mockServerWithSpecificHttpResponse(200, "/serviceid1/test", 0, (headers) -> { - }, "".getBytes()); + mockService("serviceid1").scope(MockService.Scope.CLASS) + .addEndpoint("/test") + .and().start(); } - @Nested - class WhenValidInstanceId { - - @Test - void routeToCorrectService() { - given() - .header(HEADER_X_FORWARD_TO, "serviceid1") - .when() - .get(basePath + serviceWithCustomConfiguration.getPath()) - .then().statusCode(Matchers.is(SC_OK)); - } - } - - @Nested - class WhenNonExistingInstanceId { - @Test - void cantRouteToServer() { - given() - .header(HEADER_X_FORWARD_TO, "non-existing"). - when() - .get(basePath + serviceWithCustomConfiguration.getPath()) - .then() - .statusCode(is(SC_NOT_FOUND)); - } + @Test + void routeToCorrectService() { + given() + .header(HEADER_X_FORWARD_TO, "serviceid1") + .when() + .get(basePath + "/test") + .then() + .statusCode(Matchers.is(SC_OK)); } @Test void routeToServiceWithCorsDisabled() { - given() .header("Origin", "https://localhost:3000") .header(HEADER_X_FORWARD_TO, "serviceid1") - .when() - .get(basePath + serviceWithCustomConfiguration.getPath()) - .then().statusCode(Matchers.is(SC_FORBIDDEN)); + .when() + .get(basePath + "/test") + .then() + .statusCode(Matchers.is(SC_FORBIDDEN)); + } + + @Test + void cantRouteToServer() { + given() + .header(HEADER_X_FORWARD_TO, "non-existing") + .when() + .get(basePath + "/test") + .then() + .statusCode(is(SC_NOT_FOUND)); } + } diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/RetryPerServiceTest.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/RetryPerServiceTest.java index 5a86e531ab..acede74756 100644 --- a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/RetryPerServiceTest.java +++ b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/RetryPerServiceTest.java @@ -10,14 +10,15 @@ package org.zowe.apiml.cloudgatewayservice.acceptance; -import com.sun.net.httpserver.Headers; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; import org.zowe.apiml.cloudgatewayservice.acceptance.common.AcceptanceTest; -import org.zowe.apiml.cloudgatewayservice.acceptance.common.AcceptanceTestWithTwoServices; +import org.zowe.apiml.cloudgatewayservice.acceptance.common.AcceptanceTestWithMockServices; +import org.zowe.apiml.cloudgatewayservice.acceptance.common.MockService; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Consumer; +import java.io.IOException; import static io.restassured.RestAssured.given; import static org.apache.http.HttpStatus.SC_SERVICE_UNAVAILABLE; @@ -26,70 +27,58 @@ import static org.junit.jupiter.api.Assertions.assertEquals; @AcceptanceTest -class RetryPerServiceTest extends AcceptanceTestWithTwoServices { +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class RetryPerServiceTest extends AcceptanceTestWithMockServices { private static final String HEADER_X_FORWARD_TO = "X-Forward-To"; - Consumer dummyConsumer = (headers -> { - }); + private MockService mockService; + + @BeforeAll + void startMockService() throws IOException { + mockService = mockService("serviceid1").scope(MockService.Scope.CLASS) + .addEndpoint("/503").responseCode(503) + .and() + .addEndpoint("/401").responseCode(401) + .and().start(); + } @Nested class GivenRetryOnAllOperationsIsDisabled { + @Test void whenGetReturnsUnavailable_thenRetry() throws Exception { - AtomicInteger counter = mockServerWithSpecificHttpResponse(503, "/serviceid2/test", 0, dummyConsumer, "".getBytes()); given() - .header(HEADER_X_FORWARD_TO, "serviceid2") - .when() - .get(basePath + serviceWithDefaultConfiguration.getPath()) - .then().statusCode(is(SC_SERVICE_UNAVAILABLE)); - assertEquals(6, counter.get()); + .header(HEADER_X_FORWARD_TO, "serviceid1") + .when() + .get(basePath + "/503") + .then() + .statusCode(is(SC_SERVICE_UNAVAILABLE)); + assertEquals(6, mockService.getCounter()); } @Test void whenRequestReturnsUnauthorized_thenDontRetry() throws Exception { - AtomicInteger counter = mockServerWithSpecificHttpResponse(401, "/serviceid2/test", 0, dummyConsumer, "".getBytes()); - given() - .header(HEADER_X_FORWARD_TO, "serviceid2") - .when() - .get(basePath + serviceWithDefaultConfiguration.getPath()) - .then().statusCode(is(SC_UNAUTHORIZED)); - assertEquals(1, counter.get()); - given() - .header(HEADER_X_FORWARD_TO, "serviceid2") + for (int i = 1; i < 6; i++) { + given() + .header(HEADER_X_FORWARD_TO, "serviceid1") .when() - .post(basePath + serviceWithDefaultConfiguration.getPath()) - .then().statusCode(is(SC_UNAUTHORIZED)); - assertEquals(2, counter.get()); - given() - .header(HEADER_X_FORWARD_TO, "serviceid2") - .when() - .put(basePath + serviceWithDefaultConfiguration.getPath()) - .then().statusCode(is(SC_UNAUTHORIZED)); - assertEquals(3, counter.get()); - given() - .header(HEADER_X_FORWARD_TO, "serviceid2") - .when() - .delete(basePath + serviceWithDefaultConfiguration.getPath()) - .then().statusCode(is(SC_UNAUTHORIZED)); - assertEquals(4, counter.get()); - given() - .header(HEADER_X_FORWARD_TO, "serviceid2") - .when() - .patch(basePath + serviceWithDefaultConfiguration.getPath()) - .then().statusCode(is(SC_UNAUTHORIZED)); - assertEquals(5, counter.get()); + .get(basePath + "/401") + .then() + .statusCode(is(SC_UNAUTHORIZED)); + assertEquals(i, mockService.getCounter()); + } } @Test void whenPostReturnsUnavailable_thenDontRetry() throws Exception { - AtomicInteger counter = mockServerWithSpecificHttpResponse(503, "/serviceid2/test", 0, dummyConsumer, "".getBytes()); given() - .header(HEADER_X_FORWARD_TO, "serviceid2") - .when() - .post(basePath + serviceWithDefaultConfiguration.getPath()) - .then().statusCode(is(SC_SERVICE_UNAVAILABLE)); - assertEquals(1, counter.get()); + .header(HEADER_X_FORWARD_TO, "serviceid1") + .when() + .post(basePath + "/503") + .then() + .statusCode(is(SC_SERVICE_UNAVAILABLE)); + assertEquals(1, mockService.getCounter()); } } diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/ZosmfSchemeTest.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/ZosmfSchemeTest.java new file mode 100644 index 0000000000..bd165b5617 --- /dev/null +++ b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/ZosmfSchemeTest.java @@ -0,0 +1,330 @@ +/* + * 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.cloudgatewayservice.acceptance; + +import com.sun.net.httpserver.HttpExchange; +import org.apache.commons.lang3.StringUtils; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.http.HttpHeaders; +import org.zowe.apiml.auth.AuthenticationScheme; +import org.zowe.apiml.cloudgatewayservice.acceptance.common.AcceptanceTest; +import org.zowe.apiml.cloudgatewayservice.acceptance.common.AcceptanceTestWithMockServices; +import org.zowe.apiml.cloudgatewayservice.acceptance.common.MockService; +import org.zowe.apiml.zaas.zosmf.ZosmfResponse; + +import java.io.IOException; +import java.net.HttpCookie; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.*; + +public class ZosmfSchemeTest { + + private static final String COOKIE_NAME = "zosmf_cookie"; + private static final String JWT = "jwt"; + private static final ZosmfResponse OK_RESPONSE = new ZosmfResponse(COOKIE_NAME, JWT); + + private String getCookie(HttpExchange httpExchange, String cookieName) { + List cookies = Optional.ofNullable(httpExchange.getRequestHeaders().get("Cookie")) + .orElse(Collections.emptyList()) + .stream() + .map(HttpCookie::parse) + .flatMap(Collection::stream) + .filter(c -> StringUtils.equalsIgnoreCase(cookieName, c.getName())) + .collect(Collectors.toList()); + assertTrue(cookies.size() <= 1); + return cookies.isEmpty() ? null : cookies.get(0).getValue(); + } + + @Nested + @AcceptanceTest + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class HighAvailability extends AcceptanceTestWithMockServices { + + private MockService zaasZombie; + private MockService zaasError; + private MockService zaasOk; + + private MockService service; + + private String getServiceUrl() { + return basePath + "/service/api/v1/test"; + } + + @BeforeAll + void createAllZaasServices() throws IOException { + // on the beginning prepare all as zombie, each test will decide + zaasError = mockService("gateway").scope(MockService.Scope.CLASS) + .addEndpoint("/gateway/zaas/zosmf") + .responseCode(500) + .and().build(); + zaasZombie = mockService("gateway").scope(MockService.Scope.CLASS) + .addEndpoint("/gateway/zaas/zosmf") + .bodyJson(new ZosmfResponse()) + .and().build(); + zaasOk = mockService("gateway").scope(MockService.Scope.CLASS) + .addEndpoint("/gateway/zaas/zosmf") + .bodyJson(OK_RESPONSE) + .assertion(he -> assertEquals("service", he.getRequestHeaders().getFirst("x-service-id"))) + .and().build(); + + // south-bound service - alive for all tests + service = mockService("service").scope(MockService.Scope.CLASS) + .authenticationScheme(AuthenticationScheme.ZOSMF) + .addEndpoint("/service/test") + .assertion(he -> assertEquals(JWT, getCookie(he, COOKIE_NAME))) + .and().start(); + } + + @Test + void givenNoInstanceOfZosmf_whenCallingAService_thenReturn500() { + zaasZombie.stop(); + zaasError.stop(); + zaasOk.stop(); + + given().when().get(getServiceUrl()).then().statusCode(500); + assertEquals(0, service.getCounter()); + } + + @Test + void givenInstanceOfZosmf_whenCallingAService_thenReturn200() throws IOException { + zaasZombie.stop(); + zaasError.stop(); + zaasOk.start(); + + given().when().get(getServiceUrl()).then().statusCode(200); + assertEquals(1, service.getCounter()); + } + + @Test + void givenZombieAndOkInstanceOfZosmf_whenCallingAService_preventZombieOne() throws IOException { + zaasZombie.zombie(); + zaasError.stop(); + zaasOk.start(); + + for (int i = 1; i < 10; i++) { + given().when().get(getServiceUrl()).then().statusCode(200); + assertEquals(i, zaasOk.getCounter()); + assertEquals(i, service.getCounter()); + } + } + + @Test + void givenOnlyZombieZosmf_whenCallingAService_return500() { + zaasZombie.zombie(); + zaasError.stop(); + zaasOk.stop(); + + given().when().get(getServiceUrl()).then().statusCode(500); + assertEquals(0, service.getCounter()); + } + + @Test + void givenZombieAndErrorZosmf_whenCallingAService_return500() throws IOException { + zaasZombie.zombie(); + zaasError.start(); + zaasOk.stop(); + + given().when().get(getServiceUrl()).then().statusCode(500); + assertEquals(0, service.getCounter()); + } + + @Test + void givenZombieFailingAndSuccessZosmf_whenCallingAService_return200() throws IOException { + zaasZombie.zombie(); + zaasError.start(); + zaasOk.start(); + + for (int i = 1; i < 10; i++) { + given().when().get(getServiceUrl()).then().statusCode(200); + assertEquals(i, zaasOk.getCounter()); + assertEquals(i, service.getCounter()); + } + assertNotEquals(0, zaasError.getCounter()); + } + + } + + @Nested + @AcceptanceTest + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class InvalidAuthentication extends AcceptanceTestWithMockServices { + + private MockService zaas; + private MockService service; + + private String getServiceUrl() { + return basePath + "/service/api/v1/test"; + } + + @BeforeAll + void createServices() throws IOException { + zaas = mockService("gateway").scope(MockService.Scope.CLASS) + .addEndpoint("/gateway/zaas/zosmf") + .responseCode(401) + .and().start(); + + service = mockService("service").scope(MockService.Scope.CLASS) + .authenticationScheme(AuthenticationScheme.ZOSMF) + .addEndpoint("/service/test") + .assertion(he -> assertNull(getCookie(he, COOKIE_NAME))) + .and().start(); + } + + @Test + void givenZaasWithInvalidResponse_whenCallingAService_thenDontPropagateCredentials() { + given().when().get(getServiceUrl()).then().statusCode(200); + assertEquals(1, zaas.getCounter()); + assertEquals(1, service.getCounter()); + } + + } + + @Nested + @AcceptanceTest + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class ResponseWithoutToken extends AcceptanceTestWithMockServices { + + private MockService zaas; + private MockService service; + + private String getServiceUrl() { + return basePath + "/service/api/v1/test"; + } + + @BeforeAll + void createZaas() throws IOException { + zaas = mockService("gateway").scope(MockService.Scope.CLASS) + .addEndpoint("/gateway/zaas/zosmf") + .responseCode(200) + .bodyJson(new ZosmfResponse()) + .and().start(); + + + service = mockService("service").scope(MockService.Scope.CLASS) + .authenticationScheme(AuthenticationScheme.ZOSMF) + .addEndpoint("/service/test") + .assertion(he -> assertNull(getCookie(he, COOKIE_NAME))) + .and().start(); + } + + @Test + void givenNoCredentials_whenCallingAService_thenDontPropagateCredentials() { + given().when().get(getServiceUrl()).then().statusCode(200); + assertEquals(1, zaas.getCounter()); + assertEquals(1, service.getCounter()); + } + + @Test + void givenInvalidCredentials_whenCallingAService_thenDontPropagateCredentials() { + given() + .header(HttpHeaders.AUTHORIZATION, "Baerer nonSense") + .when() + .get(getServiceUrl()) + .then() + .statusCode(200); + assertEquals(1, zaas.getCounter()); + assertEquals(1, service.getCounter()); + } + + } + + @Nested + @AcceptanceTest + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class ZaasCommunication extends AcceptanceTestWithMockServices { + + @BeforeAll + void createZaas() throws IOException { + mockService("gateway").scope(MockService.Scope.CLASS) + .addEndpoint("/gateway/zaas/zosmf") + .bodyJson(OK_RESPONSE) + .assertion(he -> assertEquals("Bearer userJwt", he.getRequestHeaders().getFirst(HttpHeaders.AUTHORIZATION))) + + .assertion(he -> assertEquals("service", he.getRequestHeaders().getFirst("x-service-id"))) + + .assertion(he -> assertNull(he.getRequestHeaders().getFirst("myheader"))) + .assertion(he -> assertNull(he.getRequestHeaders().getFirst("X-SAF-Token"))) + .assertion(he -> assertNull(he.getRequestHeaders().getFirst("X-Certificate-Public"))) + .assertion(he -> assertNull(he.getRequestHeaders().getFirst("X-Certificate-DistinguishedName"))) + .assertion(he -> assertNull(he.getRequestHeaders().getFirst("X-Certificate-CommonName"))) + + .assertion(he -> assertNull(getCookie(he, "mycookie"))) + .assertion(he -> assertEquals("pat", getCookie(he, "personalAccessToken"))) + .assertion(he -> assertEquals("jwt1", getCookie(he, "apimlAuthenticationToken"))) + .assertion(he -> assertEquals("jwt2", getCookie(he, "apimlAuthenticationToken.2"))) + .assertion(he -> assertNull(getCookie(he, "jwtToken"))) + .assertion(he -> assertNull(getCookie(he, "LtpaToken2"))) + .and().start(); + } + + @BeforeAll + void createService() throws IOException { + mockService("service").scope(MockService.Scope.CLASS) + .authenticationScheme(AuthenticationScheme.ZOSMF) + .addEndpoint("/service/test") + .assertion(he -> assertEquals(JWT, getCookie(he, COOKIE_NAME))) + + .assertion(he -> assertNull(he.getRequestHeaders().getFirst(HttpHeaders.AUTHORIZATION))) + .assertion(he -> assertNull(he.getRequestHeaders().getFirst("x-service-id"))) + .assertion(he -> assertNull(he.getRequestHeaders().getFirst("X-SAF-Token"))) + .assertion(he -> assertNull(he.getRequestHeaders().getFirst("X-Certificate-Public"))) + .assertion(he -> assertNull(he.getRequestHeaders().getFirst("X-Certificate-DistinguishedName"))) + .assertion(he -> assertNull(he.getRequestHeaders().getFirst("X-Certificate-CommonName"))) + .assertion(he -> assertEquals("myvalue", he.getRequestHeaders().getFirst("myheader"))) + + .assertion(he -> assertNull(getCookie(he, "personalAccessToken"))) + .assertion(he -> assertNull(getCookie(he, "apimlAuthenticationToken"))) + .assertion(he -> assertNull(getCookie(he, "apimlAuthenticationToken.2"))) + .assertion(he -> assertNull(getCookie(he, "jwtToken"))) + .assertion(he -> assertNull(getCookie(he, "LtpaToken2"))) + .assertion(he -> assertEquals("mycookievalue", getCookie(he, "mycookie"))) + .and().start(); + } + + private String getServiceUrl() { + return basePath + "/service/api/v1/test"; + } + + @Test + void givenMultipleHeaders_whenCallingAService_thenTheyAreResend() { + given() + .header(HttpHeaders.AUTHORIZATION, "Bearer userJwt") + + .header("myheader", "myvalue") + .header("X-SAF-Token", "X-SAF-Token") + .header("X-Certificate-Public", "X-Certificate-Public") + .header("X-Certificate-DistinguishedName", "X-Certificate-DistinguishedName") + .header("X-Certificate-CommonName", "X-Certificate-CommonName") + + .cookie("mycookie", "mycookievalue") + .cookie("personalAccessToken", "pat") + .cookie("apimlAuthenticationToken", "jwt1") + .cookie("apimlAuthenticationToken.2", "jwt2") + .cookie("jwtToken", "jwtToken") + .cookie("LtpaToken2", "LtpaToken2") + .when() + .get(getServiceUrl()) + .then() + .statusCode(200); + } + + } + +} diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/common/AcceptanceTestWithBasePath.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/common/AcceptanceTestWithBasePath.java index e448632ff7..3a375c09c9 100644 --- a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/common/AcceptanceTestWithBasePath.java +++ b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/common/AcceptanceTestWithBasePath.java @@ -21,7 +21,9 @@ public class AcceptanceTestWithBasePath { protected int port; @BeforeEach - public void setBasePath() { + void setBasePath() { basePath = String.format("https://localhost:%d", port); } + + } diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/common/AcceptanceTestWithMockServices.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/common/AcceptanceTestWithMockServices.java new file mode 100644 index 0000000000..4325183d8b --- /dev/null +++ b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/common/AcceptanceTestWithMockServices.java @@ -0,0 +1,87 @@ +/* + * 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.cloudgatewayservice.acceptance.common; + +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cloud.gateway.event.RefreshRoutesEvent; +import org.springframework.context.ApplicationEventPublisher; +import org.zowe.apiml.cloudgatewayservice.acceptance.netflix.ApplicationRegistry; + +@Slf4j +@AcceptanceTest +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class AcceptanceTestWithMockServices extends AcceptanceTestWithBasePath { + + @Autowired + private ApplicationEventPublisher applicationEventPublisher; + + @Autowired + protected ApplicationRegistry applicationRegistry; + + @BeforeEach + void resetCounters() { + applicationRegistry.getMockServices().forEach(MockService::resetCounter); + } + + @AfterEach + void checkAssertionErrorsOnMockServices() { + MockService.checkAssertionErrors(); + } + + protected void updateRoutingRules() { + applicationEventPublisher.publishEvent(new RefreshRoutesEvent("List of services changed")); + } + + /** + * Create mock service. It will be automatically registred and removed on the time. It is not necessary to handle + * its lifecycle. + * + * Example: + * + * MockService myService; + * + * @BeforeAll + * void createMyService() { + * myService = mockService("myservice").scope(MockService.Scope.CLASS) + * .addEndpoint("/test/500") + * .responseCode(500) + * .bodyJson("{}") + * .and().start(); + * } + * + * @param serviceId serviceId of the new service + * @return builder to define a new MockService + */ + protected MockService.MockServiceBuilder mockService(String serviceId) { + return MockService.builder() + .statusChangedlistener(mockService -> { + applicationRegistry.update(mockService); + updateRoutingRules(); + }) + .serviceId(serviceId); + } + + @AfterEach + void stopMocksWithTestScope() { + applicationRegistry.afterTest(); + } + + @AfterAll + void stopMocksWithClassScope() { + applicationRegistry.afterClass(); + } + +} diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/common/AcceptanceTestWithTwoServices.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/common/AcceptanceTestWithTwoServices.java deleted file mode 100644 index c5899a10cd..0000000000 --- a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/common/AcceptanceTestWithTwoServices.java +++ /dev/null @@ -1,78 +0,0 @@ -/* - * 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.cloudgatewayservice.acceptance.common; - -import com.sun.net.httpserver.Headers; -import com.sun.net.httpserver.HttpServer; -import org.apache.http.HttpHeaders; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; -import org.zowe.apiml.cloudgatewayservice.acceptance.netflix.ApimlDiscoveryClientStub; -import org.zowe.apiml.cloudgatewayservice.acceptance.netflix.ApplicationRegistry; - -import java.io.IOException; -import java.net.InetSocketAddress; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Consumer; - -@AcceptanceTest -public class AcceptanceTestWithTwoServices extends AcceptanceTestWithBasePath { - - @Autowired - @Qualifier("test") - protected ApimlDiscoveryClientStub discoveryClient; - @Autowired - protected ApplicationRegistry applicationRegistry; - - public ApplicationRegistry getApplicationRegistry() { - return applicationRegistry; - } - - protected HttpServer server; - protected Service serviceWithDefaultConfiguration = new Service("serviceid2", "/serviceid2/**", "serviceid2"); - protected Service serviceWithCustomConfiguration = new Service("serviceid1", "/serviceid1/**", "serviceid1"); - - @BeforeEach - public void prepareApplications() { - applicationRegistry.clearApplications(); - applicationRegistry.addApplication(serviceWithDefaultConfiguration, MetadataBuilder.defaultInstance(), false); - applicationRegistry.addApplication(serviceWithCustomConfiguration, MetadataBuilder.customInstance(), false); - } - - @AfterEach - public void tearDown() { - server.stop(0); - } - - protected AtomicInteger mockServerWithSpecificHttpResponse(int statusCode, String uri, int port, Consumer assertion, byte[] body) throws IOException { - if (port == 0) { - port = applicationRegistry.findFreePort(); - } - server = HttpServer.create(new InetSocketAddress(port), 0); - AtomicInteger counter = new AtomicInteger(); - server.createContext(uri, (t) -> { - t.getResponseHeaders().add(HttpHeaders.CONTENT_TYPE, "application/json"); - t.sendResponseHeaders(statusCode, 0); - - t.getResponseBody().write(body); - - assertion.accept(t.getRequestHeaders()); - t.getResponseBody().close(); - - counter.getAndIncrement(); - }); - server.setExecutor(null); - server.start(); - return counter; - } -} diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/common/MetadataBuilder.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/common/MetadataBuilder.java deleted file mode 100644 index e5025599d1..0000000000 --- a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/common/MetadataBuilder.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - * 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.cloudgatewayservice.acceptance.common; - -import java.util.HashMap; -import java.util.Map; - -public class MetadataBuilder { - private Map metadata; - - public MetadataBuilder() { - metadata = new HashMap<>(); - metadata.put("apiml.routes.api-v1.gatewayUrl", "api/v1"); - metadata.put("apiml.routes.api-v1.serviceUrl", "/serviceid2"); - - } - - - public Map build() { - return metadata; - } - - public static MetadataBuilder defaultInstance() { - MetadataBuilder builder = new MetadataBuilder(); - builder.metadata.put("apiml.corsEnabled", "true"); - builder.metadata.put("apiml.authentication.scheme", "httpBasicPassTicket"); - return builder; - } - - public static MetadataBuilder customInstance() { - MetadataBuilder builder = new MetadataBuilder(); - return builder; - } -} diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/common/MockService.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/common/MockService.java new file mode 100644 index 0000000000..e74ba8acdb --- /dev/null +++ b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/common/MockService.java @@ -0,0 +1,497 @@ +/* + * 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.cloudgatewayservice.acceptance.common; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.netflix.appinfo.InstanceInfo; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import lombok.*; +import org.apache.http.HttpHeaders; +import org.assertj.core.error.MultipleAssertionsError; +import org.springframework.cloud.netflix.eureka.EurekaServiceInstance; +import org.springframework.http.MediaType; +import org.zowe.apiml.auth.AuthenticationScheme; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * This class allows to mock any simply service to a functional test. It is fully integrated in + * {@link AcceptanceTestWithMockServices}. In case you are using directly this implementation DO NOT FORGET to close + * the service once it is released and in the similar way, without {@link AcceptanceTestWithMockServices} it is + * necessary to mock registry and routing. The easiest way is to use the method + * {@link AcceptanceTestWithMockServices#mockService(String)}. It allows to you to use the same features and also + * takes care about clean up, mocking of service register, and updating routing rules. + * + * Example: + * + * try (MockService mockservice = MockService.builder() + * .serviceId("myservice") + * .scope(MockService.Scope.CLASS) + * .authenticationScheme(AuthenticationScheme.HTTP_BASIC_PASSTICKET).applid("MYAPPLID") + * .addEndpoint("/test") + * .responseCode(403) + * .bodyJson("{\"error\": \"authenticatin failed\"}") + * .assertions(httpExchange -> assertNull(he.getRequestHeaders().getFirst("X-My-Header"))) + * .and().addEndpoint("/404") + * .responseCode(404) + * .and().start() + * ) { + * // do a test + * + * assertEquals(5, mockservice.getCounter()); + * MockService.checkAssertionErrors(); + * } + * + * Note: Before implementation please check the full list of methods. + */ +@Builder(builderClassName = "MockServiceBuilder", buildMethodName = "internalBuild") +@Getter +public class MockService implements AutoCloseable { + + private static int idCounter = 1; + // in case on zombie mode is necessary to have a unique port number, on start replaced with the real one + private int port; + + /** + * HTTP server to handle requests and the endpoint configuration + */ + @Getter(AccessLevel.NONE) + private HttpServer server; + @Getter(AccessLevel.NONE) + private List endpointsConfig; + + /** + * Service identification + */ + private String serviceId; + private String vipAddress; + @Builder.Default + private String hostname = "localhost"; + + /** + * Routing configuration + */ + private String gatewayUrl; + private String serviceUrl; + + /** + * Authentication configuration + */ + private AuthenticationScheme authenticationScheme; + private String applid; + + /** + * It defines till when should be service instance available - it should be handled by an external component, i.e. + * {@link AcceptanceTestWithMockServices} use it to releasing an instance. + */ + @Builder.Default + private Scope scope = Scope.TEST; + + @Singular + @Getter(AccessLevel.NONE) + private List> statusChangedlisteners; + + /** + * All registered endpoints. It is possible to get any instance by path. If there is just one endpoint in the + * service, you can use {@link MockService#getEndpoint()} + */ + private final Map endpoints = new HashMap<>(); + + /** + * Status of the service - see possible values {@link MockService.Status} + */ + @Getter(AccessLevel.NONE) + private final AtomicReference status = new AtomicReference<>(Status.STOPPED); + + /** + * Collector of assert error on server side. To throw them in a test is necessary to call + * method (see {@link MockService#checkAssertionErrors()}) + */ + private static AssertionError assertionError; + + private void init() throws IOException { + server = HttpServer.create(new InetSocketAddress(0), 0); + endpoints.clear(); + endpointsConfig.forEach(endpoint -> { + if (endpoints.put(endpoint.getPath(), endpoint) != null) { + throw new IllegalStateException("Duplicity of endpoints: " + endpoint.getPath()); + } + + server.createContext(endpoint.getPath(), endpoint::process); + }); + + if (gatewayUrl == null) gatewayUrl = "api/v1"; + if (serviceUrl == null) serviceUrl = "/" + serviceId; + + server.setExecutor(null); + } + + public Status getStatus() { + return status.get(); + } + + public String getInstanceId() { + return hostname + ":" + getServiceId() + ":" + port; + } + + private void fireStatusChanged() { + if (statusChangedlisteners != null) { + statusChangedlisteners.forEach(l -> l.accept(MockService.this)); + } + } + + private static void setAssertionError(AssertionError assertionError) { + if (MockService.assertionError == null) { + // in case of the first error, just store the exception + MockService.assertionError = assertionError; + } else { + // there was another exception in the past, create multiple assertion error collection all the errors + List allErrors = new LinkedList<>(); + if (MockService.assertionError instanceof MultipleAssertionsError) { + allErrors.addAll(((MultipleAssertionsError) MockService.assertionError).getErrors()); + } + allErrors.add(assertionError); + MockService.assertionError = new MultipleAssertionsError(allErrors); + } + } + + /** + * To throw assertion errors. The method clean all stored assertion errors, it means after invoking the mock + * service is ready to next testing. + */ + public static void checkAssertionErrors() { + AssertionError assertionError = MockService.assertionError; + if (assertionError != null) { + MockService.assertionError = null; + throw assertionError; + } + } + + private void setStatus(Status status) { + if (this.status.get() != status) { + this.status.set(status); + fireStatusChanged(); + } + } + + /** + * To start the service. + */ + public void start() throws IOException { + if (!status.get().isUp()) { + init(); + server.start(); + port = server.getAddress().getPort(); + } + setStatus(Status.STARTED); + } + + /** + * To stop the service. If you want release the whole service, consider calling {@link MockService#close()} + */ + public void stop() { + if (status.get().isUp()) { + server.stop(0); + } + setStatus(Status.STOPPED); + } + + /** + * To stop service without any notification (to be still in the registry). In the case service is down, just notify + * to be in the registry. + */ + public void zombie() { + if (status.get().isUp()) { + server.stop(0); + } + + setStatus(Status.ZOMBIE); + } + + /** + * The method returns the endpoint if there is just one registered, otherwise end with an exception. + * @return once registred endpoint + */ + public Endpoint getEndpoint() { + assertEquals(1, endpoints.size(), "There are more than one endpoint, please use method getEndpoints and select one"); + return endpoints.values().stream().findFirst().get(); + } + + /** + * @return the sum of all endpoints counters (of attempts / requests) + */ + public int getCounter() { + int out = 0; + for (Endpoint endpoint : endpoints.values()) { + out += endpoint.getCounter(); + } + return out; + } + + /** + * It reset counters (of attempts / requests) in all endpoints + */ + public void resetCounter() { + endpoints.values().forEach(Endpoint::resetCounter); + } + + /** + * Remove all listeners of changing status. It could be helpful if the case of removing mock service to avoid + * back calls. + */ + public void cleanStatusChangedListeners() { + statusChangedlisteners = null; + } + + /** + * Method to use on the end to stop service (if it is running) and release resource. This method avoid back calls + * to listeners of change status (using {@MockService#cleanStatusChangedListeners()}). + */ + @Override + public void close() { + cleanStatusChangedListeners(); + stop(); + status.set(Status.CANCELLING); + } + + private Map getMetadata() { + Map metadata = new HashMap<>(); + metadata.put("apiml.routes.api-v1.gatewayUrl", "api/v1"); + metadata.put("apiml.routes.api-v1.serviceUrl", "/" + serviceId); + + if (authenticationScheme != null) { + metadata.put("apiml.authentication.scheme", authenticationScheme.getScheme()); + } + if (applid != null) { + metadata.put("apiml.authentication.applid", applid); + } + + return metadata; + } + + /** + * Construct InstanceInfo for the mock service + * @return instanceInfo with all related data + */ + public InstanceInfo getInstanceInfo() { + return InstanceInfo.Builder.newBuilder() + .setInstanceId(serviceId) + .setHostName(hostname) + .setPort(port) + .setAppName(serviceId) + .setVIPAddress(vipAddress != null ? vipAddress : serviceId) + .setStatus(InstanceInfo.InstanceStatus.UP) + .setMetadata(getMetadata()) + .build(); + } + + /** + * Method call {@link MockService#getInstanceInfo()} converted to EurekaServiceInstance + * @return EurekaServiceInstance with all related data + */ + public EurekaServiceInstance getEurekaServiceInstance() { + InstanceInfo instanceInfo = getInstanceInfo(); + return instanceInfo == null ? null : new EurekaServiceInstance(instanceInfo); + } + + public static class MockServiceBuilder { + + private List endpoints = new LinkedList<>(); + + /** + * Create a new endpoint of the Mock Service + * @param path Path of the endpoint + * @return builder to define other values + */ + public Endpoint.EndpointBuilder addEndpoint(String path) { + Endpoint.EndpointBuilder endpointBuilder = Endpoint.builder(); + endpointBuilder.path(path); + endpointBuilder.mockServiceBuilder = this; + return endpointBuilder; + } + + /** + * To build mock service. It will be stopped (not registred). It is necessary to call method start or zombie. + * @return instance of mockService + */ + public MockService build() { + MockService mockService = internalBuild(); + mockService.port = idCounter++; + mockService.endpointsConfig = endpoints; + return mockService; + } + + /** + * To start build and start MockService + * @return instance of MockService + * @throws IOException - in case of any issue with starting server + */ + public MockService start() throws IOException { + MockService mockService = build(); + mockService.start(); + return mockService; + } + + } + + @Builder + @Value + public static class Endpoint { + + /** + * Response code of a response, as default 200 + */ + @Builder.Default + private int responseCode = 200; + + /** + * Content type of the response. As default null (no header is generated). + */ + private String contentType; + + /** + * Response body to answer + */ + private String body; + + /** + * Path of the endpoint + */ + private String path; + + /** + * Lambdas about assertion on server side. The outcome exception could be thrown by + * {@link MockService#checkAssertionErrors()} + */ + @Singular + private List> assertions; + + /** + * Counter of calls. It contains amount of received requests. + */ + @Builder.Default + private AtomicInteger counter = new AtomicInteger(); + + void process(HttpExchange httpExchange) throws IOException { + try { + if (contentType != null) { + httpExchange.getResponseHeaders().add(HttpHeaders.CONTENT_TYPE, contentType); + } + + httpExchange.sendResponseHeaders(responseCode, 0); + + if (body != null) { + httpExchange.getResponseBody().write(body.getBytes(StandardCharsets.UTF_8)); + } + + if (assertions != null) { + assertions.forEach(a -> { + try { + a.accept(httpExchange); + } catch (AssertionError afe) { + setAssertionError(afe); + } + }); + } + + httpExchange.getResponseBody().close(); + } finally { + counter.getAndIncrement(); + } + } + + /** + * @return count of received requests since service is available or the last call of {@link Endpoint#resetCounter()} + */ + public int getCounter() { + return counter.get(); + } + + /** + * To reset counter of received requests + */ + public void resetCounter() { + counter.set(0); + } + + public static class EndpointBuilder { + + private MockServiceBuilder mockServiceBuilder; + + /** + * Definition of the endpoint is done, continue with defining of the MockService + * @return instance of MockService's builder + */ + public MockServiceBuilder and() { + Endpoint endpoint = build(); + mockServiceBuilder.endpoints.add(endpoint); + return mockServiceBuilder; + } + + /** + * To set body and content type to application/json + * @param body object to be converted to the json (to be returned in the response) + * @return builder of the endpoint + * @throws JsonProcessingException in case an issue with generation of JSON + */ + public EndpointBuilder bodyJson(Object body) throws JsonProcessingException { + ObjectWriter writer = new ObjectMapper().writer(); + contentType(MediaType.APPLICATION_JSON_VALUE); + return body(writer.writeValueAsString(body)); + } + + } + + } + + public enum Scope { + + // the service should be stopped once the test (method) is done + TEST, + // the service should be stopped after evaluating all tests (methods) in the class + CLASS + + } + + public enum Status { + + // service is stopped (not registred) + STOPPED, + // service is up and could be called by gateway + STARTED, + // service was stopped, and it should be removed from the memory + CANCELLING, + // service is registered but it is also down + ZOMBIE + + ; + + public boolean isUp() { + return this == STARTED; + } + + } + +} diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/common/Service.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/common/Service.java deleted file mode 100644 index 2344b44fe7..0000000000 --- a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/common/Service.java +++ /dev/null @@ -1,26 +0,0 @@ -/* - * 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.cloudgatewayservice.acceptance.common; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -@Getter -public class Service { - private final String id; - private final String locationPattern; - private final String serviceRoute; - - public String getPath() { - return "/" + id + "/test"; - } -} diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/config/DiscoveryClientTestConfig.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/config/DiscoveryClientTestConfig.java index de4152f8eb..f44daff552 100644 --- a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/config/DiscoveryClientTestConfig.java +++ b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/config/DiscoveryClientTestConfig.java @@ -15,7 +15,6 @@ import com.netflix.appinfo.HealthCheckHandler; import com.netflix.discovery.AbstractDiscoveryClientOptionalArgs; import com.netflix.discovery.EurekaClientConfig; -import com.netflix.discovery.shared.Applications; import com.netflix.discovery.shared.transport.jersey.EurekaJerseyClient; import com.sun.jersey.api.client.ClientResponse; import com.sun.jersey.api.client.WebResource; @@ -23,18 +22,18 @@ import lombok.RequiredArgsConstructor; import org.springframework.aop.support.AopUtils; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.discovery.ReactiveDiscoveryClient; import org.springframework.cloud.context.config.annotation.RefreshScope; import org.springframework.cloud.netflix.eureka.MutableDiscoveryClientOptionalArgs; import org.springframework.cloud.util.ProxyUtils; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Primary; -import org.zowe.apiml.cloudgatewayservice.acceptance.common.MetadataBuilder; -import org.zowe.apiml.cloudgatewayservice.acceptance.common.Service; import org.zowe.apiml.cloudgatewayservice.acceptance.netflix.ApimlDiscoveryClientStub; import org.zowe.apiml.cloudgatewayservice.acceptance.netflix.ApplicationRegistry; +import reactor.core.publisher.Flux; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; @@ -50,15 +49,36 @@ @TestConfiguration @RequiredArgsConstructor public class DiscoveryClientTestConfig { - private final ApplicationContext context; - protected Service serviceWithDefaultConfiguration = new Service("serviceid2", "/serviceid2/**", "serviceid2"); - protected Service serviceWithCustomConfiguration = new Service("serviceid1", "/serviceid1/**", "serviceid1"); + private final ApplicationContext context; @Bean public ApplicationRegistry registry() { - ApplicationRegistry applicationRegistry = new ApplicationRegistry(); - return applicationRegistry; + return new ApplicationRegistry(); + } + + @Bean + public ReactiveDiscoveryClient mockServicesReactiveDiscoveryClient(ApplicationRegistry applicationRegistry) { + return new ReactiveDiscoveryClient() { + + @Override + public String description() { + return "mocked services"; + } + + @Override + public Flux getInstances(String serviceId) { + return Flux.just(applicationRegistry.getServiceInstance(serviceId).toArray(new ServiceInstance[0])); + } + + @Override + public Flux getServices() { + return Flux.just(applicationRegistry.getInstances().stream() + .map(a -> a.getId()) + .distinct() + .toArray(String[]::new)); + } + }; } @Bean(destroyMethod = "shutdown", name = "test") @@ -68,8 +88,7 @@ public ApimlDiscoveryClientStub eurekaClient(ApplicationInfoManager manager, EurekaClientConfig config, EurekaInstanceConfig instance, @Autowired(required = false) HealthCheckHandler healthCheckHandler, - ApplicationRegistry applicationRegistry, - @Value("${currentApplication:}") String currentApplication + ApplicationRegistry applicationRegistry ) { ApplicationInfoManager appManager; if (AopUtils.isAopProxy(manager)) { @@ -79,7 +98,7 @@ public ApimlDiscoveryClientStub eurekaClient(ApplicationInfoManager manager, } AbstractDiscoveryClientOptionalArgs args = new MutableDiscoveryClientOptionalArgs(); - args.setEurekaJerseyClient(eurekaJerseyClient(applicationRegistry, currentApplication)); + args.setEurekaJerseyClient(eurekaJerseyClient()); final ApimlDiscoveryClientStub discoveryClient = new ApimlDiscoveryClientStub(appManager, config, args, this.context, applicationRegistry); @@ -90,7 +109,7 @@ public ApimlDiscoveryClientStub eurekaClient(ApplicationInfoManager manager, return discoveryClient; } - private EurekaJerseyClient eurekaJerseyClient(ApplicationRegistry registry, String currentApplication) { + private EurekaJerseyClient eurekaJerseyClient() { EurekaJerseyClient jerseyClient = mock(EurekaJerseyClient.class); ApacheHttpClient4 httpClient4 = mock(ApacheHttpClient4.class); when(jerseyClient.getClient()).thenReturn(httpClient4); @@ -104,12 +123,6 @@ private EurekaJerseyClient eurekaJerseyClient(ApplicationRegistry registry, Stri when(builder.get(ClientResponse.class)).thenReturn(response); when(response.getStatus()).thenReturn(Response.Status.OK.getStatusCode()); when(response.hasEntity()).thenReturn(true); - - registry.addApplication(serviceWithDefaultConfiguration, MetadataBuilder.defaultInstance(), false); - registry.addApplication(serviceWithCustomConfiguration, MetadataBuilder.customInstance(), false); - registry.setCurrentApplication(currentApplication); - when(response.getEntity(Applications.class)).thenReturn(registry.getApplications()); - return jerseyClient; } diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/netflix/ApplicationRegistry.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/netflix/ApplicationRegistry.java index 3aa45180c6..de5d0b170b 100644 --- a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/netflix/ApplicationRegistry.java +++ b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/acceptance/netflix/ApplicationRegistry.java @@ -10,106 +10,110 @@ package org.zowe.apiml.cloudgatewayservice.acceptance.netflix; -import com.netflix.appinfo.DataCenterInfo; import com.netflix.appinfo.InstanceInfo; -import com.netflix.appinfo.MyDataCenterInfo; import com.netflix.discovery.shared.Application; import com.netflix.discovery.shared.Applications; -import org.zowe.apiml.cloudgatewayservice.acceptance.common.MetadataBuilder; -import org.zowe.apiml.cloudgatewayservice.acceptance.common.Service; +import org.apache.commons.lang3.StringUtils; +import org.springframework.cloud.client.ServiceInstance; +import org.zowe.apiml.cloudgatewayservice.acceptance.common.MockService; -import java.io.IOException; -import java.net.ServerSocket; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; +import java.util.stream.Collectors; /** * Register the route to all components that need the information for the request to pass properly through the * Cloud Gateway. This class is heavily depended upon from the Stubs in this package. */ public class ApplicationRegistry { - private String currentApplication; - protected int servicePort; - private Map applicationsToReturn = new HashMap<>(); + private final Map instanceIdToService = Collections.synchronizedMap(new HashMap<>()); - public ApplicationRegistry() { + public boolean remove(MockService mockService) { + boolean removed = false; + for (Iterator> i = instanceIdToService.entrySet().iterator(); i.hasNext(); ) { + Map.Entry entry = i.next(); + if (entry.getValue() == mockService) { + i.remove(); + removed = true; + } + } + return removed; } - public synchronized int findFreePort() { - if (servicePort != 0) return servicePort; - try (ServerSocket server = new ServerSocket(0);) { - this.servicePort = server.getLocalPort(); - } catch (IOException e) { - throw new RuntimeException("Failed to find free local port to bind the agent to", e); + public boolean update(MockService mockService) { + switch (mockService.getStatus()) { + case STARTED: + case ZOMBIE: + if (instanceIdToService.get(mockService.getInstanceId()) == mockService) { + return false; + } + remove(mockService); + instanceIdToService.put(mockService.getInstanceId(), mockService); + return true; + case STOPPED: + case CANCELLING: + return remove(mockService); + default: + throw new IllegalStateException("Unsupported status: " + mockService.getStatus()); } - return servicePort; } - /** - * Add new application. The customization to the metadata are done via the MetadataBuilder. - * - * @param service Details of the service to be registered in the Cloud Gateway - * @param builder The builder pattern for metadata. - * @param multipleInstances Whether there are multiple instances of the service. - */ - public void addApplication(Service service, MetadataBuilder builder, boolean multipleInstances) { - String id = service.getId(); - Applications applications = new Applications(); - Application withMetadata = new Application(id); + public Collection getMockServices() { + return instanceIdToService.values(); + } - Map metadata = builder.build(); + public Application getApplication(String serviceId) { + Application application = new Application(); + application.setName(serviceId); + instanceIdToService.values().stream() + .filter(i -> StringUtils.equalsIgnoreCase(serviceId, i.getServiceId())) + .map(MockService::getInstanceInfo) + .forEach(application::addInstance); + return application; + } - withMetadata.addInstance(getStandardInstance(metadata, id, id)); - if (multipleInstances) { - withMetadata.addInstance(getStandardInstance(metadata, id, id + "-copy")); - } - applications.addApplication(withMetadata); + public Applications getApplications() { + Applications applications = new Applications(); - applicationsToReturn.put(id, applications); - } + instanceIdToService.values().stream() + .map(MockService::getServiceId).distinct() + .map(this::getApplication) + .forEach(applications::addApplication); - /** - * Remove all applications from internal mappings. This needs to be followed by adding new ones in order for the - * discovery infrastructure to work properly. - */ - public void clearApplications() { - applicationsToReturn.clear(); + return applications; } - /** - * Sets which application should be returned for all the callers looking up the service. - * - * @param currentApplication Id of the application. - */ - public void setCurrentApplication(String currentApplication) { - this.currentApplication = currentApplication; + public List getInstances() { + return instanceIdToService.values().stream() + .map(MockService::getInstanceInfo) + .collect(Collectors.toList()); } - - public Applications getApplications() { - return applicationsToReturn.get(currentApplication); + public List getServiceInstance(String serviceId) { + return instanceIdToService.values().stream() + .filter(ms -> StringUtils.equalsIgnoreCase(serviceId, ms.getServiceId())) + .map(MockService::getEurekaServiceInstance) + .collect(Collectors.toList()); } - public List getInstances() { - if (applicationsToReturn.get(currentApplication) == null) { - currentApplication = "serviceid2"; + public boolean afterTest() { + boolean anyChange = false; + for (Iterator> i = instanceIdToService.entrySet().iterator(); i.hasNext(); ) { + MockService mockService = i.next().getValue(); + if (mockService.getScope() == MockService.Scope.TEST) { + i.remove(); + mockService.close(); + anyChange = true; + } } - return applicationsToReturn.get(currentApplication).getRegisteredApplications(currentApplication).getInstances(); + return anyChange; } - private InstanceInfo getStandardInstance(Map metadata, String serviceId, String instanceId) { - return InstanceInfo.Builder.newBuilder() - .setAppName(serviceId) - .setInstanceId(instanceId) - .setHostName("localhost") - .setVIPAddress(serviceId) - .setMetadata(metadata) - .setDataCenterInfo(new MyDataCenterInfo(DataCenterInfo.Name.MyOwn)) - .setStatus(InstanceInfo.InstanceStatus.UP) - .setSecurePort(findFreePort()) - .setPort(findFreePort()) - .build(); + public boolean afterClass() { + boolean anyChange = !instanceIdToService.isEmpty(); + instanceIdToService.values().forEach(MockService::close); + instanceIdToService.clear(); + return anyChange; } + } diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/filters/RobinRoundIteratorTest.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/filters/RobinRoundIteratorTest.java new file mode 100644 index 0000000000..2b21d66377 --- /dev/null +++ b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/filters/RobinRoundIteratorTest.java @@ -0,0 +1,90 @@ +/* + * 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.cloudgatewayservice.filters; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +class RobinRoundIteratorTest { + + private List fetch(Iterator iterator) { + List output = new LinkedList<>(); + while (iterator.hasNext()) { + output.add(iterator.next()); + } + return output; + } + + @Nested + class GivenSameCollection { + + @Test + void givenLongerCollections_whenIterateThem_thenCycleOrder() { + RobinRoundIterator rri = new RobinRoundIterator<>(); + + Collection input = Arrays.asList(1, 2, 3); + assertIterableEquals(Arrays.asList(1, 2, 3), fetch(rri.getIterator(input))); + assertIterableEquals(Arrays.asList(2, 3, 1), fetch(rri.getIterator(input))); + assertIterableEquals(Arrays.asList(3, 1, 2), fetch(rri.getIterator(input))); + assertIterableEquals(Arrays.asList(1, 2, 3), fetch(rri.getIterator(input))); + } + + @Test + void givenEmptyCollection_whenTransform_thenReturnEmptyCollection() { + RobinRoundIterator rri = new RobinRoundIterator<>(); + + assertIterableEquals(Collections.emptyList(), fetch(rri.getIterator(Collections.emptyList()))); + } + + @Test + void givenSingletonList_whenTransform_theOrderIsTheSame() { + RobinRoundIterator rri = new RobinRoundIterator<>(); + + assertIterableEquals(Collections.singleton(1), fetch(rri.getIterator(Collections.singleton(1)))); + assertIterableEquals(Collections.singleton(1), fetch(rri.getIterator(Collections.singleton(1)))); + } + + } + + @Nested + class MutableCollection { + + @Test + void givenListWithDifferentLength_whenTransform_theOffsetIsStable() { + RobinRoundIterator rri = new RobinRoundIterator<>(); + + assertIterableEquals(Arrays.asList(1, 2, 3), fetch(rri.getIterator(Arrays.asList(1, 2, 3)))); + assertIterableEquals(Arrays.asList(2, 1), fetch(rri.getIterator(Arrays.asList(1, 2)))); + assertIterableEquals(Arrays.asList(3, 1, 2), fetch(rri.getIterator(Arrays.asList(1, 2, 3)))); + assertIterableEquals(Arrays.asList(4, 1, 2, 3), fetch(rri.getIterator(Arrays.asList(1, 2, 3, 4)))); + assertIterableEquals(Arrays.asList(2, 3, 1), fetch(rri.getIterator(Arrays.asList(1, 2, 3)))); + } + + } + + @Nested + class EdgeCases { + + @Test + void givenACollection_whenIterateOver_thenThrowAnException() { + RobinRoundIterator rri = new RobinRoundIterator<>(); + Iterator i = rri.getIterator(Collections.emptyList()); + assertFalse(i.hasNext()); + assertThrows(NoSuchElementException.class, i::next); + } + + } + +} \ No newline at end of file diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/RouteLocatorTest.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/RouteLocatorTest.java index f7c2735c0e..f4f78235af 100644 --- a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/RouteLocatorTest.java +++ b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/RouteLocatorTest.java @@ -41,6 +41,8 @@ class RouteLocatorTest { + private final ServiceInstance MOCK_SERVICE = createServiceInstance("mockService"); + private static final FilterDefinition[] COMMON_FILTERS = { new FilterDefinition(), new FilterDefinition() }; @@ -136,17 +138,17 @@ void givenDiscoveryClient_whenGetServiceInstances_thenReturnAllServiceInstances( @Test void givenNoAuthentication_whenSetAuth_thenDoNothing() { - assertDoesNotThrow(() -> routeLocator.setAuth(null, null)); + assertDoesNotThrow(() -> routeLocator.setAuth(MOCK_SERVICE,null, null)); } @Test void givenNoAuthenticationScheme_whenSetAuth_thenDoNothing() { - assertDoesNotThrow(() -> routeLocator.setAuth(null, new Authentication())); + assertDoesNotThrow(() -> routeLocator.setAuth(MOCK_SERVICE, null, new Authentication())); } @Test void givenAuthenticationSchemeWithoutFilter_whenSetAuth_thenDoNothing() { - assertDoesNotThrow(() -> routeLocator.setAuth(null, new Authentication(AuthenticationScheme.X509, null))); + assertDoesNotThrow(() -> routeLocator.setAuth(MOCK_SERVICE, null, new Authentication(AuthenticationScheme.X509, null))); } @Test @@ -154,9 +156,9 @@ void givenExistingAuthenticationScheme_whenSetAuth_thenCallApply() { RouteDefinition routeDefinition = mock(RouteDefinition.class); Authentication authentication = new Authentication(AuthenticationScheme.BYPASS, null); - routeLocator.setAuth(routeDefinition, authentication); + routeLocator.setAuth(MOCK_SERVICE, routeDefinition, authentication); - verify(SCHEME_HANDLER_FILTERS[0]).apply(routeDefinition, authentication); + verify(SCHEME_HANDLER_FILTERS[0]).apply(MOCK_SERVICE, routeDefinition, authentication); } private TriConsumer getCorsLambda(Consumer> metadataProcessor) { diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/scheme/HttpBasicPassticketTest.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/scheme/HttpBasicPassticketTest.java index 23808694a4..e28a8a1652 100644 --- a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/scheme/HttpBasicPassticketTest.java +++ b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/scheme/HttpBasicPassticketTest.java @@ -11,12 +11,15 @@ package org.zowe.apiml.cloudgatewayservice.service.scheme; import org.junit.jupiter.api.Test; +import org.springframework.cloud.client.ServiceInstance; import org.springframework.cloud.gateway.filter.FilterDefinition; import org.springframework.cloud.gateway.route.RouteDefinition; import org.zowe.apiml.auth.Authentication; import org.zowe.apiml.auth.AuthenticationScheme; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; class HttpBasicPassticketTest { @@ -30,8 +33,10 @@ void givenRouteDefinition_whenApply_thenFulfillFilterFactorArgs() { RouteDefinition routeDefinition = new RouteDefinition(); Authentication authentication = new Authentication(); authentication.setApplid("applid"); + ServiceInstance serviceInstance = mock(ServiceInstance.class); + doReturn("service").when(serviceInstance).getServiceId(); - new HttpBasicPassticket().apply(routeDefinition, authentication); + new HttpBasicPassticket().apply(serviceInstance, routeDefinition, authentication); assertEquals(1, routeDefinition.getFilters().size()); FilterDefinition filterDefinition = routeDefinition.getFilters().get(0); diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/scheme/X509Test.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/scheme/X509Test.java index 0dfa464bc3..6a17d123a7 100644 --- a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/scheme/X509Test.java +++ b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/scheme/X509Test.java @@ -11,12 +11,15 @@ package org.zowe.apiml.cloudgatewayservice.service.scheme; import org.junit.jupiter.api.Test; +import org.springframework.cloud.client.ServiceInstance; import org.springframework.cloud.gateway.filter.FilterDefinition; import org.springframework.cloud.gateway.route.RouteDefinition; import org.zowe.apiml.auth.Authentication; import org.zowe.apiml.auth.AuthenticationScheme; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; class X509Test { @@ -30,8 +33,10 @@ void givenX509_whenApply_thenFulfillFilterFactorArgs() { RouteDefinition routeDefinition = new RouteDefinition(); Authentication authentication = new Authentication(); authentication.setHeaders("header1,header2"); + ServiceInstance serviceInstance = mock(ServiceInstance.class); + doReturn("service").when(serviceInstance).getServiceId(); - new X509().apply(routeDefinition, authentication); + new X509().apply(serviceInstance, routeDefinition, authentication); assertEquals(1, routeDefinition.getFilters().size()); FilterDefinition filterDefinition = routeDefinition.getFilters().get(0); diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/scheme/ZosmfTest.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/scheme/ZosmfTest.java new file mode 100644 index 0000000000..0d23dffc1e --- /dev/null +++ b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/scheme/ZosmfTest.java @@ -0,0 +1,45 @@ +/* + * 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.cloudgatewayservice.service.scheme; + +import org.junit.jupiter.api.Test; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.gateway.filter.FilterDefinition; +import org.springframework.cloud.gateway.route.RouteDefinition; +import org.zowe.apiml.auth.Authentication; +import org.zowe.apiml.auth.AuthenticationScheme; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +class ZosmfTest { + + @Test + void givenZosmfInstance_whenGetAuthenticationScheme_thenReturnProperType() { + assertEquals(AuthenticationScheme.ZOSMF, new Zosmf().getAuthenticationScheme()); + } + + @Test + void givenRouteDefinition_whenApply_thenFulfillFilterFactorArgs() { + RouteDefinition routeDefinition = new RouteDefinition(); + Authentication authentication = new Authentication(); + authentication.setApplid("applid"); + ServiceInstance serviceInstance = mock(ServiceInstance.class); + doReturn("service").when(serviceInstance).getServiceId(); + + new Zosmf().apply(serviceInstance, routeDefinition, authentication); + + assertEquals(1, routeDefinition.getFilters().size()); + FilterDefinition filterDefinition = routeDefinition.getFilters().get(0); + assertEquals("ZosmfFilterFactory", filterDefinition.getName()); + } +} diff --git a/cloud-gateway-service/src/test/resources/application-test.yml b/cloud-gateway-service/src/test/resources/application-test.yml index dc6ddcbb68..5fcbf2e53d 100644 --- a/cloud-gateway-service/src/test/resources/application-test.yml +++ b/cloud-gateway-service/src/test/resources/application-test.yml @@ -48,4 +48,3 @@ management: base-path: /application exposure: include: health,gateway -currentApplication: serviceid1 diff --git a/cloud-gateway-service/src/test/resources/application.yml b/cloud-gateway-service/src/test/resources/application.yml index 3d3e43eedc..462e9ee0d1 100644 --- a/cloud-gateway-service/src/test/resources/application.yml +++ b/cloud-gateway-service/src/test/resources/application.yml @@ -58,5 +58,3 @@ management: base-path: /application exposure: include: health,gateway - -currentApplication: serviceid2 diff --git a/common-service-core/src/main/java/org/zowe/apiml/zaas/zosmf/ZosmfResponse.java b/common-service-core/src/main/java/org/zowe/apiml/zaas/zosmf/ZosmfResponse.java index b3d6777f35..b4ae2a04b3 100644 --- a/common-service-core/src/main/java/org/zowe/apiml/zaas/zosmf/ZosmfResponse.java +++ b/common-service-core/src/main/java/org/zowe/apiml/zaas/zosmf/ZosmfResponse.java @@ -19,7 +19,6 @@ @AllArgsConstructor public class ZosmfResponse { - String cookieName; - String token; - + private String cookieName; + private String token; } diff --git a/integration-tests/src/test/java/org/zowe/apiml/functional/gateway/CloudGatewayRoutingTest.java b/integration-tests/src/test/java/org/zowe/apiml/functional/gateway/CloudGatewayRoutingTest.java index a5e73e0055..05b7ecd9a9 100644 --- a/integration-tests/src/test/java/org/zowe/apiml/functional/gateway/CloudGatewayRoutingTest.java +++ b/integration-tests/src/test/java/org/zowe/apiml/functional/gateway/CloudGatewayRoutingTest.java @@ -11,10 +11,14 @@ package org.zowe.apiml.functional.gateway; import io.restassured.RestAssured; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Tag; +import io.restassured.response.Response; +import org.junit.jupiter.api.*; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.http.HttpHeaders; +import org.zowe.apiml.util.SecurityUtils; import org.zowe.apiml.util.TestWithStartedInstances; import org.zowe.apiml.util.categories.DiscoverableClientDependentTest; import org.zowe.apiml.util.config.CloudGatewayConfiguration; @@ -22,9 +26,12 @@ import java.net.URI; import java.net.URISyntaxException; +import java.util.function.Consumer; +import java.util.stream.Stream; import static io.restassured.RestAssured.given; -import static org.zowe.apiml.util.requests.Endpoints.DISCOVERABLE_GREET; +import static org.junit.jupiter.api.Assertions.*; +import static org.zowe.apiml.util.requests.Endpoints.*; @DiscoverableClientDependentTest @Tag("CloudGatewayServiceRouting") @@ -103,4 +110,85 @@ void testWrongRoutingWithBasePath(String basePath) throws URISyntaxException { String scgUrl = String.format("%s://%s:%s%s", conf.getScheme(), conf.getHost(), conf.getPort(), basePath); given().get(new URI(scgUrl)).then().statusCode(404); } + + @SuppressWarnings("unused") + private static Stream validToBeTransformed() { + return Stream.of( + Arguments.of("z/OSMF auth scheme", ZOSMF_REQUEST, (Consumer) response -> { + assertNotNull(response.jsonPath().getString("cookies.jwtToken")); + assertNull(response.jsonPath().getString("headers.authorization")); + //TODO: uncomment once http client handle client certs propertly + //assertTrue(CollectionUtils.isEmpty(response.jsonPath().getList("certs"))); + }), + Arguments.of("passticket auth scheme", REQUEST_INFO_ENDPOINT, (Consumer) response -> { + assertNotNull(response.jsonPath().getString("headers.authorization")); + assertTrue(response.jsonPath().getString("headers.authorization").startsWith("Basic ")); + //TODO: uncomment once http client handle client certs propertly + //assertTrue(CollectionUtils.isEmpty(response.jsonPath().getList("certs"))); + }) + ); + } + + @SuppressWarnings("unused") + private static Stream noCredentials() { + Consumer assertions = response -> { + assertEquals(200, response.getStatusCode()); + assertNull(response.jsonPath().getString("cookies.jwtToken")); + assertNull(response.jsonPath().getString("headers.authorization")); + //TODO: uncomment once http client handle client certs propertly + //assertTrue(CollectionUtils.isEmpty(response.jsonPath().getList("certs"))); + }; + + return Stream.of( + Arguments.of("z/OSMF auth scheme", ZOSMF_REQUEST, assertions), + Arguments.of("passticket auth scheme", REQUEST_INFO_ENDPOINT, assertions) + ); + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class AuthSchema { + + private String token; + + @BeforeAll + void setCredentials() { + token = SecurityUtils.gatewayToken( + ConfigReader.environmentConfiguration().getCredentials().getUser(), + ConfigReader.environmentConfiguration().getCredentials().getPassword() + ); + } + + @ParameterizedTest(name = "givenValidRequest_thenCredentialsAreTransformed {0} [{index}]") + @MethodSource("org.zowe.apiml.functional.gateway.CloudGatewayRoutingTest#validToBeTransformed") + void givenValidRequest_thenCredentialsAreTransformed(String title, String basePath, Consumer assertions) { + Response response = given() + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .when() + .get(String.format("%s://%s:%s%s", conf.getScheme(), conf.getHost(), conf.getPort(), basePath)); + assertions.accept(response); + assertEquals(200, response.getStatusCode()); + } + + @ParameterizedTest(name = "givenNoCredentials_thenNoCredentialsAreProvided {0} [{index}]") + @MethodSource("org.zowe.apiml.functional.gateway.CloudGatewayRoutingTest#noCredentials") + void givenNoCredentials_thenNoCredentialsAreProvided(String title, String basePath, Consumer assertions) { + Response response = given().when() + .get(String.format("%s://%s:%s%s", conf.getScheme(), conf.getHost(), conf.getPort(), basePath)); + assertions.accept(response); + assertEquals(200, response.getStatusCode()); + } + + @ParameterizedTest(name = "givenInvalidCredentials_thenNoCredentialsAreProvided {0} [{index}]") + @MethodSource("org.zowe.apiml.functional.gateway.CloudGatewayRoutingTest#noCredentials") + void givenInvalidCredentials_thenNoCredentialsAreProvided(String title, String basePath, Consumer assertions) { + Response response = given().header(HttpHeaders.AUTHORIZATION, "Bearer invalidToken") + .when() + .get(String.format("%s://%s:%s%s", conf.getScheme(), conf.getHost(), conf.getPort(), basePath)); + assertions.accept(response); + assertEquals(200, response.getStatusCode()); + } + + } + }