diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index a1a4e47b7..121fcb961 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -7,7 +7,7 @@ repositories { } dependencies { - implementation 'org.openapitools:openapi-generator-gradle-plugin:5.4.0' + implementation 'org.openapitools:openapi-generator-gradle-plugin:6.6.0' implementation 'de.undercouch:gradle-download-task:5.0.2' implementation 'com.github.ben-manes:gradle-versions-plugin:0.42.0' } diff --git a/symphony-bdk-core/build.gradle b/symphony-bdk-core/build.gradle index 337d05ecc..0bbbbaabc 100644 --- a/symphony-bdk-core/build.gradle +++ b/symphony-bdk-core/build.gradle @@ -76,13 +76,14 @@ dependencies { } // OpenAPI code generation -def apiBaseUrl = "https://raw.githubusercontent.com/finos/symphony-api-spec/fc80c3204d8a92a0b82d3c951eab7f5cb78a7c53" +def apiBaseUrl = "https://raw.githubusercontent.com/tzhao-symphony/symphony-api-spec/refs/heads/ADMIN-10454/add_app_users_endpoint/" def generatedFolder = "$buildDir/generated/openapi" def apisToGenerate = [ Agent: 'agent/agent-api-public-deprecated.yaml', Pod : 'pod/pod-api-public-deprecated.yaml', Auth : 'authenticator/authenticator-api-public-deprecated.yaml', Login: 'login/login-api-public.yaml', + Users: 'users/users-api-public.yaml' ] sourceSets.main.java.srcDirs += "$generatedFolder/src/main/java" diff --git a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/ExtAppServiceFactory.java b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/ExtAppServiceFactory.java new file mode 100644 index 000000000..09915a1d2 --- /dev/null +++ b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/ExtAppServiceFactory.java @@ -0,0 +1,46 @@ +package com.symphony.bdk.core; + +import com.symphony.bdk.core.auth.ExtAppAuthSession; +import com.symphony.bdk.core.client.ApiClientFactory; +import com.symphony.bdk.core.config.model.BdkConfig; +import com.symphony.bdk.core.retry.RetryWithRecoveryBuilder; +import com.symphony.bdk.core.service.app.AppUsersService; +import com.symphony.bdk.gen.api.AppsApi; +import com.symphony.bdk.http.api.ApiClient; + +import lombok.extern.slf4j.Slf4j; +import org.apiguardian.api.API; + +/** + * Factory responsible for creating Ext App service instances for Symphony Bdk apps entry point: + * + */ +@Slf4j +@API(status = API.Status.INTERNAL) +class ExtAppServiceFactory { + + private final ApiClient usersClient; + private final ExtAppAuthSession authSession; + private final BdkConfig config; + private final RetryWithRecoveryBuilder retryBuilder; + + public ExtAppServiceFactory(ApiClientFactory apiClientFactory, ExtAppAuthSession authSession, BdkConfig config) { + this.config = config; + this.usersClient = apiClientFactory.getUsersClient(); + this.authSession = authSession; + this.retryBuilder = new RetryWithRecoveryBuilder<>().retryConfig(config.getRetry()); + + + } + + /** + * Returns a fully initialized {@link AppUsersService}. + * + * @return a new {@link AppUsersService} instance. + */ + public AppUsersService getAppService() { + return new AppUsersService(config.getApp().getAppId(), new AppsApi(usersClient), this.authSession, this.retryBuilder); + } +} diff --git a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/ExtAppServices.java b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/ExtAppServices.java new file mode 100644 index 000000000..e3dc68982 --- /dev/null +++ b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/ExtAppServices.java @@ -0,0 +1,30 @@ +package com.symphony.bdk.core; + +import com.symphony.bdk.core.auth.ExtAppAuthSession; +import com.symphony.bdk.core.client.ApiClientFactory; +import com.symphony.bdk.core.config.model.BdkConfig; +import com.symphony.bdk.core.service.app.AppUsersService; + +import org.apiguardian.api.API; + +/** + * Entry point for external application services relying on the App session token + */ +@API(status = API.Status.STABLE) +public class ExtAppServices { + AppUsersService appUsersService; + + public ExtAppServices(ApiClientFactory apiClientFactory, ExtAppAuthSession authSession, BdkConfig config) { + ExtAppServiceFactory extAppServiceFactory = new ExtAppServiceFactory(apiClientFactory, authSession, config); + this.appUsersService = extAppServiceFactory.getAppService(); + } + + /** + * Get the {@link AppUsersService}. + * + * @return an {@link AppUsersService} instance. + */ + public AppUsersService appUsers() { + return this.appUsersService; + }; +} diff --git a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/SymphonyBdk.java b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/SymphonyBdk.java index 076d35693..776f11708 100644 --- a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/SymphonyBdk.java +++ b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/SymphonyBdk.java @@ -48,6 +48,7 @@ public class SymphonyBdk { private final ExtensionAppAuthenticator extensionAppAuthenticator; private final AuthSession botSession; + private final ExtAppAuthSession extAppAuthSession; private final UserV2 botInfo; private final DatafeedLoop datafeedLoop; private final DatahoseLoop datahoseLoop; @@ -64,6 +65,9 @@ public class SymphonyBdk { private final HealthService healthService; private final ExtensionService extensionService; + private final ExtAppServices extAppServices; + + /** * Returns a new {@link SymphonyBdkBuilder} for fluent initialization. * @@ -128,6 +132,15 @@ protected SymphonyBdk( this.messageService = serviceFactory != null ? serviceFactory.getMessageService() : null; this.disclaimerService = serviceFactory != null ? serviceFactory.getDisclaimerService() : null; + if (config.isOboConfigured()) { + ExtAppAuthenticator extAppAuthenticator = authenticatorFactory.getExtAppAuthenticator(); + this.extAppAuthSession = extAppAuthenticator.authenticateExtApp(); + this.extAppServices = new ExtAppServices(apiClientFactory, this.extAppAuthSession, this.config); + } else { + this.extAppServices = null; + this.extAppAuthSession = null; + } + // retrieve bot session info this.botInfo = sessionService != null ? sessionService.getSession() : null; @@ -304,6 +317,14 @@ public OboServices obo(AuthSession oboSession) { return new OboServices(config, oboSession); } + /** + * Get an {@link ExtAppServices} gathering all extension app enabled services + * @return an {@link ExtAppServices} instance + */ + public ExtAppServices app() { + return this.extAppServices; + } + /** * Returns the {@link ExtensionAppAuthenticator}. * @@ -313,6 +334,17 @@ public ExtensionAppAuthenticator appAuthenticator() { return this.getExtensionAppAuthenticator(); } + /** + * Returns the extension app auth session. + * + * @return extension app auth session. + */ + @API(status = API.Status.EXPERIMENTAL) + public ExtAppAuthSession extAppAuthSession() { + return Optional.ofNullable(this.extAppAuthSession) + .orElseThrow(() -> new IllegalStateException("Cannot get App auth session. Ext app is not configured.")); + } + /** * Returns the Bot session. * diff --git a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/AuthenticatorFactory.java b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/AuthenticatorFactory.java index 5ee0e53f3..e97b2a3a2 100644 --- a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/AuthenticatorFactory.java +++ b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/AuthenticatorFactory.java @@ -43,4 +43,13 @@ public interface AuthenticatorFactory { */ @Nonnull ExtensionAppAuthenticator getExtensionAppAuthenticator() throws AuthInitializationException; + + /** + * Creates a new instance of a {@link ExtAppAuthenticator}. + * + * @return a new {@link ExtAppAuthenticator} instance. + * @throws AuthInitializationException if the authenticator cannot be instantiated. + */ + @Nonnull + ExtAppAuthenticator getExtAppAuthenticator() throws AuthInitializationException; } diff --git a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/ExtAppAuthSession.java b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/ExtAppAuthSession.java new file mode 100644 index 000000000..6a275990a --- /dev/null +++ b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/ExtAppAuthSession.java @@ -0,0 +1,26 @@ +package com.symphony.bdk.core.auth; + +import com.symphony.bdk.core.auth.exception.AuthUnauthorizedException; + +import org.apiguardian.api.API; + +import javax.annotation.Nullable; + +/** + * Extension App Authentication session handle. The {@link ExtAppAuthSession#refresh()} will trigger a re-auth against the API endpoints. + */ +@API(status = API.Status.STABLE) +public interface ExtAppAuthSession { + /** + * Extension app session token. + * + * @return extension app session token + */ + @Nullable + String getAppSession(); + + /** + * Trigger re-authentication to refresh session token. + */ + void refresh() throws AuthUnauthorizedException; +} diff --git a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/ExtAppAuthenticator.java b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/ExtAppAuthenticator.java new file mode 100644 index 000000000..b342d2868 --- /dev/null +++ b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/ExtAppAuthenticator.java @@ -0,0 +1,20 @@ +package com.symphony.bdk.core.auth; + +import com.symphony.bdk.core.auth.exception.AuthUnauthorizedException; +import org.apiguardian.api.API; + +import javax.annotation.Nonnull; + +/** + * Extension App authenticator service. + */ +@API(status = API.Status.STABLE) +public interface ExtAppAuthenticator { + + /** + * Authenticates an extension app. + * + * @return the authentication session. + */ + @Nonnull ExtAppAuthSession authenticateExtApp() throws AuthUnauthorizedException; +} diff --git a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/impl/AbstractExtAppAuthenticator.java b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/impl/AbstractExtAppAuthenticator.java new file mode 100644 index 000000000..53e4b5ffb --- /dev/null +++ b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/impl/AbstractExtAppAuthenticator.java @@ -0,0 +1,41 @@ +package com.symphony.bdk.core.auth.impl; + +import com.symphony.bdk.core.auth.ExtAppAuthenticator; +import com.symphony.bdk.core.auth.OboAuthenticator; +import com.symphony.bdk.core.auth.exception.AuthUnauthorizedException; +import com.symphony.bdk.core.config.model.BdkRetryConfig; +import com.symphony.bdk.http.api.ApiException; + +import lombok.extern.slf4j.Slf4j; +import org.apiguardian.api.API; + +/** + * Abstract class to factorize the {@link OboAuthenticator} logic between RSA and certificate, + * especially the retry logic on top of HTTP calls. + */ +@Slf4j +@API(status = API.Status.INTERNAL) +public abstract class AbstractExtAppAuthenticator implements ExtAppAuthenticator { + + protected final String appId; + private final AuthenticationRetry authenticationRetry; + + protected AbstractExtAppAuthenticator(BdkRetryConfig retryConfig, String appId) { + this.appId = appId; + this.authenticationRetry = new AuthenticationRetry<>(retryConfig); + } + + protected String retrieveAppSessionToken() throws AuthUnauthorizedException { + log.debug("Start authenticating app with id : {} ...", appId); + + final String unauthorizedErrorMessage = "Unable to authenticate app with ID : " + appId + ". " + + "It usually happens when the app has not been configured or is not activated."; + + return authenticationRetry.executeAndRetry("AbstractExtAppAuthenticator.retrieveAppSessionToken", getBasePath(), + this::authenticateAndRetrieveAppSessionToken, unauthorizedErrorMessage); + } + + protected abstract String authenticateAndRetrieveAppSessionToken() throws ApiException; + + protected abstract String getBasePath(); +} diff --git a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/impl/AuthenticatorFactoryImpl.java b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/impl/AuthenticatorFactoryImpl.java index 5e18bb5aa..92a61920b 100644 --- a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/impl/AuthenticatorFactoryImpl.java +++ b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/impl/AuthenticatorFactoryImpl.java @@ -179,6 +179,43 @@ ExtensionAppAuthenticator getExtensionAppAuthenticator() throws AuthInitializati throw new AuthInitializationException("Neither RSA private key nor certificate is configured."); } + /** + * Creates a new instance of a {@link ExtAppAuthenticator} service. + * + * @return a new {@link ExtAppAuthenticator} instance. + */ + @Nonnull + @Override + public ExtAppAuthenticator getExtAppAuthenticator() throws AuthInitializationException { + if (this.config.getApp().isBothCertificateAndRsaConfigured()) { + throw new AuthInitializationException( + "Both of certificate and rsa authentication are configured. Only one of them should be provided."); + } + if (this.config.getApp().isCertificateAuthenticationConfigured()) { + if (!this.config.getApp().isCertificateConfigurationValid()) { + throw new AuthInitializationException( + "Only one of certificate path or content should be configured for app authentication."); + } + return new ExtAppAuthenticatorCertImpl( + this.config.getRetry(), + this.config.getApp().getAppId(), + this.apiClientFactory.getExtAppSessionAuthClient()); + } + if (this.config.getApp().isRsaAuthenticationConfigured()) { + if (!this.config.getApp().isRsaConfigurationValid()) { + throw new AuthInitializationException( + "Only one of private key path or content should be configured for app authentication."); + } + return new ExtAppAuthenticatorRsaImpl( + this.config.getRetry(), + this.config.getApp().getAppId(), + this.loadPrivateKeyFromAuthenticationConfig(this.config.getApp()), + this.apiClientFactory.getLoginClient() + ); + } + throw new AuthInitializationException("Neither RSA private key nor certificate is configured."); + } + private PrivateKey loadPrivateKeyFromAuthenticationConfig(BdkAuthenticationConfig config) throws AuthInitializationException { String privateKeyPath = ""; diff --git a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/impl/ExtAppAuthSessionCertImpl.java b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/impl/ExtAppAuthSessionCertImpl.java new file mode 100644 index 000000000..9d806e0b9 --- /dev/null +++ b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/impl/ExtAppAuthSessionCertImpl.java @@ -0,0 +1,39 @@ +package com.symphony.bdk.core.auth.impl; + +import com.symphony.bdk.core.auth.ExtAppAuthSession; +import com.symphony.bdk.core.auth.exception.AuthUnauthorizedException; + +import org.apiguardian.api.API; +import org.jetbrains.annotations.Nullable; + +/** + * {@link ExtAppAuthSession} impl for Extension App Certificate authentication mode. + */ +@API(status = API.Status.INTERNAL) +public class ExtAppAuthSessionCertImpl implements ExtAppAuthSession { + + String appSession; + ExtAppAuthenticatorCertImpl authenticator; + + public ExtAppAuthSessionCertImpl(ExtAppAuthenticatorCertImpl authenticator) { + this.authenticator = authenticator; + } + + @Nullable + @Override + public String getAppSession() { + return appSession; + } + + @Override + public void refresh() throws AuthUnauthorizedException { + this.appSession = this.authenticator.retrieveAppSessionToken(); + } + + /** + * This method is only visible for testing. + */ + protected ExtAppAuthenticatorCertImpl getAuthenticator() { + return authenticator; + } +} diff --git a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/impl/ExtAppAuthSessionImpl.java b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/impl/ExtAppAuthSessionImpl.java new file mode 100644 index 000000000..70eb332e7 --- /dev/null +++ b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/impl/ExtAppAuthSessionImpl.java @@ -0,0 +1,38 @@ +package com.symphony.bdk.core.auth.impl; + +import com.symphony.bdk.core.auth.ExtAppAuthSession; +import com.symphony.bdk.core.auth.exception.AuthUnauthorizedException; + +import org.apiguardian.api.API; +import org.jetbrains.annotations.Nullable; + +/** + * {@link ExtAppAuthSession} impl for Extension App RSA authentication mode. + */ +@API(status = API.Status.INTERNAL) +public class ExtAppAuthSessionImpl implements ExtAppAuthSession { + ExtAppAuthenticatorRsaImpl authenticator; + String appSession; + + public ExtAppAuthSessionImpl(ExtAppAuthenticatorRsaImpl authenticator) { + this.authenticator = authenticator; + } + + @Nullable + @Override + public String getAppSession() { + return appSession; + } + + @Override + public void refresh() throws AuthUnauthorizedException { + this.appSession = this.authenticator.retrieveAppSessionToken(); + } + + /** + * This method is only visible for testing. + */ + protected ExtAppAuthenticatorRsaImpl getAuthenticator() { + return authenticator; + } +} diff --git a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/impl/ExtAppAuthenticatorCertImpl.java b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/impl/ExtAppAuthenticatorCertImpl.java new file mode 100644 index 000000000..123726323 --- /dev/null +++ b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/impl/ExtAppAuthenticatorCertImpl.java @@ -0,0 +1,42 @@ +package com.symphony.bdk.core.auth.impl; + +import com.symphony.bdk.core.auth.ExtAppAuthSession; +import com.symphony.bdk.core.auth.exception.AuthUnauthorizedException; + +import com.symphony.bdk.core.config.model.BdkRetryConfig; +import com.symphony.bdk.gen.api.CertificateAuthenticationApi; +import com.symphony.bdk.http.api.ApiClient; +import com.symphony.bdk.http.api.ApiException; + +import org.apiguardian.api.API; +import org.jetbrains.annotations.NotNull; + +@API(status = API.Status.INTERNAL) +public class ExtAppAuthenticatorCertImpl extends AbstractExtAppAuthenticator { + private final CertificateAuthenticationApi authenticationApi; + + public ExtAppAuthenticatorCertImpl(BdkRetryConfig retryConfig, + String appId, + ApiClient loginApiClient) { + super(retryConfig, appId); + this.authenticationApi = new CertificateAuthenticationApi(loginApiClient); + } + + @NotNull + @Override + public ExtAppAuthSession authenticateExtApp() throws AuthUnauthorizedException { + ExtAppAuthSession session = new ExtAppAuthSessionCertImpl(this); + session.refresh(); + return session; + } + + @Override + protected String authenticateAndRetrieveAppSessionToken() throws ApiException { + return this.authenticationApi.v1AppAuthenticatePost().getToken(); + } + + @Override + protected String getBasePath() { + return authenticationApi.getApiClient().getBasePath(); + } +} diff --git a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/impl/ExtAppAuthenticatorRsaImpl.java b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/impl/ExtAppAuthenticatorRsaImpl.java new file mode 100644 index 000000000..d8fbbc5b5 --- /dev/null +++ b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/impl/ExtAppAuthenticatorRsaImpl.java @@ -0,0 +1,55 @@ +package com.symphony.bdk.core.auth.impl; + +import com.symphony.bdk.core.auth.ExtAppAuthSession; +import com.symphony.bdk.core.auth.exception.AuthUnauthorizedException; + +import com.symphony.bdk.core.auth.jwt.JwtHelper; +import com.symphony.bdk.core.config.model.BdkRetryConfig; + +import com.symphony.bdk.gen.api.AuthenticationApi; +import com.symphony.bdk.gen.api.model.AuthenticateRequest; +import com.symphony.bdk.http.api.ApiClient; +import com.symphony.bdk.http.api.ApiException; + +import org.apiguardian.api.API; +import org.jetbrains.annotations.NotNull; + +import java.security.PrivateKey; + +@API(status = API.Status.INTERNAL) +public class ExtAppAuthenticatorRsaImpl extends AbstractExtAppAuthenticator { + + private final AuthenticationApi authenticationApi; + private final PrivateKey appPrivateKey; + + public ExtAppAuthenticatorRsaImpl(BdkRetryConfig retryConfig, + String appId, + PrivateKey appPrivateKey, + ApiClient loginApiClient) { + super(retryConfig, appId); + this.appPrivateKey = appPrivateKey; + this.authenticationApi = new AuthenticationApi(loginApiClient); + } + + @Override + protected String authenticateAndRetrieveAppSessionToken() throws ApiException { + final String jwt = JwtHelper.createSignedJwt(appId, JwtHelper.JWT_EXPIRATION_MILLIS, appPrivateKey); + final AuthenticateRequest req = new AuthenticateRequest(); + req.setToken(jwt); + + return this.authenticationApi.pubkeyAppAuthenticatePost(req).getToken(); + } + + @Override + protected String getBasePath() { + return authenticationApi.getApiClient().getBasePath(); + } + + @NotNull + @Override + public ExtAppAuthSession authenticateExtApp() throws AuthUnauthorizedException { + ExtAppAuthSessionImpl extAppAuthSession = new ExtAppAuthSessionImpl(this); + extAppAuthSession.refresh(); + return extAppAuthSession; + } +} diff --git a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/client/ApiClientFactory.java b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/client/ApiClientFactory.java index 977e7b499..5b5f50563 100644 --- a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/client/ApiClientFactory.java +++ b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/client/ApiClientFactory.java @@ -32,6 +32,7 @@ @API(status = API.Status.EXPERIMENTAL) public class ApiClientFactory { + private static final String USERS_CONTEXT_PATH = ""; private static final String LOGIN_CONTEXT_PATH = "/login"; private static final String POD_CONTEXT_PATH = "/pod"; private static final String AGENT_CONTEXT_PATH = "/agent"; @@ -80,6 +81,10 @@ public ApiClient getPodClient(String contextPath) { return buildClient(contextPath, this.config.getPod()); } + public ApiClient getUsersClient() { + return buildClient(USERS_CONTEXT_PATH, this.config.getPod()); + } + /** * Returns a fully initialized {@link ApiClient} for KeyManager API. * diff --git a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/service/app/AppUsersService.java b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/service/app/AppUsersService.java new file mode 100644 index 000000000..d8d0bf581 --- /dev/null +++ b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/service/app/AppUsersService.java @@ -0,0 +1,92 @@ +package com.symphony.bdk.core.service.app; + +import com.symphony.bdk.core.auth.ExtAppAuthSession; +import com.symphony.bdk.core.retry.RetryWithRecovery; +import com.symphony.bdk.core.retry.RetryWithRecoveryBuilder; +import com.symphony.bdk.core.retry.function.SupplierWithApiException; + +import com.symphony.bdk.gen.api.AppsApi; +import com.symphony.bdk.gen.api.model.AppUsersResponse; + +import com.symphony.bdk.http.api.ApiException; + +import org.apiguardian.api.API; + +import java.util.List; + +/** + * Service class for fetching app users. + *

