From 489f7aa0d683762c3ec990358d25944694b29af8 Mon Sep 17 00:00:00 2001 From: Daniel K Date: Thu, 7 Mar 2024 16:23:41 +0100 Subject: [PATCH] #76 rearchitecting suggestions for easier testability of UserSearchService + added some tests (#102) --- .../main/resources/example.application.yaml | 1 - .../loginsvc/rest/AuthManagerConfig.scala | 19 ++- .../auth/ActiveDirectoryLDAPConfig.scala | 2 +- ...icAuthOrder.scala => ConfigOrdering.scala} | 2 +- .../rest/config/auth/UsersConfig.scala | 2 +- .../rest/config/provider/ConfigProvider.scala | 5 - .../rest/service/jwt/JWTService.scala | 4 +- .../service/search/AuthSearchService.scala | 59 -------- .../search/DefaultUserRepositories.scala | 48 ++++++ ...rovider.scala => LdapUserRepository.scala} | 6 +- .../service/search/UserRepositories.scala | 22 +++ ...rchProvider.scala => UserRepository.scala} | 2 +- .../service/search/UserSearchService.scala | 35 +++++ ....scala => UsersFromConfigRepository.scala} | 6 +- api/src/test/resources/application.yaml | 2 - .../config/provider/ConfigProviderTest.scala | 5 - .../rest/service/jwt/JWTServiceTest.scala | 6 +- .../search/AuthSearchServiceTest.scala | 67 --------- .../search/DefaultUserRepositoriesTest.scala | 107 ++++++++++++++ .../search/UserSearchServiceTest.scala | 138 ++++++++++++++++++ ...la => UsersFromConfigRepositoryTest.scala} | 4 +- project/Dependencies.scala | 6 +- 22 files changed, 384 insertions(+), 164 deletions(-) rename api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/{DynamicAuthOrder.scala => ConfigOrdering.scala} (96%) delete mode 100644 api/src/main/scala/za/co/absa/loginsvc/rest/service/search/AuthSearchService.scala create mode 100644 api/src/main/scala/za/co/absa/loginsvc/rest/service/search/DefaultUserRepositories.scala rename api/src/main/scala/za/co/absa/loginsvc/rest/service/search/{LdapSearchProvider.scala => LdapUserRepository.scala} (95%) create mode 100644 api/src/main/scala/za/co/absa/loginsvc/rest/service/search/UserRepositories.scala rename api/src/main/scala/za/co/absa/loginsvc/rest/service/search/{AuthSearchProvider.scala => UserRepository.scala} (96%) create mode 100644 api/src/main/scala/za/co/absa/loginsvc/rest/service/search/UserSearchService.scala rename api/src/main/scala/za/co/absa/loginsvc/rest/service/search/{ConfigSearchProvider.scala => UsersFromConfigRepository.scala} (88%) delete mode 100644 api/src/test/scala/za/co/absa/loginsvc/rest/service/search/AuthSearchServiceTest.scala create mode 100644 api/src/test/scala/za/co/absa/loginsvc/rest/service/search/DefaultUserRepositoriesTest.scala create mode 100644 api/src/test/scala/za/co/absa/loginsvc/rest/service/search/UserSearchServiceTest.scala rename api/src/test/scala/za/co/absa/loginsvc/rest/service/search/{ConfigSearchProviderTest.scala => UsersFromConfigRepositoryTest.scala} (89%) diff --git a/api/src/main/resources/example.application.yaml b/api/src/main/resources/example.application.yaml index 53102d18..871b2c37 100644 --- a/api/src/main/resources/example.application.yaml +++ b/api/src/main/resources/example.application.yaml @@ -20,7 +20,6 @@ loginsvc: #poll-time: 5min #alg-name: "RS256" config: - some-key: "BETA" # Generates git.properties file for use on info endpoint. git-info: # Generate Git Information on each run of the application. diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/AuthManagerConfig.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/AuthManagerConfig.scala index 607d07d6..30f2a922 100644 --- a/api/src/main/scala/za/co/absa/loginsvc/rest/AuthManagerConfig.scala +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/AuthManagerConfig.scala @@ -22,7 +22,7 @@ import org.springframework.context.annotation.{Bean, Configuration} import org.springframework.security.authentication.{AuthenticationManager, AuthenticationProvider} import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder import org.springframework.security.config.annotation.web.builders.HttpSecurity -import za.co.absa.loginsvc.rest.config.auth.{ActiveDirectoryLDAPConfig, DynamicAuthOrder, UsersConfig} +import za.co.absa.loginsvc.rest.config.auth.{ActiveDirectoryLDAPConfig, ConfigOrdering, UsersConfig} import za.co.absa.loginsvc.rest.config.provider.AuthConfigProvider import za.co.absa.loginsvc.rest.config.validation.ConfigValidationException import za.co.absa.loginsvc.rest.provider.ConfigUsersAuthenticationProvider @@ -30,11 +30,16 @@ import za.co.absa.loginsvc.rest.provider.ad.ldap.ActiveDirectoryLDAPAuthenticati import scala.collection.immutable.SortedMap +/** + * This class registers the authManager bean that is responsible for users to be able to "login" using their credentials + * based on the config + * @param authConfigsProvider + */ @Configuration -class AuthManagerConfig @Autowired()(authConfigProvider: AuthConfigProvider){ +class AuthManagerConfig @Autowired()(authConfigsProvider: AuthConfigProvider){ - private val usersConfig: Option[UsersConfig] = authConfigProvider.getUsersConfig - private val adLDAPConfig: Option[ActiveDirectoryLDAPConfig] = authConfigProvider.getLdapConfig + private val usersConfig: Option[UsersConfig] = authConfigsProvider.getUsersConfig + private val adLDAPConfig: Option[ActiveDirectoryLDAPConfig] = authConfigsProvider.getLdapConfig private val logger = LoggerFactory.getLogger(classOf[AuthManagerConfig]) @@ -42,8 +47,8 @@ class AuthManagerConfig @Autowired()(authConfigProvider: AuthConfigProvider){ def authManager(http: HttpSecurity): AuthenticationManager = { val authenticationManagerBuilder = http.getSharedObject(classOf[AuthenticationManagerBuilder]).parentAuthenticationManager(null) - val configs: Array[DynamicAuthOrder] = Array(usersConfig, adLDAPConfig).flatten - val orderedProviders = createProviders(configs) + val configs: Array[ConfigOrdering] = Array(usersConfig, adLDAPConfig).flatten + val orderedProviders = createAuthProviders(configs) if(orderedProviders.isEmpty) throw ConfigValidationException("No authentication method enabled in config") @@ -55,7 +60,7 @@ class AuthManagerConfig @Autowired()(authConfigProvider: AuthConfigProvider){ authenticationManagerBuilder.build } - private def createProviders(configs: Array[DynamicAuthOrder]): Array[AuthenticationProvider] = { + private def createAuthProviders(configs: Array[ConfigOrdering]): Array[AuthenticationProvider] = { Array.empty[AuthenticationProvider] ++ configs.filter(_.order > 0).sortBy(_.order) .map { case c: UsersConfig => new ConfigUsersAuthenticationProvider(c) diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/ActiveDirectoryLDAPConfig.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/ActiveDirectoryLDAPConfig.scala index c8195c64..7df93c09 100644 --- a/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/ActiveDirectoryLDAPConfig.scala +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/ActiveDirectoryLDAPConfig.scala @@ -35,7 +35,7 @@ case class ActiveDirectoryLDAPConfig(domain: String, order: Int, serviceAccount: ServiceAccountConfig, attributes: Option[Map[String, String]]) - extends ConfigValidatable with DynamicAuthOrder + extends ConfigValidatable with ConfigOrdering { def throwErrors(): Unit = diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/DynamicAuthOrder.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/ConfigOrdering.scala similarity index 96% rename from api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/DynamicAuthOrder.scala rename to api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/ConfigOrdering.scala index 909264b9..d9eff75e 100644 --- a/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/DynamicAuthOrder.scala +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/ConfigOrdering.scala @@ -15,6 +15,6 @@ */ package za.co.absa.loginsvc.rest.config.auth -trait DynamicAuthOrder { +trait ConfigOrdering { def order : Int } diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/UsersConfig.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/UsersConfig.scala index 2a14d7f1..6aee92be 100644 --- a/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/UsersConfig.scala +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/UsersConfig.scala @@ -20,7 +20,7 @@ import za.co.absa.loginsvc.rest.config.validation.ConfigValidationResult.{Config import za.co.absa.loginsvc.rest.config.validation.{ConfigValidatable, ConfigValidationException, ConfigValidationResult} case class UsersConfig(knownUsers: Array[UserConfig], order: Int) - extends ConfigValidatable with DynamicAuthOrder { + extends ConfigValidatable with ConfigOrdering { lazy val knownUsersMap: Map[String, UserConfig] = knownUsers .map { entry => (entry.username, entry) } diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/config/provider/ConfigProvider.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/config/provider/ConfigProvider.scala index 0b365baa..933d30c5 100644 --- a/api/src/main/scala/za/co/absa/loginsvc/rest/config/provider/ConfigProvider.scala +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/config/provider/ConfigProvider.scala @@ -42,11 +42,6 @@ class ConfigProvider(@Value("${spring.config.location}") yamlPath: String) //GitConfig needs to be initialized at startup getGitConfig - def getBaseConfig : BaseConfig = { - createConfigClass[BaseConfig]("loginsvc.rest.config"). - getOrElse(BaseConfig("")) - } - def getJwtKeyConfig : KeyConfig = { val inMemoryKeyConfig: Option[InMemoryKeyConfig] = createConfigClass[InMemoryKeyConfig]("loginsvc.rest.jwt.generate-in-memory") val awsSecretsManagerKeyConfig: Option[AwsSecretsManagerKeyConfig] = createConfigClass[AwsSecretsManagerKeyConfig]("loginsvc.rest.jwt.aws-secrets-manager") diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/service/jwt/JWTService.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/service/jwt/JWTService.scala index caf6a673..e5fe8747 100644 --- a/api/src/main/scala/za/co/absa/loginsvc/rest/service/jwt/JWTService.scala +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/service/jwt/JWTService.scala @@ -26,7 +26,7 @@ import za.co.absa.loginsvc.model.User import za.co.absa.loginsvc.rest.config.provider.JwtConfigProvider import za.co.absa.loginsvc.rest.model.{AccessToken, RefreshToken, Token} import za.co.absa.loginsvc.rest.service.jwt.JWTService.extractUserFrom -import za.co.absa.loginsvc.rest.service.search.AuthSearchService +import za.co.absa.loginsvc.rest.service.search.UserSearchService import java.security.interfaces.RSAPublicKey import java.security.{KeyPair, PublicKey} @@ -38,7 +38,7 @@ import scala.compat.java8.DurationConverters._ import scala.concurrent.duration.FiniteDuration @Service -class JWTService @Autowired()(jwtConfigProvider: JwtConfigProvider, authSearchService: AuthSearchService) { +class JWTService @Autowired()(jwtConfigProvider: JwtConfigProvider, authSearchService: UserSearchService) { private val logger = LoggerFactory.getLogger(classOf[JWTService]) private val scheduler = Executors.newSingleThreadScheduledExecutor(r => { diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/service/search/AuthSearchService.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/service/search/AuthSearchService.scala deleted file mode 100644 index 1b1fbe89..00000000 --- a/api/src/main/scala/za/co/absa/loginsvc/rest/service/search/AuthSearchService.scala +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2023 ABSA Group Limited - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package za.co.absa.loginsvc.rest.service.search - -import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.stereotype.Service -import za.co.absa.loginsvc.model.User -import za.co.absa.loginsvc.rest.config.auth.{ActiveDirectoryLDAPConfig, DynamicAuthOrder, UsersConfig} -import za.co.absa.loginsvc.rest.config.provider.AuthConfigProvider -import za.co.absa.loginsvc.rest.config.validation.ConfigValidationException - -@Service -class AuthSearchService @Autowired()(authConfigProvider: AuthConfigProvider) { - - private val logger = LoggerFactory.getLogger(classOf[AuthSearchService]) - - private val usersConfig: Option[DynamicAuthOrder] = authConfigProvider.getUsersConfig - private val adLDAPConfig: Option[DynamicAuthOrder] = authConfigProvider.getLdapConfig - - private val configs: Array[DynamicAuthOrder] = Array(usersConfig, adLDAPConfig).flatten.filter(_.order != 0).sortBy(_.order) - private val orderedProviders = createProviders(configs) - - if (orderedProviders.isEmpty) - throw ConfigValidationException("No authentication method enabled in config") - - def searchUser(username: String): User = { - orderedProviders.foreach { provider => - val user = provider.searchForUser(username) - if (user.isDefined) { - return user.get - } - } - throw new NoSuchElementException(s"Value not found in any object.") - } - - private def createProviders(configs: Array[DynamicAuthOrder]): Array[AuthSearchProvider] = { - Array.empty[AuthSearchProvider] ++ configs.filter(_.order > 0).sortBy(_.order) - .map { - case c: UsersConfig => new ConfigSearchProvider(c) - case c: ActiveDirectoryLDAPConfig => new LdapSearchProvider(c) - case other => throw new IllegalStateException(s"unsupported config $other") - } - } -} diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/service/search/DefaultUserRepositories.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/service/search/DefaultUserRepositories.scala new file mode 100644 index 00000000..6fa58ac9 --- /dev/null +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/service/search/DefaultUserRepositories.scala @@ -0,0 +1,48 @@ +/* + * Copyright 2023 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.loginsvc.rest.service.search + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Component +import za.co.absa.loginsvc.rest.config.auth.{ActiveDirectoryLDAPConfig, ConfigOrdering, UsersConfig} +import za.co.absa.loginsvc.rest.config.provider.AuthConfigProvider +import za.co.absa.loginsvc.rest.config.validation.ConfigValidationException + + +@Component +class DefaultUserRepositories @Autowired()(authConfigProvider: AuthConfigProvider) extends UserRepositories { + + private val usersConfig: Option[ConfigOrdering] = authConfigProvider.getUsersConfig + private val adLDAPConfig: Option[ConfigOrdering] = authConfigProvider.getLdapConfig + + private val configs: Seq[ConfigOrdering] = Seq(usersConfig, adLDAPConfig).flatten.filter(_.order != 0).sortBy(_.order) + + override val orderedProviders: Seq[UserRepository] = createUserRepositories(configs) + + if (orderedProviders.isEmpty) + throw ConfigValidationException("No authentication method enabled in config") + + private def createUserRepositories(configs: Seq[ConfigOrdering]): Seq[UserRepository] = { + Array.empty[UserRepository] ++ configs.filter(_.order > 0).sortBy(_.order) + .map { + case c: UsersConfig => new UsersFromConfigRepository(c) + case c: ActiveDirectoryLDAPConfig => new LdapUserRepository(c) + case other => throw new IllegalStateException(s"unsupported config $other") + } + } + +} diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/service/search/LdapSearchProvider.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/service/search/LdapUserRepository.scala similarity index 95% rename from api/src/main/scala/za/co/absa/loginsvc/rest/service/search/LdapSearchProvider.scala rename to api/src/main/scala/za/co/absa/loginsvc/rest/service/search/LdapUserRepository.scala index f7cdc967..84619b57 100644 --- a/api/src/main/scala/za/co/absa/loginsvc/rest/service/search/LdapSearchProvider.scala +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/service/search/LdapUserRepository.scala @@ -26,10 +26,10 @@ import javax.naming.directory.{Attributes, DirContext, SearchControls, SearchRes import javax.naming.ldap.{Control, InitialLdapContext, PagedResultsControl} import scala.collection.JavaConverters.enumerationAsScalaIteratorConverter -class LdapSearchProvider(activeDirectoryLDAPConfig: ActiveDirectoryLDAPConfig) - extends AuthSearchProvider { +class LdapUserRepository(activeDirectoryLDAPConfig: ActiveDirectoryLDAPConfig) + extends UserRepository { - private val logger = LoggerFactory.getLogger(classOf[LdapSearchProvider]) + private val logger = LoggerFactory.getLogger(classOf[LdapUserRepository]) def searchForUser(username: String): Option[User] = { logger.info(s"Searching for user in Ldap: $username") diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/service/search/UserRepositories.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/service/search/UserRepositories.scala new file mode 100644 index 00000000..8a1bcf5b --- /dev/null +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/service/search/UserRepositories.scala @@ -0,0 +1,22 @@ +/* + * Copyright 2023 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.loginsvc.rest.service.search + +trait UserRepositories { + + def orderedProviders: Seq[UserRepository] +} diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/service/search/AuthSearchProvider.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/service/search/UserRepository.scala similarity index 96% rename from api/src/main/scala/za/co/absa/loginsvc/rest/service/search/AuthSearchProvider.scala rename to api/src/main/scala/za/co/absa/loginsvc/rest/service/search/UserRepository.scala index d26a97cc..baeddcdb 100644 --- a/api/src/main/scala/za/co/absa/loginsvc/rest/service/search/AuthSearchProvider.scala +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/service/search/UserRepository.scala @@ -18,6 +18,6 @@ package za.co.absa.loginsvc.rest.service.search import za.co.absa.loginsvc.model.User -trait AuthSearchProvider { +trait UserRepository { def searchForUser(username: String): Option[User] } diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/service/search/UserSearchService.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/service/search/UserSearchService.scala new file mode 100644 index 00000000..66ac9b13 --- /dev/null +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/service/search/UserSearchService.scala @@ -0,0 +1,35 @@ +/* + * Copyright 2023 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.loginsvc.rest.service.search + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Service +import za.co.absa.loginsvc.model.User + +@Service +class UserSearchService @Autowired()(userRepositories: UserRepositories) { + def searchUser(username: String): User = { + val maybeFoundUser = userRepositories.orderedProviders.toIterator // this makes it lazy + .map(_.searchForUser(username)) // expensive operation done lazily + .dropWhile(_.isEmpty) // keeps searching until the possibly-found-user is not empty or all providers are exhausted + + if (maybeFoundUser.hasNext) { + maybeFoundUser.next().getOrElse(throw new IllegalStateException(s"Valid user $maybeFoundUser should be available")) + } else + throw new NoSuchElementException(s"No user found by username $username anywhere") + } +} diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/service/search/ConfigSearchProvider.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/service/search/UsersFromConfigRepository.scala similarity index 88% rename from api/src/main/scala/za/co/absa/loginsvc/rest/service/search/ConfigSearchProvider.scala rename to api/src/main/scala/za/co/absa/loginsvc/rest/service/search/UsersFromConfigRepository.scala index aa6cb200..d6fd0412 100644 --- a/api/src/main/scala/za/co/absa/loginsvc/rest/service/search/ConfigSearchProvider.scala +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/service/search/UsersFromConfigRepository.scala @@ -20,10 +20,10 @@ import org.slf4j.LoggerFactory import za.co.absa.loginsvc.model.User import za.co.absa.loginsvc.rest.config.auth.UsersConfig -class ConfigSearchProvider(usersConfig: UsersConfig) - extends AuthSearchProvider { +class UsersFromConfigRepository(usersConfig: UsersConfig) + extends UserRepository { - private val logger = LoggerFactory.getLogger(classOf[ConfigSearchProvider]) + private val logger = LoggerFactory.getLogger(classOf[UsersFromConfigRepository]) def searchForUser(username: String): Option[User] = { logger.info(s"Searching for user in config: $username") usersConfig.knownUsersMap.get(username).flatMap { userConfig => diff --git a/api/src/test/resources/application.yaml b/api/src/test/resources/application.yaml index 7286d54b..73d0d9b0 100644 --- a/api/src/test/resources/application.yaml +++ b/api/src/test/resources/application.yaml @@ -7,8 +7,6 @@ loginsvc: refresh-exp-time: 10h key-rotation-time: 5sec alg-name: "RS256" - config: - some-key: "BETA" # Rest Auth Config (AD) auth: diff --git a/api/src/test/scala/za/co/absa/loginsvc/rest/config/provider/ConfigProviderTest.scala b/api/src/test/scala/za/co/absa/loginsvc/rest/config/provider/ConfigProviderTest.scala index 74c6505f..d71ca17a 100644 --- a/api/src/test/scala/za/co/absa/loginsvc/rest/config/provider/ConfigProviderTest.scala +++ b/api/src/test/scala/za/co/absa/loginsvc/rest/config/provider/ConfigProviderTest.scala @@ -32,11 +32,6 @@ class ConfigProviderTest extends AnyFlatSpec with Matchers { private val configProvider : ConfigProvider = new ConfigProvider("api/src/test/resources/application.yaml") - "The baseConfig properties" should "Match" in { - val baseConfig: BaseConfig = configProvider.getBaseConfig - baseConfig.someKey shouldBe "BETA" - } - "The jwtConfig properties" should "Match" in { val keyConfig: KeyConfig = configProvider.getJwtKeyConfig keyConfig.algName shouldBe "RS256" diff --git a/api/src/test/scala/za/co/absa/loginsvc/rest/service/jwt/JWTServiceTest.scala b/api/src/test/scala/za/co/absa/loginsvc/rest/service/jwt/JWTServiceTest.scala index e1686de5..0c8559ef 100644 --- a/api/src/test/scala/za/co/absa/loginsvc/rest/service/jwt/JWTServiceTest.scala +++ b/api/src/test/scala/za/co/absa/loginsvc/rest/service/jwt/JWTServiceTest.scala @@ -26,7 +26,7 @@ import za.co.absa.loginsvc.model.User import za.co.absa.loginsvc.rest.config.jwt.{InMemoryKeyConfig, KeyConfig} import za.co.absa.loginsvc.rest.config.provider.{ConfigProvider, JwtConfigProvider} import za.co.absa.loginsvc.rest.model.{AccessToken, RefreshToken, Token} -import za.co.absa.loginsvc.rest.service.search.AuthSearchService +import za.co.absa.loginsvc.rest.service.search.{DefaultUserRepositories, UserSearchService} import java.security.PublicKey import java.util @@ -38,7 +38,7 @@ class JWTServiceTest extends AnyFlatSpec with BeforeAndAfterEach with Matchers { private val testConfig : ConfigProvider = new ConfigProvider("api/src/test/resources/application.yaml") private var jwtService: JWTService = _ - private var authSearchService: AuthSearchService = _ + private var authSearchService: UserSearchService = _ private val userWithoutEmailAndGroups: User = User( name = "user2", @@ -59,7 +59,7 @@ class JWTServiceTest extends AnyFlatSpec with BeforeAndAfterEach with Matchers { } override def beforeEach(): Unit = { - authSearchService = new AuthSearchService(testConfig) + authSearchService = new UserSearchService(new DefaultUserRepositories(testConfig)) jwtService = new JWTService(testConfig, authSearchService) } diff --git a/api/src/test/scala/za/co/absa/loginsvc/rest/service/search/AuthSearchServiceTest.scala b/api/src/test/scala/za/co/absa/loginsvc/rest/service/search/AuthSearchServiceTest.scala deleted file mode 100644 index d639f921..00000000 --- a/api/src/test/scala/za/co/absa/loginsvc/rest/service/search/AuthSearchServiceTest.scala +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright 2023 ABSA Group Limited - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package za.co.absa.loginsvc.rest.service.search - -import org.scalatest.BeforeAndAfterEach -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.should.Matchers -import za.co.absa.loginsvc.model.User -import za.co.absa.loginsvc.rest.config.auth._ -import za.co.absa.loginsvc.rest.config.provider.{AuthConfigProvider, ConfigProvider} -import za.co.absa.loginsvc.rest.config.validation.ConfigValidationException - -class AuthSearchServiceTest extends AnyFlatSpec with BeforeAndAfterEach with Matchers { - - private val testConfig: ConfigProvider = new ConfigProvider("api/src/test/resources/application.yaml") - private val emptyServiceAccount = ServiceAccountConfig("", Option(LdapUserCredentialsConfig("", "")), None) - - private val authConfigProvider: AuthConfigProvider = new AuthConfigProvider { - override def getLdapConfig: Option[ActiveDirectoryLDAPConfig] = Option(ActiveDirectoryLDAPConfig("", "", "", 0, emptyServiceAccount, None)) - override def getUsersConfig: Option[UsersConfig] = testConfig.getUsersConfig - } - private val authSearchService: AuthSearchService = new AuthSearchService(authConfigProvider) - - private val user: User = User( - name = "user2", - groups = Seq("group2"), - optionalAttributes = Map("mail" -> Some("user@two.org")) - ) - - it should "return a matching user" in { - val result = authSearchService.searchUser(user.name) - result.name shouldBe user.name - result.optionalAttributes.get("mail") shouldBe user.optionalAttributes.get("mail") - result.groups shouldBe user.groups - } - - it should "fail if user doesn't exist" in { - an [NoSuchElementException] should be thrownBy { - authSearchService.searchUser("nonexistent") - } - } - - it should "fail if no auth config is provided" in { - val emptyAuthConfigProvider: AuthConfigProvider = new AuthConfigProvider { - override def getLdapConfig: Option[ActiveDirectoryLDAPConfig] = Option(ActiveDirectoryLDAPConfig("", "", "", 0, emptyServiceAccount, None)) - override def getUsersConfig: Option[UsersConfig] = Option(UsersConfig(Array.empty[UserConfig], 0)) - } - - an [ConfigValidationException] should be thrownBy { - new AuthSearchService(emptyAuthConfigProvider) - } - } -} diff --git a/api/src/test/scala/za/co/absa/loginsvc/rest/service/search/DefaultUserRepositoriesTest.scala b/api/src/test/scala/za/co/absa/loginsvc/rest/service/search/DefaultUserRepositoriesTest.scala new file mode 100644 index 00000000..5d507c87 --- /dev/null +++ b/api/src/test/scala/za/co/absa/loginsvc/rest/service/search/DefaultUserRepositoriesTest.scala @@ -0,0 +1,107 @@ +/* + * Copyright 2023 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.loginsvc.rest.service.search + +import org.scalamock.scalatest.MockFactory +import org.scalatest.BeforeAndAfterEach +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.funsuite.AnyFunSuiteLike +import org.scalatest.matchers.should.Matchers +import za.co.absa.loginsvc.rest.config.auth.{ActiveDirectoryLDAPConfig, LdapUserCredentialsConfig, ServiceAccountConfig, UserConfig, UsersConfig} +import za.co.absa.loginsvc.rest.config.provider.{AuthConfigProvider, ConfigProvider} +import za.co.absa.loginsvc.rest.config.validation.ConfigValidationException + +class DefaultUserRepositoriesTest extends AnyFlatSpec with BeforeAndAfterEach with Matchers with MockFactory { + + private val testConfig: ConfigProvider = new ConfigProvider("api/src/test/resources/application.yaml") + private val emptyServiceAccount = ServiceAccountConfig("", Option(LdapUserCredentialsConfig("", "")), None) + + private val enabledLdapTestConfig = Some(ActiveDirectoryLDAPConfig("", "", "", order = 2, emptyServiceAccount, None)) + private val enabledUsersConfig = testConfig.getUsersConfig // has order = 1 + + private val disabledLdapTestConfig = Some(ActiveDirectoryLDAPConfig("", "", "", order = 0, emptyServiceAccount, None)) + private val disabledUsersConfig = Some(UsersConfig(Array.empty[UserConfig], order = 0)) + + private def createAuthConfigProviderUsing(optLdapConfig: Option[ActiveDirectoryLDAPConfig], optUsersConfig: Option[UsersConfig]): AuthConfigProvider = { + new AuthConfigProvider { + override def getLdapConfig: Option[ActiveDirectoryLDAPConfig] = optLdapConfig + override def getUsersConfig: Option[UsersConfig] = optUsersConfig + } + } + + behavior of "DefaultUserRepositories (constructor)" + + it should "fail if no auth config is provided" in { + val emptyAuthConfigProvider = createAuthConfigProviderUsing(None, None) + + the [ConfigValidationException] thrownBy { + new DefaultUserRepositories(emptyAuthConfigProvider) + } should have message "No authentication method enabled in config" + } + + it should "fail if no enabled (order !=0) auth config is provided" in { + val noEnabledAuthConfigProvider = createAuthConfigProviderUsing(disabledLdapTestConfig, disabledUsersConfig) + + the [ConfigValidationException] thrownBy { + new DefaultUserRepositories(noEnabledAuthConfigProvider) + } should have message "No authentication method enabled in config" + } + + behavior of "DefaultUserRepositories.orderedProviders" + + it should "correctly create userRepositories object with both ordered configs" in { + val emptyAuthConfigProvider = createAuthConfigProviderUsing(enabledLdapTestConfig, enabledUsersConfig) + + val userRepositories = new DefaultUserRepositories(emptyAuthConfigProvider) + userRepositories.orderedProviders.map(_.getClass) shouldBe Seq(classOf[UsersFromConfigRepository], classOf[LdapUserRepository]) // order 1, order 2 + } + + it should "correctly create userRepositories object with both reordered configs" in { + val emptyAuthConfigProvider = createAuthConfigProviderUsing(enabledLdapTestConfig, enabledUsersConfig.map(_.copy(order = 3))) + + val userRepositories = new DefaultUserRepositories(emptyAuthConfigProvider) + userRepositories.orderedProviders.map(_.getClass) shouldBe Seq(classOf[LdapUserRepository], classOf[UsersFromConfigRepository]) // order 2, order 3 + } + + it should "correctly create userRepositories object with one enabled config only" in { + val emptyAuthConfigProvider = createAuthConfigProviderUsing(enabledLdapTestConfig, disabledUsersConfig) + + val userRepositories = new DefaultUserRepositories(emptyAuthConfigProvider) + userRepositories.orderedProviders.map(_.getClass) shouldBe Seq(classOf[LdapUserRepository]) + } + + it should "correctly create userRepositories object with one enabled config only 2" in { + val emptyAuthConfigProvider = createAuthConfigProviderUsing(disabledLdapTestConfig, enabledUsersConfig) + + val userRepositories = new DefaultUserRepositories(emptyAuthConfigProvider) + userRepositories.orderedProviders.map(_.getClass) shouldBe Seq(classOf[UsersFromConfigRepository]) + } + + it should "correctly create userRepositories object with one present config only" in { + val emptyAuthConfigProvider = createAuthConfigProviderUsing(enabledLdapTestConfig, None) + + val userRepositories = new DefaultUserRepositories(emptyAuthConfigProvider) + userRepositories.orderedProviders.map(_.getClass) shouldBe Seq(classOf[LdapUserRepository]) + } + + it should "correctly create userRepositories object with one present config only 2" in { + val emptyAuthConfigProvider = createAuthConfigProviderUsing(None, enabledUsersConfig) + + val userRepositories = new DefaultUserRepositories(emptyAuthConfigProvider) + userRepositories.orderedProviders.map(_.getClass) shouldBe Seq(classOf[UsersFromConfigRepository]) + } +} diff --git a/api/src/test/scala/za/co/absa/loginsvc/rest/service/search/UserSearchServiceTest.scala b/api/src/test/scala/za/co/absa/loginsvc/rest/service/search/UserSearchServiceTest.scala new file mode 100644 index 00000000..d3a675e8 --- /dev/null +++ b/api/src/test/scala/za/co/absa/loginsvc/rest/service/search/UserSearchServiceTest.scala @@ -0,0 +1,138 @@ +/* + * Copyright 2023 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.loginsvc.rest.service.search + +import org.scalamock.scalatest.MockFactory +import org.scalatest.BeforeAndAfterEach +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import za.co.absa.loginsvc.model.User + +class UserSearchServiceTest extends AnyFlatSpec with BeforeAndAfterEach with Matchers with MockFactory { + + private case class Mocks(userRepositories: UserRepositories) + + private def createAuthServiceWithMocks: (UserSearchService, Mocks) = { + val userRepositories = mock[UserRepositories] + + val userSearchService: UserSearchService = new UserSearchService(userRepositories) + + (userSearchService, Mocks(userRepositories)) + } + + private val user: User = User( + name = "user2", + groups = Seq("group2"), + optionalAttributes = Map("mail" -> Some("user@two.org")) + ) + + def createDefinedUserRepository: UserRepository = { + val definedUserRepository = mock[UserRepository] + (definedUserRepository.searchForUser _) + .expects("user2") // once set up it must be used + .returns(Some(user)) + + definedUserRepository + } + + def createEmptyUserRepository: UserRepository = { + val emptyUserRepository = mock[UserRepository] + (emptyUserRepository.searchForUser _) // once set up it must be used + .expects("user2") + .returns(None) + + emptyUserRepository + } + + private object testUserRepoException extends Throwable + private val throwingUserRepository: UserRepository = new UserRepository { + override def searchForUser(username: String): Option[User] = throw testUserRepoException + } + + it should "return a matching user (single repo)" in { + val (userSearchService, mocks) = createAuthServiceWithMocks + (mocks.userRepositories.orderedProviders _) + .expects() + .returns(Seq( + createDefinedUserRepository // proves that repo was used 1x + )) + + val result = userSearchService.searchUser(user.name) + + result.name shouldBe user.name + result.optionalAttributes.get("mail") shouldBe user.optionalAttributes.get("mail") + result.groups shouldBe user.groups + } + + it should "return user form the first successful service (multi repo)" in { + val (userSearchService, mocks) = createAuthServiceWithMocks + + (mocks.userRepositories.orderedProviders _) + .expects() + .returns(Seq( + createDefinedUserRepository, + throwingUserRepository // proves short-circuiting: not reached + )) + + val result = userSearchService.searchUser(user.name) + result shouldBe user + } + + it should "return user form the first successful service (multi repo) 2 " in { + val (userSearchService, mocks) = createAuthServiceWithMocks + + (mocks.userRepositories.orderedProviders _) + .expects() + .returns(Seq( + createEmptyUserRepository, // proves that repo was used 1x + createDefinedUserRepository, // proves that repo was used 1x + throwingUserRepository // proves short-circuiting: not reached + )) + + val result = userSearchService.searchUser(user.name) + result shouldBe user + } + + it should "fail if user doesn't exist (single repo)" in { + val (userSearchService, mocks) = createAuthServiceWithMocks + (mocks.userRepositories.orderedProviders _) + .expects() + .returns(Seq(createEmptyUserRepository)) + + an[NoSuchElementException] should be thrownBy { + userSearchService.searchUser(user.name) + } + } + + it should "fail if user doesn't exist (multi repo)" in { + val (userSearchService, mocks) = createAuthServiceWithMocks + + (mocks.userRepositories.orderedProviders _) + .expects() + .returns(Seq( + createEmptyUserRepository, + createEmptyUserRepository, + createEmptyUserRepository + )) + + an[NoSuchElementException] should be thrownBy { + userSearchService.searchUser(user.name) + } + + } + +} diff --git a/api/src/test/scala/za/co/absa/loginsvc/rest/service/search/ConfigSearchProviderTest.scala b/api/src/test/scala/za/co/absa/loginsvc/rest/service/search/UsersFromConfigRepositoryTest.scala similarity index 89% rename from api/src/test/scala/za/co/absa/loginsvc/rest/service/search/ConfigSearchProviderTest.scala rename to api/src/test/scala/za/co/absa/loginsvc/rest/service/search/UsersFromConfigRepositoryTest.scala index 08d3e378..d0427de9 100644 --- a/api/src/test/scala/za/co/absa/loginsvc/rest/service/search/ConfigSearchProviderTest.scala +++ b/api/src/test/scala/za/co/absa/loginsvc/rest/service/search/UsersFromConfigRepositoryTest.scala @@ -23,12 +23,12 @@ import za.co.absa.loginsvc.model.User import za.co.absa.loginsvc.rest.config.auth.UsersConfig import za.co.absa.loginsvc.rest.config.provider.ConfigProvider -class ConfigSearchProviderTest extends AnyFlatSpec with BeforeAndAfterEach with Matchers { +class UsersFromConfigRepositoryTest extends AnyFlatSpec with BeforeAndAfterEach with Matchers { private val testConfig: ConfigProvider = new ConfigProvider("api/src/test/resources/application.yaml") private val usersConfig: UsersConfig = testConfig.getUsersConfig.get - private val configSearchProvider: ConfigSearchProvider = new ConfigSearchProvider(usersConfig) + private val configSearchProvider: UsersFromConfigRepository = new UsersFromConfigRepository(usersConfig) private val user: User = User( name = "user2", diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 99cd2093..094484e2 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -72,6 +72,8 @@ object Dependencies { lazy val springBootTest = "org.springframework.boot" % "spring-boot-starter-test" % Versions.springBoot % Test lazy val springBootSecurityTest = "org.springframework.security" % "spring-security-test" % Versions.spring % Test + lazy val scalaMockTest = "org.scalamock" %% "scalamock" % "5.2.0" % Test + def apiDependencies: Seq[ModuleID] = Seq( jacksonModuleScala, jacksonDatabind, @@ -101,7 +103,9 @@ object Dependencies { scalaTest, springBootTest, - springBootSecurityTest + springBootSecurityTest, + + scalaMockTest ) def clientLibDependencies: Seq[ModuleID] = Seq(