+ * This service is used for retrieving information about users that have the application installed: + *

+ *

+ */ +@API(status = API.Status.STABLE) +public class AppUsersService { + + private final static Integer DEFAULT_PAGE_SIZE = 100; + + private final AppsApi appsApi; + private final ExtAppAuthSession authSession; + private final RetryWithRecoveryBuilder retryBuilder; + private final String appId; + + + public AppUsersService(String appId, + AppsApi appsApi, + ExtAppAuthSession authSession, + RetryWithRecoveryBuilder retryBuilder) { + this.appsApi = appsApi; + this.authSession = authSession; + this.retryBuilder = RetryWithRecoveryBuilder.copyWithoutRecoveryStrategies(retryBuilder) + .recoveryStrategy(ApiException::isUnauthorized, authSession::refresh);; + this.appId = appId; + } + + /** + * Get app users + * @param page to be returned + * @param size number of result per page + * @param sort sorting parameters + * @return {@link AppUsersResponse} + */ + public AppUsersResponse listAppUsers(Integer page, Integer size, List sort) { + String session = authSession.getAppSession(); + return executeAndRetry("listAppUsers", appsApi.getApiClient().getBasePath(), + () -> appsApi.findAppUsers(session, appId, true, page, size, sort)); + } + + /** + * Get app users + * @param page to be returned + * @param size number of result per page + * @return {@link AppUsersResponse} + */ + public AppUsersResponse listAppUsers(Integer page, Integer size) { + return listAppUsers(page, size, null); + } + + /** + * Get app users + * @param page to be returned + * @return {@link AppUsersResponse} + */ + public AppUsersResponse listAppUsers(Integer page) { + return listAppUsers(page, DEFAULT_PAGE_SIZE, null); + } + + /** + * Get app users + * @return {@link AppUsersResponse} + */ + public AppUsersResponse listAppUsers() { + return listAppUsers(null, DEFAULT_PAGE_SIZE, null); + } + + private T executeAndRetry(String name, String address, SupplierWithApiException supplier) { + return RetryWithRecovery.executeAndRetry(retryBuilder, name, address, supplier); + } +} diff --git a/symphony-bdk-core/src/test/java/com/symphony/bdk/core/ExtAppServiceFactoryTest.java b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/ExtAppServiceFactoryTest.java new file mode 100644 index 000000000..09a4582e0 --- /dev/null +++ b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/ExtAppServiceFactoryTest.java @@ -0,0 +1,31 @@ +package com.symphony.bdk.core; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.symphony.bdk.core.auth.ExtAppAuthSession; +import com.symphony.bdk.core.client.ApiClientFactory; +import com.symphony.bdk.core.config.model.BdkConfig; +import com.symphony.bdk.core.service.app.AppUsersService; +import com.symphony.bdk.http.api.ApiClient; + +import org.junit.jupiter.api.Test; + +class ExtAppServiceFactoryTest { + + @Test + void testGetAppService() { + final ApiClientFactory apiClientFactory = mock(ApiClientFactory.class); + final ExtAppAuthSession authSession = mock(ExtAppAuthSession.class); + final BdkConfig config = new BdkConfig(); + config.getApp().setAppId("testApp"); + + when(apiClientFactory.getUsersClient()).thenReturn(mock(ApiClient.class)); + + final ExtAppServiceFactory serviceFactory = new ExtAppServiceFactory(apiClientFactory, authSession, config); + final AppUsersService appUsersService = serviceFactory.getAppService(); + + assertNotNull(appUsersService); + } +} diff --git a/symphony-bdk-core/src/test/java/com/symphony/bdk/core/ExtAppServicesTest.java b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/ExtAppServicesTest.java new file mode 100644 index 000000000..564506e88 --- /dev/null +++ b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/ExtAppServicesTest.java @@ -0,0 +1,33 @@ +package com.symphony.bdk.core; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; + +import com.symphony.bdk.core.auth.ExtAppAuthSession; +import com.symphony.bdk.core.client.ApiClientFactory; +import com.symphony.bdk.core.config.model.BdkConfig; +import com.symphony.bdk.core.config.model.BdkExtAppConfig; +import com.symphony.bdk.core.service.app.AppUsersService; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ExtAppServicesTest { + + private ExtAppServices extAppServices; + + @BeforeEach + void setUp() { + final BdkConfig config = new BdkConfig(); + config.setApp(new BdkExtAppConfig()); + config.getApp().setAppId("test-app"); + + this.extAppServices = new ExtAppServices(mock(ApiClientFactory.class), mock(ExtAppAuthSession.class), config); + } + + @Test + void testAppUsers() { + AppUsersService appUsersService = this.extAppServices.appUsers(); + assertNotNull(appUsersService); + } +} diff --git a/symphony-bdk-core/src/test/java/com/symphony/bdk/core/SymphonyBdkTest.java b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/SymphonyBdkTest.java index 8a4a1a997..1d7eeeb3b 100644 --- a/symphony-bdk-core/src/test/java/com/symphony/bdk/core/SymphonyBdkTest.java +++ b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/SymphonyBdkTest.java @@ -68,6 +68,7 @@ void setUp() throws BdkConfigException, AuthUnauthorizedException, AuthInitializ this.mockApiClient.onPost(LOGIN_PUBKEY_AUTHENTICATE, "{ \"token\": \"1234\", \"name\": \"sessionToken\" }"); this.mockApiClient.onPost(RELAY_PUBKEY_AUTHENTICATE, "{ \"token\": \"1234\", \"name\": \"keyManagerToken\" }"); + this.mockApiClient.onPost(LOGIN_PUBKEY_APP_AUTHENTICATE, "{ \"token\": \"1234\", \"name\": \"sessionToken\" }"); this.mockApiClient.onGet(V2_SESSION_INFO, JsonHelper.readFromClasspath("/res_response/bot_info.json")); this.symphonyBdk = SymphonyBdk.builder() @@ -197,6 +198,17 @@ void extAppAuthenticateTest() throws AuthUnauthorizedException { assertEquals(authSession.expireAt(), 1539636528288L); } + @Test + void getAppServices() { + assertNotNull(this.symphonyBdk.app()); + assertNotNull(this.symphonyBdk.app().appUsers()); + } + + @Test + void getAppSession() { + assertNotNull(this.symphonyBdk.extAppAuthSession()); + } + @Test void botSessionTest() { assertNotNull(this.symphonyBdk.botSession()); diff --git a/symphony-bdk-core/src/test/java/com/symphony/bdk/core/auth/AuthenticatorFactoryTest.java b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/auth/AuthenticatorFactoryTest.java index 5c1b6e1b2..2331a3d1e 100644 --- a/symphony-bdk-core/src/test/java/com/symphony/bdk/core/auth/AuthenticatorFactoryTest.java +++ b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/auth/AuthenticatorFactoryTest.java @@ -10,6 +10,8 @@ import com.symphony.bdk.core.auth.impl.AuthenticatorFactoryImpl; import com.symphony.bdk.core.auth.impl.BotAuthenticatorCertImpl; import com.symphony.bdk.core.auth.impl.BotAuthenticatorRsaImpl; +import com.symphony.bdk.core.auth.impl.ExtAppAuthenticatorCertImpl; +import com.symphony.bdk.core.auth.impl.ExtAppAuthenticatorRsaImpl; import com.symphony.bdk.core.auth.impl.ExtensionAppAuthenticatorCertImpl; import com.symphony.bdk.core.auth.impl.ExtensionAppAuthenticatorRsaImpl; import com.symphony.bdk.core.auth.impl.OboAuthenticatorCertImpl; @@ -52,6 +54,7 @@ public static void setup() { when(DUMMY_API_CLIENT_FACTORY.getRelayClient()).thenReturn(DUMMY_API_CLIENT); when(DUMMY_API_CLIENT_FACTORY.getSessionAuthClient()).thenReturn(DUMMY_API_CLIENT); when(DUMMY_API_CLIENT_FACTORY.getKeyAuthClient()).thenReturn(DUMMY_API_CLIENT); + when(DUMMY_API_CLIENT_FACTORY.getExtAppSessionAuthClient()).thenReturn(DUMMY_API_CLIENT); } @Test @@ -211,6 +214,33 @@ void testGetExtAppAuthenticatorWithValidCertificatePath(@TempDir Path tempDir) t assertEquals(ExtensionAppAuthenticatorCertImpl.class, extAppAuthenticator.getClass()); } + @Test + void testGetNewExtAppAuthenticatorWithValidPrivateKey(@TempDir Path tempDir) throws AuthInitializationException { + + final BdkConfig config = createRsaConfig(() -> { + final Path privateKeyPath = tempDir.resolve(UUID.randomUUID().toString() + "-privateKey.pem"); + writeContentToPath(RSA_PRIVATE_KEY, privateKeyPath); + return privateKeyPath.toAbsolutePath().toString(); + }); + + final AuthenticatorFactory factory = new AuthenticatorFactoryImpl(config, DUMMY_API_CLIENT_FACTORY); + final ExtAppAuthenticator extAppAuth = factory.getExtAppAuthenticator(); + + assertEquals(ExtAppAuthenticatorRsaImpl.class, extAppAuth.getClass()); + } + + @Test + void testGetNewExtAppAuthenticatorWithValidCertificatePath() throws AuthInitializationException { + final BdkConfig config = new BdkConfig(); + config.getApp().getCertificate().setPath("/path/to/cert/file.p12"); + config.getApp().getCertificate().setPassword("password"); + + final AuthenticatorFactory factory = new AuthenticatorFactoryImpl(config, DUMMY_API_CLIENT_FACTORY); + final ExtAppAuthenticator extAppAuthenticator = factory.getExtAppAuthenticator(); + + assertEquals(ExtAppAuthenticatorCertImpl.class, extAppAuthenticator.getClass()); + } + @Test void testGetAuthenticatorWithBothCertificatePathAndContentConfigured() { final BdkConfig config = new BdkConfig(); @@ -225,6 +255,7 @@ void testGetAuthenticatorWithBothCertificatePathAndContentConfigured() { assertThrows(AuthInitializationException.class, factory::getBotAuthenticator); assertThrows(AuthInitializationException.class, factory::getExtensionAppAuthenticator); assertThrows(AuthInitializationException.class, factory::getOboAuthenticator); + assertThrows(AuthInitializationException.class, factory::getExtAppAuthenticator); } @Test @@ -239,6 +270,7 @@ void testGetAuthenticatorWithBothPrivateKeyPathAndContentConfigured() { assertThrows(AuthInitializationException.class, factory::getBotAuthenticator); assertThrows(AuthInitializationException.class, factory::getExtensionAppAuthenticator); assertThrows(AuthInitializationException.class, factory::getOboAuthenticator); + assertThrows(AuthInitializationException.class, factory::getExtAppAuthenticator); } @Test @@ -255,6 +287,20 @@ void testGetExtAppAuthenticatorWithInvalidPrivateKey(@TempDir Path tempDir) { assertThrows(AuthInitializationException.class, factory::getExtensionAppAuthenticator); } + @Test + void testGetNewExtAppAuthenticatorWithInvalidPrivateKey(@TempDir Path tempDir) { + + final BdkConfig config = createRsaConfig(() -> { + final Path privateKeyPath = tempDir.resolve(UUID.randomUUID().toString() + "-privateKey.pem"); + writeContentToPath("invalid-private-key-content", privateKeyPath); + return privateKeyPath.toAbsolutePath().toString(); + }); + + final AuthenticatorFactory factory = new AuthenticatorFactoryImpl(config, DUMMY_API_CLIENT_FACTORY); + + assertThrows(AuthInitializationException.class, factory::getExtAppAuthenticator); + } + @Test void testGetExtAppAuthenticatorWithNotFoundPrivateKey(@TempDir Path tempDir) { @@ -265,6 +311,16 @@ void testGetExtAppAuthenticatorWithNotFoundPrivateKey(@TempDir Path tempDir) { assertThrows(AuthInitializationException.class, factory::getExtensionAppAuthenticator); } + @Test + void testGetNewExtAppAuthenticatorWithNotFoundPrivateKey(@TempDir Path tempDir) { + + final BdkConfig config = createRsaConfig(() -> tempDir.resolve(UUID.randomUUID().toString() + "-privateKey.pem").toAbsolutePath().toString()); + + final AuthenticatorFactory factory = new AuthenticatorFactoryImpl(config, DUMMY_API_CLIENT_FACTORY); + + assertThrows(AuthInitializationException.class, factory::getExtAppAuthenticator); + } + @Test void testGetAuthenticationRsaAndCertificateNotConfigured() { @@ -275,6 +331,7 @@ void testGetAuthenticationRsaAndCertificateNotConfigured() { assertThrows(AuthInitializationException.class, factory::getBotAuthenticator); assertThrows(AuthInitializationException.class, factory::getOboAuthenticator); assertThrows(AuthInitializationException.class, factory::getExtensionAppAuthenticator); + assertThrows(AuthInitializationException.class, factory::getExtAppAuthenticator); } @Test @@ -308,6 +365,7 @@ void testGetAppAuthenticatorBothRsaAndCertificateConfigured(@TempDir Path tempDi assertThrows(AuthInitializationException.class, factory::getExtensionAppAuthenticator); assertThrows(AuthInitializationException.class, factory::getOboAuthenticator); + assertThrows(AuthInitializationException.class, factory::getExtAppAuthenticator); } @Test @@ -327,6 +385,7 @@ void testGetAuthenticatorRsaInvalid(@TempDir Path tempDir) { assertThrows(AuthInitializationException.class, factory::getBotAuthenticator); assertThrows(AuthInitializationException.class, factory::getExtensionAppAuthenticator); assertThrows(AuthInitializationException.class, factory::getOboAuthenticator); + assertThrows(AuthInitializationException.class, factory::getExtAppAuthenticator); } @Test diff --git a/symphony-bdk-core/src/test/java/com/symphony/bdk/core/auth/impl/AbstractExtAppAuthenticatorTest.java b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/auth/impl/AbstractExtAppAuthenticatorTest.java new file mode 100644 index 000000000..b0e86635b --- /dev/null +++ b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/auth/impl/AbstractExtAppAuthenticatorTest.java @@ -0,0 +1,98 @@ +package com.symphony.bdk.core.auth.impl; + +import static com.symphony.bdk.core.test.BdkRetryConfigTestHelper.ofMinimalInterval; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.symphony.bdk.core.auth.ExtAppAuthSession; +import com.symphony.bdk.core.auth.exception.AuthUnauthorizedException; +import com.symphony.bdk.core.config.model.BdkRetryConfig; +import com.symphony.bdk.http.api.ApiException; +import com.symphony.bdk.http.api.ApiRuntimeException; + +import jakarta.ws.rs.ProcessingException; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +import java.net.ConnectException; + +class AbstractExtAppAuthenticatorTest { + + private static class TestExtAppAuthenticator extends AbstractExtAppAuthenticator { + public TestExtAppAuthenticator(BdkRetryConfig retryConfig) { + super(retryConfig, "appId"); + } + + @Override + protected String authenticateAndRetrieveAppSessionToken() throws ApiException { + return null; + } + + @Override + protected String getBasePath() { + return "localhost.symphony.com"; + } + + @NotNull + @Override + public ExtAppAuthSession authenticateExtApp() throws AuthUnauthorizedException { + return null; + } + } + + @Test + void testRetrieveAppSessionTokenSuccess() throws ApiException, AuthUnauthorizedException { + final String appSessionToken = "appSessionToken"; + final AbstractExtAppAuthenticator authenticator = spy(new TestExtAppAuthenticator(ofMinimalInterval())); + doReturn(appSessionToken).when(authenticator).authenticateAndRetrieveAppSessionToken(); + + assertEquals(appSessionToken, authenticator.retrieveAppSessionToken()); + verify(authenticator, times(1)).authenticateAndRetrieveAppSessionToken(); + } + + @Test + void testRetrieveAppSessionTokenShouldRetry() throws ApiException, AuthUnauthorizedException { + final String appSessionToken = "appSessionToken"; + final AbstractExtAppAuthenticator authenticator = spy(new TestExtAppAuthenticator(ofMinimalInterval())); + doThrow(new ApiException(429, "")) + .doThrow(new ApiException(503, "")) + .doThrow(new ProcessingException(new ConnectException())) + .doReturn(appSessionToken) + .when(authenticator).authenticateAndRetrieveAppSessionToken(); + + assertEquals(appSessionToken, authenticator.retrieveAppSessionToken()); + verify(authenticator, times(4)).authenticateAndRetrieveAppSessionToken(); + } + + @Test + void testRetrieveAppSessionTokenRetriesExhausted() throws ApiException { + final AbstractExtAppAuthenticator authenticator = spy(new TestExtAppAuthenticator(ofMinimalInterval(2))); + doThrow(new ApiException(503, "")).when(authenticator).authenticateAndRetrieveAppSessionToken(); + + assertThrows(ApiRuntimeException.class, authenticator::retrieveAppSessionToken); + verify(authenticator, times(2)).authenticateAndRetrieveAppSessionToken(); + } + + @Test + void testRetrieveAppSessionTokenUnauthorized() throws ApiException { + final AbstractExtAppAuthenticator authenticator = spy(new TestExtAppAuthenticator(ofMinimalInterval())); + doThrow(new ApiException(401, "")).when(authenticator).authenticateAndRetrieveAppSessionToken(); + + assertThrows(AuthUnauthorizedException.class, authenticator::retrieveAppSessionToken); + verify(authenticator, times(1)).authenticateAndRetrieveAppSessionToken(); + } + + @Test + void testRetrieveAppSessionTokenUnexpectedApiException() throws ApiException { + final AbstractExtAppAuthenticator authenticator = spy(new TestExtAppAuthenticator(ofMinimalInterval())); + doThrow(new ApiException(404, "")).when(authenticator).authenticateAndRetrieveAppSessionToken(); + + assertThrows(ApiRuntimeException.class, authenticator::retrieveAppSessionToken); + verify(authenticator, times(1)).authenticateAndRetrieveAppSessionToken(); + } +} diff --git a/symphony-bdk-core/src/test/java/com/symphony/bdk/core/auth/impl/ExtAppAuthSessionCertImplTest.java b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/auth/impl/ExtAppAuthSessionCertImplTest.java new file mode 100644 index 000000000..3ac49acd2 --- /dev/null +++ b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/auth/impl/ExtAppAuthSessionCertImplTest.java @@ -0,0 +1,34 @@ +package com.symphony.bdk.core.auth.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.symphony.bdk.core.auth.exception.AuthUnauthorizedException; + +import org.junit.jupiter.api.Test; + +class ExtAppAuthSessionCertImplTest { + + @Test + void testRefresh() throws AuthUnauthorizedException { + final ExtAppAuthenticatorCertImpl authenticator = mock(ExtAppAuthenticatorCertImpl.class); + final ExtAppAuthSessionCertImpl session = new ExtAppAuthSessionCertImpl(authenticator); + + when(authenticator.retrieveAppSessionToken()).thenReturn("appSessionToken"); + + session.refresh(); + assertEquals("appSessionToken", session.getAppSession()); + } + + @Test + void testRefreshAuthUnauthorized() throws AuthUnauthorizedException { + final ExtAppAuthenticatorCertImpl authenticator = mock(ExtAppAuthenticatorCertImpl.class); + final ExtAppAuthSessionCertImpl session = new ExtAppAuthSessionCertImpl(authenticator); + + when(authenticator.retrieveAppSessionToken()).thenThrow(new AuthUnauthorizedException("")); + + assertThrows(AuthUnauthorizedException.class, session::refresh); + } +} diff --git a/symphony-bdk-core/src/test/java/com/symphony/bdk/core/auth/impl/ExtAppAuthSessionImplTest.java b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/auth/impl/ExtAppAuthSessionImplTest.java new file mode 100644 index 000000000..7a42f0418 --- /dev/null +++ b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/auth/impl/ExtAppAuthSessionImplTest.java @@ -0,0 +1,34 @@ +package com.symphony.bdk.core.auth.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.symphony.bdk.core.auth.exception.AuthUnauthorizedException; + +import org.junit.jupiter.api.Test; + +class ExtAppAuthSessionImplTest { + + @Test + void testRefresh() throws AuthUnauthorizedException { + final ExtAppAuthenticatorRsaImpl authenticator = mock(ExtAppAuthenticatorRsaImpl.class); + final ExtAppAuthSessionImpl session = new ExtAppAuthSessionImpl(authenticator); + + when(authenticator.retrieveAppSessionToken()).thenReturn("appSessionToken"); + + session.refresh(); + assertEquals("appSessionToken", session.getAppSession()); + } + + @Test + void testRefreshAuthUnauthorized() throws AuthUnauthorizedException { + final ExtAppAuthenticatorRsaImpl authenticator = mock(ExtAppAuthenticatorRsaImpl.class); + final ExtAppAuthSessionImpl session = new ExtAppAuthSessionImpl(authenticator); + + when(authenticator.retrieveAppSessionToken()).thenThrow(new AuthUnauthorizedException("")); + + assertThrows(AuthUnauthorizedException.class, session::refresh); + } +} diff --git a/symphony-bdk-core/src/test/java/com/symphony/bdk/core/auth/impl/ExtAppAuthenticatorCertImplTest.java b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/auth/impl/ExtAppAuthenticatorCertImplTest.java new file mode 100644 index 000000000..e845a3e01 --- /dev/null +++ b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/auth/impl/ExtAppAuthenticatorCertImplTest.java @@ -0,0 +1,63 @@ +package com.symphony.bdk.core.auth.impl; + +import static com.symphony.bdk.core.test.BdkRetryConfigTestHelper.ofMinimalInterval; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.symphony.bdk.core.auth.ExtAppAuthSession; +import com.symphony.bdk.core.auth.exception.AuthUnauthorizedException; +import com.symphony.bdk.core.test.BdkMockServer; +import com.symphony.bdk.core.test.BdkMockServerExtension; +import com.symphony.bdk.http.api.ApiRuntimeException; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(BdkMockServerExtension.class) +class ExtAppAuthenticatorCertImplTest { + + private ExtAppAuthenticatorCertImpl authenticator; + + @BeforeEach + void init(final BdkMockServer mockServer) { + this.authenticator = new ExtAppAuthenticatorCertImpl( + ofMinimalInterval(1), + "appId", + mockServer.newApiClient("/login") + ); + } + + @Test + void testAuthenticateExtApp(final BdkMockServer mockServer) throws AuthUnauthorizedException { + mockServer.onPost("/login/v1/app/authenticate", res -> res.withBody("{ \"token\": \"1234\" }")); + + final ExtAppAuthSession session = this.authenticator.authenticateExtApp(); + assertNotNull(session); + assertEquals(ExtAppAuthSessionCertImpl.class, session.getClass()); + assertEquals(this.authenticator, ((ExtAppAuthSessionCertImpl) session).getAuthenticator()); + } + + @Test + void testRetrieveAppSessionToken(final BdkMockServer mockServer) throws AuthUnauthorizedException { + mockServer.onPost("/login/v1/app/authenticate", res -> res.withBody("{ \"token\": \"1234\" }")); + + final String appSessionToken = this.authenticator.retrieveAppSessionToken(); + assertEquals("1234", appSessionToken); + } + + @Test + void testAuthUnauthorizedException(final BdkMockServer mockServer) { + mockServer.onPost("/login/v1/app/authenticate", res -> res.withStatusCode(401)); + + assertThrows(AuthUnauthorizedException.class, () -> this.authenticator.retrieveAppSessionToken()); + } + + @Test + void testUnknownApiException(final BdkMockServer mockServer) { + mockServer.onPost("/login/v1/app/authenticate", res -> res.withStatusCode(503)); + + assertThrows(ApiRuntimeException.class, () -> this.authenticator.retrieveAppSessionToken()); + } +} diff --git a/symphony-bdk-core/src/test/java/com/symphony/bdk/core/auth/impl/ExtAppAuthenticatorRsaImplTest.java b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/auth/impl/ExtAppAuthenticatorRsaImplTest.java new file mode 100644 index 000000000..24fc4cc4f --- /dev/null +++ b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/auth/impl/ExtAppAuthenticatorRsaImplTest.java @@ -0,0 +1,69 @@ +package com.symphony.bdk.core.auth.impl; + +import static com.symphony.bdk.core.test.BdkRetryConfigTestHelper.ofMinimalInterval; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.symphony.bdk.core.auth.ExtAppAuthSession; +import com.symphony.bdk.core.auth.exception.AuthUnauthorizedException; +import com.symphony.bdk.core.test.BdkMockServer; +import com.symphony.bdk.core.test.BdkMockServerExtension; +import com.symphony.bdk.core.test.RsaTestHelper; +import com.symphony.bdk.http.api.ApiRuntimeException; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.security.PrivateKey; + +@ExtendWith(BdkMockServerExtension.class) +class ExtAppAuthenticatorRsaImplTest { + + private static final PrivateKey PRIVATE_KEY = RsaTestHelper.generateKeyPair().getPrivate(); + + private ExtAppAuthenticatorRsaImpl authenticator; + + @BeforeEach + void init(final BdkMockServer mockServer) { + this.authenticator = new ExtAppAuthenticatorRsaImpl( + ofMinimalInterval(1), + "appId", + PRIVATE_KEY, + mockServer.newApiClient("/login") + ); + } + + @Test + void testAuthenticateExtApp(final BdkMockServer mockServer) throws AuthUnauthorizedException { + mockServer.onPost("/login/pubkey/app/authenticate", res -> res.withBody("{ \"token\": \"1234\" }")); + + final ExtAppAuthSession session = this.authenticator.authenticateExtApp(); + assertNotNull(session); + assertEquals(ExtAppAuthSessionImpl.class, session.getClass()); + assertEquals(this.authenticator, ((ExtAppAuthSessionImpl) session).getAuthenticator()); + } + + @Test + void testRetrieveAppSessionToken(final BdkMockServer mockServer) throws AuthUnauthorizedException { + mockServer.onPost("/login/pubkey/app/authenticate", res -> res.withBody("{ \"token\": \"1234\" }")); + + final String appSessionToken = this.authenticator.retrieveAppSessionToken(); + assertEquals("1234", appSessionToken); + } + + @Test + void testAuthUnauthorizedException(final BdkMockServer mockServer) { + mockServer.onPost("/login/pubkey/app/authenticate", res -> res.withStatusCode(401)); + + assertThrows(AuthUnauthorizedException.class, () -> this.authenticator.retrieveAppSessionToken()); + } + + @Test + void testUnknownApiException(final BdkMockServer mockServer) { + mockServer.onPost("/login/pubkey/app/authenticate", res -> res.withStatusCode(503)); + + assertThrows(ApiRuntimeException.class, () -> this.authenticator.retrieveAppSessionToken()); + } +} diff --git a/symphony-bdk-core/src/test/java/com/symphony/bdk/core/client/ApiClientFactoryTest.java b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/client/ApiClientFactoryTest.java index 92d8cb72f..4a6c2ab6a 100644 --- a/symphony-bdk-core/src/test/java/com/symphony/bdk/core/client/ApiClientFactoryTest.java +++ b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/client/ApiClientFactoryTest.java @@ -39,6 +39,13 @@ void testGetLoginClient() { assertEquals("https://pod-host:443/login", loginClient.getBasePath()); } + @Test + void testGetUsersClient() { + final ApiClient usersClient = this.factory.getUsersClient(); + assertEquals(ApiClientJersey2.class, usersClient.getClass()); + assertEquals("https://pod-host:443", usersClient.getBasePath()); + } + @Test void testGetRelayClient() { final ApiClient relayClient = this.factory.getRelayClient(); diff --git a/symphony-bdk-core/src/test/java/com/symphony/bdk/core/service/app/AppUsersServiceTest.java b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/service/app/AppUsersServiceTest.java new file mode 100644 index 000000000..a78c1351c --- /dev/null +++ b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/service/app/AppUsersServiceTest.java @@ -0,0 +1,102 @@ +package com.symphony.bdk.core.service.app; + +import com.symphony.bdk.core.auth.AuthSession; +import com.symphony.bdk.core.auth.ExtAppAuthSession; +import com.symphony.bdk.core.retry.RetryWithRecoveryBuilder; +import com.symphony.bdk.core.service.application.ApplicationService; +import com.symphony.bdk.core.test.JsonHelper; +import com.symphony.bdk.core.test.MockApiClient; +import com.symphony.bdk.gen.api.AppEntitlementApi; +import com.symphony.bdk.gen.api.ApplicationApi; +import com.symphony.bdk.gen.api.AppsApi; +import com.symphony.bdk.gen.api.model.AppUsersResponse; +import com.symphony.bdk.http.api.ApiClient; + +import com.symphony.bdk.http.api.ApiException; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class AppUsersServiceTest { + + private AppsApi appsApi; + private MockApiClient mockApiClient; + private AppUsersService service; + + private static final String SESSION_TOKEN = "app-session-token"; + private static final String APP_ID = "my_app-id"; + private static final String LIST_APP_USERS_PATH = "/v5/users/apps/{appId}"; + + @BeforeEach + void init() { + this.mockApiClient = new MockApiClient(); + ExtAppAuthSession authSession = mock(ExtAppAuthSession.class); + ApiClient podClient = mockApiClient.getApiClient(""); + AppsApi applicationApi = new AppsApi(podClient); + this.appsApi = spy(applicationApi); + this.service = new AppUsersService(APP_ID, this.appsApi, authSession, + new RetryWithRecoveryBuilder<>()); + + when(authSession.getAppSession()).thenReturn(SESSION_TOKEN); + } + + @Test + void listAppUsers() throws IOException { + mockApiClient.onGet(LIST_APP_USERS_PATH.replace("{appId}", APP_ID), + JsonHelper.readFromClasspath("/app/list_app_users.json")); + AppUsersResponse response = this.service.listAppUsers(); + assertEquals(9, response.getUsers().size()); + assertEquals("premium", response.getUsers().get(0).getProduct().getType()); + assertEquals("default", response.getUsers().get(1).getProduct().getType()); + assertEquals(9, response.getPage().getTotalElements()); + assertEquals(1, response.getPage().getTotalPages()); + assertEquals(100, response.getPage().getSize()); + assertEquals(0, response.getPage().getNumber()); + } + + @Test + void listAppUsersWithPage() throws IOException, ApiException { + mockApiClient.onGet(LIST_APP_USERS_PATH.replace("{appId}", APP_ID), + JsonHelper.readFromClasspath("/app/list_app_users.json")); + Integer PAGE = 2; + + this.service.listAppUsers(PAGE); + + verify(this.appsApi).findAppUsers(eq(SESSION_TOKEN), eq(APP_ID), eq(true), eq(PAGE), eq(100), eq(null)); + } + + @Test + void listAppUsersWithPageAndSize() throws IOException, ApiException { + mockApiClient.onGet(LIST_APP_USERS_PATH.replace("{appId}", APP_ID), + JsonHelper.readFromClasspath("/app/list_app_users.json")); + Integer PAGE = 2; + Integer PAGE_SIZE = 10; + + this.service.listAppUsers(PAGE, PAGE_SIZE); + + verify(this.appsApi).findAppUsers(eq(SESSION_TOKEN), eq(APP_ID), eq(true), eq(PAGE), eq(PAGE_SIZE), eq(null)); + } + + @Test + void listAppUsersWithPageAndSizeAndSort() throws IOException, ApiException { + mockApiClient.onGet(LIST_APP_USERS_PATH.replace("{appId}", APP_ID), + JsonHelper.readFromClasspath("/app/list_app_users.json")); + Integer PAGE = 2; + Integer PAGE_SIZE = 10; + List SORT_PARAMETERS = List.of("userId"); + + this.service.listAppUsers(PAGE, PAGE_SIZE, SORT_PARAMETERS); + + verify(this.appsApi).findAppUsers(eq(SESSION_TOKEN), eq(APP_ID), eq(true), eq(PAGE), eq(PAGE_SIZE), eq(SORT_PARAMETERS)); + } +} diff --git a/symphony-bdk-core/src/test/resources/app/list_app_users.json b/symphony-bdk-core/src/test/resources/app/list_app_users.json new file mode 100644 index 000000000..c232547e1 --- /dev/null +++ b/symphony-bdk-core/src/test/resources/app/list_app_users.json @@ -0,0 +1,110 @@ +{ + "users": [ + { + "userId": 82806969466881, + "enable": true, + "listed": false, + "product": { + "name": "premium", + "type": "premium", + "subscribed": true + } + }, + { + "userId": 82806969466882, + "enable": true, + "listed": false, + "product": { + "name": "Standard", + "type": "default", + "subscribed": true + } + }, + { + "userId": 82806969466883, + "enable": true, + "listed": false, + "product": { + "name": "Standard", + "type": "default", + "subscribed": true + } + }, + { + "userId": 82806969466884, + "enable": true, + "listed": false, + "product": { + "name": "Standard", + "type": "default", + "subscribed": true + } + }, + { + "userId": 82806969466885, + "enable": true, + "listed": false, + "product": { + "name": "Standard", + "type": "default", + "subscribed": true + } + }, + { + "userId": 82806969466886, + "enable": true, + "listed": false, + "product": { + "name": "Standard", + "type": "default", + "subscribed": true + } + }, + { + "userId": 82806969466887, + "enable": true, + "listed": false, + "product": { + "name": "Standard", + "type": "default", + "subscribed": true + } + }, + { + "userId": 82806969466888, + "enable": true, + "listed": false, + "product": { + "name": "Standard", + "type": "default", + "subscribed": true + } + }, + { + "userId": 82806969466889, + "enable": true, + "listed": false, + "product": { + "name": "Standard", + "type": "default", + "subscribed": true + } + } + ], + "page": { + "size": 100, + "number": 0, + "totalElements": 9, + "totalPages": 1, + "first": true, + "last": true, + "hasNext": false, + "hasPrevious": false, + "sort": [ + { + "property": "userId", + "direction": "ASC" + } + ] + } +} diff --git a/symphony-bdk-extensions/symphony-group-extension/build.gradle b/symphony-bdk-extensions/symphony-group-extension/build.gradle index fe4fab8a4..2384fe6ec 100644 --- a/symphony-bdk-extensions/symphony-group-extension/build.gradle +++ b/symphony-bdk-extensions/symphony-group-extension/build.gradle @@ -43,6 +43,11 @@ openApiGenerate { inputSpec = "$buildDir/profile-manager-api.yaml" apiPackage = 'com.symphony.bdk.ext.group.gen.api' modelPackage = 'com.symphony.bdk.ext.group.gen.api.model' + globalProperties = [ + models: "", + apis: "", + supportingFiles: "false" + ] } tasks.openApiGenerate.dependsOn tasks.downloadFile diff --git a/templates/api.mustache b/templates/api.mustache index 5f885b634..3807194ca 100644 --- a/templates/api.mustache +++ b/templates/api.mustache @@ -51,6 +51,11 @@ public class {{classname}} { {{#allParams}} * @param {{paramName}} {{description}}{{#required}} (required){{/required}}{{^required}} (optional{{#defaultValue}}, default to {{.}}{{/defaultValue}}){{/required}} {{/allParams}} + {{#vendorExtensions.x-spring-paginated}} + * @param page Page number (0-based) (optional) + * @param size Number of records per page (optional) + * @param sort Sorting criteria in the format: property,asc|desc (optional) + {{/vendorExtensions.x-spring-paginated}} {{#returnType}} * @return {{returnType}} {{/returnType}} @@ -75,8 +80,8 @@ public class {{classname}} { {{#isDeprecated}} @Deprecated {{/isDeprecated}} - public {{#returnType}}{{{returnType}}} {{/returnType}}{{^returnType}}void {{/returnType}}{{operationId}}({{#allParams}}{{{dataType}}} {{paramName}}{{^-last}}, {{/-last}}{{/allParams}}) throws ApiException { - {{#returnType}}return {{/returnType}}{{operationId}}WithHttpInfo({{#allParams}}{{paramName}}{{^-last}}, {{/-last}}{{/allParams}}){{#returnType}}.getData(){{/returnType}}; + public {{#returnType}}{{{returnType}}} {{/returnType}}{{^returnType}}void {{/returnType}}{{operationId}}({{#allParams}}{{{dataType}}} {{paramName}}{{^-last}}, {{/-last}}{{/allParams}}{{#vendorExtensions.x-spring-paginated}}{{#hasParams}}, {{/hasParams}}Integer page, Integer size, java.util.List sort{{/vendorExtensions.x-spring-paginated}}) throws ApiException { + {{#returnType}}return {{/returnType}}{{operationId}}WithHttpInfo({{#allParams}}{{paramName}}{{^-last}}, {{/-last}}{{/allParams}}{{#vendorExtensions.x-spring-paginated}}{{#hasParams}}, {{/hasParams}}page, size, sort{{/vendorExtensions.x-spring-paginated}}){{#returnType}}.getData(){{/returnType}}; } {{/vendorExtensions.x-group-parameters}} @@ -87,6 +92,11 @@ public class {{classname}} { {{#allParams}} * @param {{paramName}} {{description}}{{#required}} (required){{/required}}{{^required}} (optional{{#defaultValue}}, default to {{.}}{{/defaultValue}}){{/required}} {{/allParams}} + {{#vendorExtensions.x-spring-paginated}} + * @param page Page number (0-based) (optional) + * @param size Number of records per page (optional) + * @param sort Sorting criteria in the format: property,asc|desc (optional) + {{/vendorExtensions.x-spring-paginated}} * @return ApiResponse<{{#returnType}}{{returnType}}{{/returnType}}{{^returnType}}Void{{/returnType}}> * @throws ApiException if fails to make API call {{#responses.0}} @@ -109,7 +119,7 @@ public class {{classname}} { {{#isDeprecated}} @Deprecated {{/isDeprecated}} - public{{/vendorExtensions.x-group-parameters}}{{#vendorExtensions.x-group-parameters}}private{{/vendorExtensions.x-group-parameters}} ApiResponse<{{#returnType}}{{{returnType}}}{{/returnType}}{{^returnType}}Void{{/returnType}}> {{operationId}}WithHttpInfo({{#allParams}}{{{dataType}}} {{paramName}}{{^-last}}, {{/-last}}{{/allParams}}) throws ApiException { + public{{/vendorExtensions.x-group-parameters}}{{#vendorExtensions.x-group-parameters}}private{{/vendorExtensions.x-group-parameters}} ApiResponse<{{#returnType}}{{{returnType}}}{{/returnType}}{{^returnType}}Void{{/returnType}}> {{operationId}}WithHttpInfo({{#allParams}}{{{dataType}}} {{paramName}}{{^-last}}, {{/-last}}{{/allParams}}{{#vendorExtensions.x-spring-paginated}}{{#hasParams}}, {{/hasParams}}Integer page, Integer size, java.util.List sort{{/vendorExtensions.x-spring-paginated}}) throws ApiException { Object localVarPostBody = {{#bodyParam}}{{paramName}}{{/bodyParam}}{{^bodyParam}}null{{/bodyParam}}; {{#allParams}}{{#required}} // verify the required parameter '{{paramName}}' is set @@ -131,6 +141,18 @@ public class {{classname}} { localVarQueryParams.addAll(apiClient.parameterToPairs("{{#collectionFormat}}{{{collectionFormat}}}{{/collectionFormat}}", "{{baseName}}", {{paramName}})); {{/queryParams}} + {{#vendorExtensions.x-spring-paginated}} + if (page != null) { + localVarQueryParams.addAll(apiClient.parameterToPairs("", "page", page)); + } + if (size != null) { + localVarQueryParams.addAll(apiClient.parameterToPairs("", "size", size)); + } + if (sort != null) { + localVarQueryParams.addAll(apiClient.parameterToPairs("multi", "sort", sort)); + } + {{/vendorExtensions.x-spring-paginated}} + {{#headerParams}}if ({{paramName}} != null) localVarHeaderParams.put("{{baseName}}", apiClient.parameterToString({{paramName}})); {{/headerParams}} @@ -170,6 +192,12 @@ public class {{classname}} { private {{#isRequired}}final {{/isRequired}}{{{dataType}}} {{paramName}}; {{/allParams}} + {{#vendorExtensions.x-spring-paginated}} + private Integer page; + private Integer size; + private java.util.List sort; + {{/vendorExtensions.x-spring-paginated}} + private API{{operationId}}Request({{#pathParams}}{{{dataType}}} {{paramName}}{{^-last}}, {{/-last}}{{/pathParams}}) { {{#pathParams}} this.{{paramName}} = {{paramName}}; @@ -190,6 +218,38 @@ public class {{classname}} { {{/isPathParam}} {{/allParams}} + {{#vendorExtensions.x-spring-paginated}} + /** + * Set page + * @param page Page number (0-based) (optional) + * @return API{{operationId}}Request + */ + public API{{operationId}}Request page(Integer page) { + this.page = page; + return this; + } + + /** + * Set size + * @param size Number of records per page (optional) + * @return API{{operationId}}Request + */ + public API{{operationId}}Request size(Integer size) { + this.size = size; + return this; + } + + /** + * Set sort + * @param sort Sorting criteria in the format: property,asc|desc (optional) + * @return API{{operationId}}Request + */ + public API{{operationId}}Request sort(java.util.List sort) { + this.sort = sort; + return this; + } + {{/vendorExtensions.x-spring-paginated}} + /** * Execute {{operationId}} request {{#returnType}}* @return {{.}}{{/returnType}} @@ -230,7 +290,7 @@ public class {{classname}} { @Deprecated {{/isDeprecated}} public ApiResponse<{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}Void{{/returnType}}> executeWithHttpInfo() throws ApiException { - return {{operationId}}WithHttpInfo({{#allParams}}{{paramName}}{{^-last}}, {{/-last}}{{/allParams}}); + return {{operationId}}WithHttpInfo({{#allParams}}{{paramName}}{{^-last}}, {{/-last}}{{/allParams}}{{#vendorExtensions.x-spring-paginated}}{{#hasParams}}, {{/hasParams}}page, size, sort{{/vendorExtensions.x-spring-paginated}}); } } diff --git a/templates/pojo.mustache b/templates/pojo.mustache index 616652520..d3fe39470 100644 --- a/templates/pojo.mustache +++ b/templates/pojo.mustache @@ -1,3 +1,6 @@ +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; + /** * {{#description}}{{.}}{{/description}}{{^description}}{{classname}}{{/description}} */{{#description}} @@ -67,12 +70,7 @@ public class {{classname}} {{#parent}}extends {{{parent}}} {{/parent}}{{#vendorE {{/isContainer}} {{/vendorExtensions.x-is-jackson-optional-nullable}} {{^vendorExtensions.x-is-jackson-optional-nullable}} - {{#isContainer}} - private {{{datatypeWithEnum}}} {{name}}{{#required}}{{#defaultValue}} = {{{.}}}{{/defaultValue}}{{/required}}{{^required}} = null{{/required}}; - {{/isContainer}} - {{^isContainer}} - private {{{datatypeWithEnum}}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}}; - {{/isContainer}} + private {{{datatypeWithEnum}}} {{name}}{{#isContainer}} = new {{#isArray}}{{#uniqueItems}}LinkedHashSet{{/uniqueItems}}{{^uniqueItems}}ArrayList{{/uniqueItems}}{{/isArray}}{{#isMap}}HashMap{{/isMap}}<>(){{/isContainer}}{{^isContainer}}{{#defaultValue}} = {{{.}}}{{/defaultValue}}{{/isContainer}}; {{/vendorExtensions.x-is-jackson-optional-nullable}} {{/vars}} @@ -101,7 +99,7 @@ public class {{classname}} {{#parent}}extends {{{parent}}} {{/parent}}{{#vendorE {{^isReadOnly}} {{#vendorExtensions.x-enum-as-string}} - public static final Set {{{nameInSnakeCase}}}_VALUES = new HashSet<>(Arrays.asList( + public static final Set {{{nameInSnakeCase}}}_VALUES = new LinkedHashSet<>(Arrays.asList( {{#allowableValues}}{{#enumVars}}{{{value}}}{{^-last}}, {{/-last}}{{/enumVars}}{{/allowableValues}} )); @@ -138,7 +136,7 @@ public class {{classname}} {{#parent}}extends {{{parent}}} {{/parent}}{{#vendorE {{^vendorExtensions.x-is-jackson-optional-nullable}} {{^required}} if (this.{{name}} == null) { - this.{{name}} = {{{defaultValue}}}; + this.{{name}} = new {{#uniqueItems}}LinkedHashSet{{/uniqueItems}}{{^uniqueItems}}ArrayList{{/uniqueItems}}<>(); } {{/required}} this.{{name}}.add({{name}}Item); @@ -163,7 +161,7 @@ public class {{classname}} {{#parent}}extends {{{parent}}} {{/parent}}{{#vendorE {{^vendorExtensions.x-is-jackson-optional-nullable}} {{^required}} if (this.{{name}} == null) { - this.{{name}} = {{{defaultValue}}}; + this.{{name}} = new HashMap<>(); } {{/required}} this.{{name}}.put(key, {{name}}Item);