From 69853a57c882af587ab9c7c41b5870a38a9cadbe Mon Sep 17 00:00:00 2001 From: Darren Walker Date: Tue, 18 Sep 2018 11:16:11 +0100 Subject: [PATCH] APIS-3756 Code Migration --- LICENSE | 1 + README.md | 27 +- app/Module.scala | 33 + app/uk/gov/hmrc/config/AppContext.scala | 74 + .../gov/hmrc/config/ApplicationGlobal.scala | 108 ++ .../connector/APIDefinitionConnector.scala | 39 + .../ApiSubscriptionFieldsConnector.scala | 38 + app/uk/gov/hmrc/connector/AuthConnector.scala | 44 + .../gov/hmrc/connector/EmailConnector.scala | 131 ++ app/uk/gov/hmrc/connector/HttpConnector.scala | 23 + app/uk/gov/hmrc/connector/TOTPConnector.scala | 44 + ...hirdPartyDelegatedAuthorityConnector.scala | 36 + .../connector/WSO2APIStoreConnector.scala | 312 ++++ .../hmrc/controllers/AccessController.scala | 61 + .../controllers/ApplicationController.scala | 289 ++++ .../controllers/AuthorisationWrapper.scala | 108 ++ .../hmrc/controllers/CommonController.scala | 58 + .../controllers/DocumentationController.scala | 47 + .../controllers/GatekeeperController.scala | 85 + app/uk/gov/hmrc/controllers/Model.scala | 79 + .../controllers/WSO2RestoreController.scala | 30 + app/uk/gov/hmrc/models/APIDefinition.scala | 70 + app/uk/gov/hmrc/models/Application.scala | 432 +++++ app/uk/gov/hmrc/models/Authorisation.scala | 26 + app/uk/gov/hmrc/models/Developer.scala | 30 + app/uk/gov/hmrc/models/Errors.scala | 41 + app/uk/gov/hmrc/models/HasSucceeded.scala | 24 + app/uk/gov/hmrc/models/JsonFormatters.scala | 231 +++ app/uk/gov/hmrc/models/StateHistory.scala | 65 + app/uk/gov/hmrc/models/TOTP.scala | 25 + .../repository/ApplicationRepository.scala | 219 +++ .../repository/StateHistoryRepository.scala | 81 + .../repository/SubscriptionRepository.scala | 97 ++ app/uk/gov/hmrc/scheduled/JobConfig.scala | 62 + .../RefreshSubscriptionsScheduledJob.scala | 65 + app/uk/gov/hmrc/scheduled/Retrying.scala | 38 + .../UpliftVerificationExpiryJob.scala | 78 + app/uk/gov/hmrc/services/AccessService.scala | 109 ++ .../hmrc/services/ApplicationService.scala | 505 ++++++ app/uk/gov/hmrc/services/AuditService.scala | 261 +++ .../gov/hmrc/services/CredentialService.scala | 127 ++ app/uk/gov/hmrc/services/DataUtil.scala | 39 + .../gov/hmrc/services/GatekeeperService.scala | 224 +++ .../hmrc/services/SubscriptionService.scala | 161 ++ app/uk/gov/hmrc/services/WSO2APIStore.scala | 255 +++ .../hmrc/services/WSO2RestoreService.scala | 135 ++ .../gov/hmrc/util/CredentialGenerator.scala | 23 + app/uk/gov/hmrc/util/http/HttpHeaders.scala | 26 + app/uk/gov/hmrc/util/mongo/IndexHelper.scala | 33 + app/uk/gov/hmrc/views/application.scala.txt | 98 ++ app/uk/gov/hmrc/views/definition.scala.txt | 20 + build.sbt | 92 ++ conf/app.routes | 49 + conf/application-json-logger.xml | 13 + conf/application.conf | 300 ++++ conf/definition.routes | 2 + conf/logback.xml | 58 + conf/prod.routes | 5 + conf/testOnlyDoNotUseInAppConf.routes | 13 + dependencyReport.py | 69 + export-versions-for-it-tests | 1 + project/build.properties | 1 + project/plugins.sbt | 15 + repository.yaml | 1 + run_all_tests.sh | 4 + run_local.sh | 3 + scalastyle-config.xml | 99 ++ .../uk/gov/hmrc/common/LogSuppressing.scala | 77 + .../hmrc/testutils/ApplicationStateUtil.scala | 42 + .../uk/gov/hmrc/PlatformIntegrationSpec.scala | 122 ++ .../gov/hmrc/component/BaseFeatureSpec.scala | 65 + .../ThirdPartyApplicationComponentSpec.scala | 571 +++++++ .../component/stubs/ApiDefinitionStub.scala | 41 + .../stubs/ApiSubscriptionFieldsStub.scala | 31 + .../gov/hmrc/component/stubs/AuthStub.scala | 33 + .../gov/hmrc/component/stubs/TOTPStub.scala | 77 + .../ThirdPartyDelegatedAuthorityStub.scala | 30 + .../stubs/ThirdPartyDeveloperStub.scala | 41 + .../hmrc/component/stubs/WSO2StoreStub.scala | 158 ++ .../ApplicationRepositorySpec.scala | 495 ++++++ .../StateHistoryRepositorySpec.scala | 154 ++ .../SubscriptionRepositorySpec.scala | 182 +++ test/it/uk/gov/hmrc/repository/package.scala | 37 + test/resources/test-apps.json | 38 + .../APIDefinitionConnectorSpec.scala | 214 +++ .../ApiSubscriptionFieldsConnectorSpec.scala | 89 ++ test/unit/connector/AuthConnectorSpec.scala | 85 + test/unit/connector/BaseConnectorSpec.scala | 41 + test/unit/connector/EmailConnectorSpec.scala | 234 +++ test/unit/connector/TOTPConnectorSpec.scala | 101 ++ .../connector/WSO2APIStoreConnectorSpec.scala | 523 ++++++ test/unit/connector/WiremockSugar.scala | 41 + .../controllers/AccessControllerSpec.scala | 217 +++ .../ApplicationControllerSpec.scala | 1408 +++++++++++++++++ .../AuthorisationWrapperSpec.scala | 207 +++ .../GatekeeperControllerSpec.scala | 357 +++++ test/unit/models/ApplicationSpec.scala | 119 ++ test/unit/models/ApplicationStateSpec.scala | 115 ++ test/unit/models/StateHistorySpec.scala | 41 + ...RefreshSubscriptionsScheduledJobSpec.scala | 106 ++ test/unit/scheduled/RetryingSpec.scala | 79 + .../UpliftVerificationExpiryJobSpec.scala | 168 ++ test/unit/services/AccessServiceSpec.scala | 226 +++ .../services/ApplicationServiceSpec.scala | 1210 ++++++++++++++ test/unit/services/AuditServiceSpec.scala | 187 +++ .../unit/services/CredentialServiceSpec.scala | 368 +++++ test/unit/services/DataUtilSpec.scala | 47 + .../unit/services/GatekeeperServiceSpec.scala | 467 ++++++ .../services/SubscriptionServiceSpec.scala | 415 +++++ test/unit/services/WSO2APIStoreSpec.scala | 315 ++++ 110 files changed, 15561 insertions(+), 5 deletions(-) create mode 100644 app/Module.scala create mode 100644 app/uk/gov/hmrc/config/AppContext.scala create mode 100644 app/uk/gov/hmrc/config/ApplicationGlobal.scala create mode 100644 app/uk/gov/hmrc/connector/APIDefinitionConnector.scala create mode 100644 app/uk/gov/hmrc/connector/ApiSubscriptionFieldsConnector.scala create mode 100644 app/uk/gov/hmrc/connector/AuthConnector.scala create mode 100644 app/uk/gov/hmrc/connector/EmailConnector.scala create mode 100644 app/uk/gov/hmrc/connector/HttpConnector.scala create mode 100644 app/uk/gov/hmrc/connector/TOTPConnector.scala create mode 100644 app/uk/gov/hmrc/connector/ThirdPartyDelegatedAuthorityConnector.scala create mode 100644 app/uk/gov/hmrc/connector/WSO2APIStoreConnector.scala create mode 100644 app/uk/gov/hmrc/controllers/AccessController.scala create mode 100644 app/uk/gov/hmrc/controllers/ApplicationController.scala create mode 100644 app/uk/gov/hmrc/controllers/AuthorisationWrapper.scala create mode 100644 app/uk/gov/hmrc/controllers/CommonController.scala create mode 100644 app/uk/gov/hmrc/controllers/DocumentationController.scala create mode 100644 app/uk/gov/hmrc/controllers/GatekeeperController.scala create mode 100644 app/uk/gov/hmrc/controllers/Model.scala create mode 100644 app/uk/gov/hmrc/controllers/WSO2RestoreController.scala create mode 100644 app/uk/gov/hmrc/models/APIDefinition.scala create mode 100644 app/uk/gov/hmrc/models/Application.scala create mode 100644 app/uk/gov/hmrc/models/Authorisation.scala create mode 100644 app/uk/gov/hmrc/models/Developer.scala create mode 100644 app/uk/gov/hmrc/models/Errors.scala create mode 100644 app/uk/gov/hmrc/models/HasSucceeded.scala create mode 100644 app/uk/gov/hmrc/models/JsonFormatters.scala create mode 100644 app/uk/gov/hmrc/models/StateHistory.scala create mode 100644 app/uk/gov/hmrc/models/TOTP.scala create mode 100644 app/uk/gov/hmrc/repository/ApplicationRepository.scala create mode 100644 app/uk/gov/hmrc/repository/StateHistoryRepository.scala create mode 100644 app/uk/gov/hmrc/repository/SubscriptionRepository.scala create mode 100644 app/uk/gov/hmrc/scheduled/JobConfig.scala create mode 100644 app/uk/gov/hmrc/scheduled/RefreshSubscriptionsScheduledJob.scala create mode 100644 app/uk/gov/hmrc/scheduled/Retrying.scala create mode 100644 app/uk/gov/hmrc/scheduled/UpliftVerificationExpiryJob.scala create mode 100644 app/uk/gov/hmrc/services/AccessService.scala create mode 100644 app/uk/gov/hmrc/services/ApplicationService.scala create mode 100644 app/uk/gov/hmrc/services/AuditService.scala create mode 100644 app/uk/gov/hmrc/services/CredentialService.scala create mode 100644 app/uk/gov/hmrc/services/DataUtil.scala create mode 100644 app/uk/gov/hmrc/services/GatekeeperService.scala create mode 100644 app/uk/gov/hmrc/services/SubscriptionService.scala create mode 100644 app/uk/gov/hmrc/services/WSO2APIStore.scala create mode 100644 app/uk/gov/hmrc/services/WSO2RestoreService.scala create mode 100644 app/uk/gov/hmrc/util/CredentialGenerator.scala create mode 100644 app/uk/gov/hmrc/util/http/HttpHeaders.scala create mode 100644 app/uk/gov/hmrc/util/mongo/IndexHelper.scala create mode 100644 app/uk/gov/hmrc/views/application.scala.txt create mode 100644 app/uk/gov/hmrc/views/definition.scala.txt create mode 100644 build.sbt create mode 100644 conf/app.routes create mode 100644 conf/application-json-logger.xml create mode 100644 conf/application.conf create mode 100644 conf/definition.routes create mode 100644 conf/logback.xml create mode 100644 conf/prod.routes create mode 100644 conf/testOnlyDoNotUseInAppConf.routes create mode 100644 dependencyReport.py create mode 100644 export-versions-for-it-tests create mode 100644 project/build.properties create mode 100644 project/plugins.sbt create mode 100644 repository.yaml create mode 100755 run_all_tests.sh create mode 100755 run_local.sh create mode 100644 scalastyle-config.xml create mode 100644 test/common/uk/gov/hmrc/common/LogSuppressing.scala create mode 100644 test/common/uk/gov/hmrc/testutils/ApplicationStateUtil.scala create mode 100644 test/it/uk/gov/hmrc/PlatformIntegrationSpec.scala create mode 100644 test/it/uk/gov/hmrc/component/BaseFeatureSpec.scala create mode 100644 test/it/uk/gov/hmrc/component/ThirdPartyApplicationComponentSpec.scala create mode 100644 test/it/uk/gov/hmrc/component/stubs/ApiDefinitionStub.scala create mode 100644 test/it/uk/gov/hmrc/component/stubs/ApiSubscriptionFieldsStub.scala create mode 100644 test/it/uk/gov/hmrc/component/stubs/AuthStub.scala create mode 100644 test/it/uk/gov/hmrc/component/stubs/TOTPStub.scala create mode 100644 test/it/uk/gov/hmrc/component/stubs/ThirdPartyDelegatedAuthorityStub.scala create mode 100644 test/it/uk/gov/hmrc/component/stubs/ThirdPartyDeveloperStub.scala create mode 100644 test/it/uk/gov/hmrc/component/stubs/WSO2StoreStub.scala create mode 100644 test/it/uk/gov/hmrc/repository/ApplicationRepositorySpec.scala create mode 100644 test/it/uk/gov/hmrc/repository/StateHistoryRepositorySpec.scala create mode 100644 test/it/uk/gov/hmrc/repository/SubscriptionRepositorySpec.scala create mode 100644 test/it/uk/gov/hmrc/repository/package.scala create mode 100644 test/resources/test-apps.json create mode 100644 test/unit/connector/APIDefinitionConnectorSpec.scala create mode 100644 test/unit/connector/ApiSubscriptionFieldsConnectorSpec.scala create mode 100644 test/unit/connector/AuthConnectorSpec.scala create mode 100644 test/unit/connector/BaseConnectorSpec.scala create mode 100644 test/unit/connector/EmailConnectorSpec.scala create mode 100644 test/unit/connector/TOTPConnectorSpec.scala create mode 100644 test/unit/connector/WSO2APIStoreConnectorSpec.scala create mode 100644 test/unit/connector/WiremockSugar.scala create mode 100644 test/unit/controllers/AccessControllerSpec.scala create mode 100644 test/unit/controllers/ApplicationControllerSpec.scala create mode 100644 test/unit/controllers/AuthorisationWrapperSpec.scala create mode 100644 test/unit/controllers/GatekeeperControllerSpec.scala create mode 100644 test/unit/models/ApplicationSpec.scala create mode 100644 test/unit/models/ApplicationStateSpec.scala create mode 100644 test/unit/models/StateHistorySpec.scala create mode 100644 test/unit/scheduled/RefreshSubscriptionsScheduledJobSpec.scala create mode 100644 test/unit/scheduled/RetryingSpec.scala create mode 100644 test/unit/scheduled/UpliftVerificationExpiryJobSpec.scala create mode 100644 test/unit/services/AccessServiceSpec.scala create mode 100644 test/unit/services/ApplicationServiceSpec.scala create mode 100644 test/unit/services/AuditServiceSpec.scala create mode 100644 test/unit/services/CredentialServiceSpec.scala create mode 100644 test/unit/services/DataUtilSpec.scala create mode 100644 test/unit/services/GatekeeperServiceSpec.scala create mode 100644 test/unit/services/SubscriptionServiceSpec.scala create mode 100644 test/unit/services/WSO2APIStoreSpec.scala diff --git a/LICENSE b/LICENSE index 261eeb9e9..d64569567 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,4 @@ + Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ diff --git a/README.md b/README.md index c3768a786..f9b9eb025 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,27 @@ +Third Party Application +============= -# third-party-application +The Third Party Application microservice is responsible for maintaining the state of applications created +by (or on behalf of) third parties to consume APIs registered on the API Platform. - [ ![Download](https://api.bintray.com/packages/hmrc/releases/third-party-application/images/download.svg) ](https://bintray.com/hmrc/releases/third-party-application/_latestVersion) +# Tests +Some tests require `MongoDB` to run. +Thus, remember to start up MongoDB if you want to run the tests locally. +The tests include unit tests and integration tests. +In order to run them, use this command line: -This is a placeholder README.md for a new repository +``` +./run_all_tests.sh +``` -### License +A report will also be generated identifying any dependencies that need upgrading. This requires that +you have defined CATALOGUE_DEPENDENCIES_URL as an environment variable pointing to the dependencies +endpoint on the Tax Platform Catalogue's API. -This code is open source software licensed under the [Apache 2.0 License]("http://www.apache.org/licenses/LICENSE-2.0.html"). +#Current Known Issues +In some use cases, specifically if this microservice is running locally there may be a problem with Wso2. +This can be resolved be entering application.conf and changing: + +``` +Dev.skipWso2 to true. +``` diff --git a/app/Module.scala b/app/Module.scala new file mode 100644 index 000000000..4eda348e0 --- /dev/null +++ b/app/Module.scala @@ -0,0 +1,33 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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. + */ + +import com.google.inject.AbstractModule +import com.typesafe.config.{Config, ConfigFactory} +import play.api.{Configuration, Environment} +import uk.gov.hmrc.config.MicroserviceAuditConnector +import uk.gov.hmrc.play.audit.http.connector.AuditConnector +import uk.gov.hmrc.services.{RealWSO2APIStore, StubAPIStore, WSO2APIStore} + +class Module(environment: Environment, configuration: Configuration) extends AbstractModule { + + override def configure = { + bind(classOf[Config]).toInstance(ConfigFactory.load()) + bind(classOf[AuditConnector]).toInstance(MicroserviceAuditConnector) + val skipWso2 = configuration.getBoolean("skipWso2").getOrElse(false) + if (skipWso2) bind(classOf[WSO2APIStore]).toInstance(StubAPIStore) + else bind(classOf[WSO2APIStore]).to(classOf[RealWSO2APIStore]) + } +} diff --git a/app/uk/gov/hmrc/config/AppContext.scala b/app/uk/gov/hmrc/config/AppContext.scala new file mode 100644 index 000000000..4afe83a60 --- /dev/null +++ b/app/uk/gov/hmrc/config/AppContext.scala @@ -0,0 +1,74 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.config + +import java.util.concurrent.TimeUnit._ +import javax.inject.Inject + +import com.typesafe.config.Config +import net.ceedubs.ficus.Ficus._ +import net.ceedubs.ficus.readers.ArbitraryTypeReader._ +import uk.gov.hmrc.models.ApplicationData +import uk.gov.hmrc.play.config.ServicesConfig +import uk.gov.hmrc.scheduled.JobConfig + +import scala.concurrent.duration.{Duration, FiniteDuration} + +class AppContext @Inject()(val config: Config) extends ServicesConfig { + + lazy val devHubBaseUrl = getConfig(s"$env.devHubBaseUrl") + lazy val skipWso2: Boolean = getConfig(s"$env.skipWso2", runModeConfiguration.getBoolean) + lazy val clientSecretLimit: Int = getConfig(s"clientSecretLimit", runModeConfiguration.getInt) + lazy val trustedApplications: Seq[String] = getConfig(s"$env.trustedApplications", runModeConfiguration.getStringSeq) + + lazy val upliftVerificationValidity: FiniteDuration = config.as[Option[FiniteDuration]]("upliftVerificationValidity") + .getOrElse(Duration(90, DAYS)) // scalastyle:off magic.number + lazy val upliftVerificationExpiryJobConfig = config.as[Option[JobConfig]](s"$env.upliftVerificationExpiryJob") + .getOrElse(JobConfig(FiniteDuration(60, SECONDS), FiniteDuration(24, HOURS), enabled = true)) // scalastyle:off magic.number + + lazy val refreshSubscriptionsJobConfig = config.as[Option[JobConfig]](s"$env.refreshSubscriptionsJob") + .getOrElse(JobConfig(FiniteDuration(120, SECONDS), FiniteDuration(60, DAYS), enabled = true)) // scalastyle:off magic.number + + lazy val devHubTitle: String = "Developer Hub" + + lazy val fetchApplicationTtlInSecs : Int = getConfig("fetchApplicationTtlInSeconds", runModeConfiguration.getInt) + lazy val fetchSubscriptionTtlInSecs : Int = getConfig("fetchSubscriptionTtlInSeconds", runModeConfiguration.getInt) + + lazy val publishApiDefinition = runModeConfiguration.getBoolean("publishApiDefinition").getOrElse(false) + lazy val apiContext = runModeConfiguration.getString("api.context").getOrElse("third-party-application") + lazy val access = runModeConfiguration.getConfig(s"api.access") + + override def toString() = { + "AppContext{" + ( + Seq(s"environment=$env",s"skipWso2=$skipWso2", + s"clientSecretLimit=$clientSecretLimit", + s"upliftVerificationValidity=$upliftVerificationValidity", + s"upliftVerificationExpiryJobConfig=$upliftVerificationExpiryJobConfig", + s"trustedApplications=$trustedApplications", + s"fetchApplicationTtlInSecs=$fetchApplicationTtlInSecs", + s"fetchSubscriptionTtlInSecs=$fetchSubscriptionTtlInSecs" + ) mkString ",") + "}" + } + + private def getConfig(key: String) = runModeConfiguration.getString(key) + .getOrElse(throw new RuntimeException(s"[$key] is not configured!")) + + private def getConfig[T](key: String, block: String => Option[T]) = block(key) + .getOrElse(throw new RuntimeException(s"[$key] is not configured!")) + + def isTrusted(application: ApplicationData): Boolean = trustedApplications.contains(application.id.toString) +} diff --git a/app/uk/gov/hmrc/config/ApplicationGlobal.scala b/app/uk/gov/hmrc/config/ApplicationGlobal.scala new file mode 100644 index 000000000..544de70cf --- /dev/null +++ b/app/uk/gov/hmrc/config/ApplicationGlobal.scala @@ -0,0 +1,108 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.config + +import com.typesafe.config.Config +import play.api._ +import play.api.mvc.EssentialFilter +import uk.gov.hmrc.api.config.{ServiceLocatorConfig, ServiceLocatorRegistration} +import uk.gov.hmrc.api.connector.ServiceLocatorConnector +import uk.gov.hmrc.http._ +import uk.gov.hmrc.http.hooks.HttpHooks +import uk.gov.hmrc.play.audit.http.HttpAuditing +import uk.gov.hmrc.play.audit.http.connector.AuditConnector +import uk.gov.hmrc.play.config.{AppName, ControllerConfig, RunMode} +import uk.gov.hmrc.play.http.ws._ +import uk.gov.hmrc.play.microservice.bootstrap.DefaultMicroserviceGlobal +import uk.gov.hmrc.play.microservice.config.LoadAuditingConfig +import uk.gov.hmrc.play.microservice.filters._ +import uk.gov.hmrc.play.scheduling.{ExclusiveScheduledJob, RunningOfScheduledJobs, ScheduledJob} +import uk.gov.hmrc.scheduled.{RefreshSubscriptionsScheduledJob, UpliftVerificationExpiryJob} + +object ApplicationGlobal extends DefaultMicroserviceGlobal with RunMode with RunningOfScheduledJobs + with ServiceLocatorRegistration with ServiceLocatorConfig { + + lazy val injector = Play.current.injector + lazy val appContext = injector.instanceOf[AppContext] + + + override def loggingFilter: LoggingFilter = MicroserviceLoggingFilter + + override def microserviceAuditFilter: AuditFilter = MicroserviceAuditFilter + + override protected def defaultMicroserviceFilters: Seq[EssentialFilter] = Seq( + Some(metricsFilter), + Some(microserviceAuditFilter), + Some(loggingFilter), + Some(DefaultToNoCacheFilter), + Some(RecoveryFilter)).flatten + + override val hc = HeaderCarrier() + override val slConnector = ServiceLocatorConnector(WSHttp) + override lazy val registrationEnabled = appContext.publishApiDefinition + + override def authFilter = None + + override def auditConnector: AuditConnector = MicroserviceAuditConnector + + override def microserviceMetricsConfig(implicit app: Application): Option[Configuration] = + app.configuration.getConfig(s"$env.microservice.metrics") + + override lazy val scheduledJobs: Seq[ScheduledJob] = { + val upliftJob: Seq[ExclusiveScheduledJob] = if (appContext.upliftVerificationExpiryJobConfig.enabled) { + Seq(injector.instanceOf[UpliftVerificationExpiryJob]) + } else { + Seq.empty + } + + val refreshJob = if (appContext.refreshSubscriptionsJobConfig.enabled) { + Seq(injector.instanceOf[RefreshSubscriptionsScheduledJob]) + } else { + Seq.empty + } + upliftJob ++ refreshJob + } +} + +object ControllerConfiguration extends ControllerConfig { + + import net.ceedubs.ficus.Ficus._ + + lazy val controllerConfigs = Play.current.configuration.underlying.as[Config]("controllers") +} + +object MicroserviceAuditFilter extends AuditFilter with AppName with MicroserviceFilterSupport { + override val auditConnector = MicroserviceAuditConnector + + override def controllerNeedsAuditing(controllerName: String) = ControllerConfiguration.paramsForController(controllerName).needsAuditing +} + +object MicroserviceLoggingFilter extends LoggingFilter with MicroserviceFilterSupport { + override def controllerNeedsLogging(controllerName: String) = ControllerConfiguration.paramsForController(controllerName).needsLogging +} + +object MicroserviceAuditConnector extends AuditConnector with RunMode { + override lazy val auditingConfig = LoadAuditingConfig(s"$env.auditing") +} + +trait Hooks extends HttpHooks with HttpAuditing { + override val hooks = Seq(AuditingHook) + override lazy val auditConnector: AuditConnector = MicroserviceAuditConnector +} + +trait WSHttp extends HttpGet with WSGet with HttpPut with WSPut with HttpPost with WSPost with HttpDelete with WSDelete with Hooks with AppName +object WSHttp extends WSHttp \ No newline at end of file diff --git a/app/uk/gov/hmrc/connector/APIDefinitionConnector.scala b/app/uk/gov/hmrc/connector/APIDefinitionConnector.scala new file mode 100644 index 000000000..748722494 --- /dev/null +++ b/app/uk/gov/hmrc/connector/APIDefinitionConnector.scala @@ -0,0 +1,39 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.connector + +import java.util.UUID + +import javax.inject.Inject +import uk.gov.hmrc.config.WSHttp +import uk.gov.hmrc.http.{HeaderCarrier, HttpReads} +import uk.gov.hmrc.models.APIDefinition + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.{ExecutionContext, Future} + +class APIDefinitionConnector @Inject() extends HttpConnector { + lazy val serviceUrl = baseUrl("api-definition") + val http = WSHttp + + def fetchAllAPIs(applicationId: UUID)(implicit rds: HttpReads[Seq[APIDefinition]], hc: HeaderCarrier, ec: ExecutionContext): Future[Seq[APIDefinition]] = { + val url = s"$serviceUrl/api-definition?applicationId=$applicationId" + http.GET[Seq[APIDefinition]](url).map(result => result) recover { + case e => throw new RuntimeException(s"Unexpected response from $url: ${e.getMessage}") + } + } +} diff --git a/app/uk/gov/hmrc/connector/ApiSubscriptionFieldsConnector.scala b/app/uk/gov/hmrc/connector/ApiSubscriptionFieldsConnector.scala new file mode 100644 index 000000000..cb6694434 --- /dev/null +++ b/app/uk/gov/hmrc/connector/ApiSubscriptionFieldsConnector.scala @@ -0,0 +1,38 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.connector + +import javax.inject.Inject + +import uk.gov.hmrc.config.WSHttp +import uk.gov.hmrc.http.{HeaderCarrier, NotFoundException} +import uk.gov.hmrc.models.HasSucceeded + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +class ApiSubscriptionFieldsConnector @Inject() extends HttpConnector { + + lazy val serviceUrl = baseUrl("api-subscription-fields") + val http = WSHttp + + def deleteSubscriptions(clientId: String)(implicit hc: HeaderCarrier): Future[HasSucceeded] = { + http.DELETE(s"$serviceUrl/field/application/$clientId") map (_ => HasSucceeded) recover { + case _: NotFoundException => HasSucceeded + } + } +} diff --git a/app/uk/gov/hmrc/connector/AuthConnector.scala b/app/uk/gov/hmrc/connector/AuthConnector.scala new file mode 100644 index 000000000..6e70c559e --- /dev/null +++ b/app/uk/gov/hmrc/connector/AuthConnector.scala @@ -0,0 +1,44 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.connector + +import javax.inject.Inject + +import uk.gov.hmrc.config.WSHttp +import uk.gov.hmrc.http.{HeaderCarrier, Upstream4xxResponse} +import uk.gov.hmrc.models.AuthRole + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +class AuthConnector @Inject() extends HttpConnector { + + val authUrl: String = s"${baseUrl("auth")}/auth/authenticate/user" + val http = WSHttp + + def authorized(role: AuthRole)(implicit hc: HeaderCarrier): Future[Boolean] = authorized(role.scope, Some(role.name)) + + def authorized(scope: String, role: Option[String])(implicit hc: HeaderCarrier): Future[Boolean] = { + val authoriseUrl = + role.map(aRole => s"$authUrl/authorise?scope=$scope&role=$aRole") + .getOrElse(s"$authUrl/authorise?scope=$scope") + + http.GET(authoriseUrl) map (_ => true) recover { + case e: Upstream4xxResponse if e.upstreamResponseCode == 401 => false + } + } +} diff --git a/app/uk/gov/hmrc/connector/EmailConnector.scala b/app/uk/gov/hmrc/connector/EmailConnector.scala new file mode 100644 index 000000000..c4b1e13a9 --- /dev/null +++ b/app/uk/gov/hmrc/connector/EmailConnector.scala @@ -0,0 +1,131 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.connector + +import javax.inject.Inject +import play.api.Logger +import play.api.libs.json.Json +import play.mvc.Http.Status._ +import uk.gov.hmrc.config.{AppContext, WSHttp} +import uk.gov.hmrc.http.{HeaderCarrier, HttpResponse} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class SendEmailRequest(to: Set[String], + templateId: String, + parameters: Map[String, String], + force: Boolean = false, + auditData: Map[String, String] = Map.empty, + eventUrl: Option[String] = None) + +object SendEmailRequest { + implicit val sendEmailRequestFmt = Json.format[SendEmailRequest] +} + +class EmailConnector @Inject()(appContext: AppContext) extends HttpConnector { + lazy val serviceUrl = baseUrl("email") + val http = WSHttp + val devHubBaseUrl = appContext.devHubBaseUrl + val devHubTitle = appContext.devHubTitle + + val addedCollaboratorConfirmation = "apiAddedDeveloperAsCollaboratorConfirmation" + val addedCollaboratorNotification = "apiAddedDeveloperAsCollaboratorNotification" + val removedCollaboratorConfirmation = "apiRemovedCollaboratorConfirmation" + val removedCollaboratorNotification = "apiRemovedCollaboratorNotification" + val applicationApprovedGatekeeperConfirmation = "apiApplicationApprovedGatekeeperConfirmation" + val applicationApprovedAdminConfirmation = "apiApplicationApprovedAdminConfirmation" + val applicationApprovedNotification = "apiApplicationApprovedNotification" + val applicationRejectedNotification = "apiApplicationRejectedNotification" + val applicationDeletedNotification = "apiApplicationDeletedNotification" + + def sendAddedCollaboratorConfirmation(role: String, application: String, recipients: Set[String])(implicit hc: HeaderCarrier): Future[HttpResponse] = { + post(SendEmailRequest(recipients, addedCollaboratorConfirmation, + Map("role" -> role, + "applicationName" -> application, + "developerHubLink" -> s"$devHubBaseUrl/developer/registration", + "developerHubTitle" -> devHubTitle))) + } + + def sendAddedCollaboratorNotification(email: String, role: String, application: String, recipients: Set[String]) + (implicit hc: HeaderCarrier): Future[HttpResponse] = { + post(SendEmailRequest(recipients, addedCollaboratorNotification, + Map("email" -> email, "role" -> s"$role", "applicationName" -> application, "developerHubTitle" -> devHubTitle))) + } + + def sendRemovedCollaboratorConfirmation(application: String, recipients: Set[String])(implicit hc: HeaderCarrier): Future[HttpResponse] = { + post(SendEmailRequest(recipients, removedCollaboratorConfirmation, + Map("applicationName" -> application, "developerHubTitle" -> devHubTitle))) + } + + def sendRemovedCollaboratorNotification(email: String, application: String, recipients: Set[String])(implicit hc: HeaderCarrier): Future[HttpResponse] = { + post(SendEmailRequest(recipients, removedCollaboratorNotification, + Map("email" -> email, "applicationName" -> application, "developerHubTitle" -> devHubTitle))) + } + + def sendApplicationApprovedGatekeeperConfirmation(email: String, application: String, recipients: Set[String])(implicit hc: HeaderCarrier) = { + post(SendEmailRequest(recipients, applicationApprovedGatekeeperConfirmation, + Map("email" -> email, "applicationName" -> application))) + } + + def sendApplicationApprovedAdminConfirmation(application: String, code: String, recipients: Set[String])(implicit hc: HeaderCarrier) = { + post(SendEmailRequest(recipients, applicationApprovedAdminConfirmation, + Map("applicationName" -> application, + "developerHubLink" -> s"$devHubBaseUrl/developer/application-verification?code=$code"))) + } + + def sendApplicationApprovedNotification(application: String, recipients: Set[String])(implicit hc: HeaderCarrier) = { + post(SendEmailRequest(recipients, applicationApprovedNotification, + Map("applicationName" -> application))) + } + + def sendApplicationRejectedNotification(application: String, recipients: Set[String], reason: String)(implicit hc: HeaderCarrier) = { + post(SendEmailRequest(recipients, applicationRejectedNotification, + Map("applicationName" -> application, + "guidelinesUrl" -> s"$devHubBaseUrl/api-documentation/docs/using-the-hub/name-guidelines", + "supportUrl" -> s"$devHubBaseUrl/developer/support", + "reason" -> reason))) + } + + def sendApplicationDeletedNotification(application: String, requesterEmail: String, recipients: Set[String])(implicit hc: HeaderCarrier) = { + post(SendEmailRequest(recipients, applicationDeletedNotification, + Map("applicationName" -> application, "requestor" -> requesterEmail))) + } + + private def post(payload: SendEmailRequest)(implicit hc: HeaderCarrier): Future[HttpResponse] = { + val url = s"$serviceUrl/hmrc/email" + + def extractError(response: HttpResponse): RuntimeException = { + Try(response.json \ "message") match { + case Success(jsValue) => new RuntimeException(jsValue.as[String]) + case Failure(_) => new RuntimeException( + s"Unable send email. Unexpected error for url=$url status=${response.status} response=${response.body}") + } + } + + http.POST[SendEmailRequest, HttpResponse](url, payload) + .map { response => + Logger.info(s"Sent '${payload.templateId}' to: ${payload.to.mkString(",")} with response: ${response.status}") + response.status match { + case status if status >= 200 && status <= 299 => response + case NOT_FOUND => throw new RuntimeException(s"Unable to send email. Downstream endpoint not found: $url") + case _ => throw extractError(response) + } + } + } +} diff --git a/app/uk/gov/hmrc/connector/HttpConnector.scala b/app/uk/gov/hmrc/connector/HttpConnector.scala new file mode 100644 index 000000000..c9be10ed9 --- /dev/null +++ b/app/uk/gov/hmrc/connector/HttpConnector.scala @@ -0,0 +1,23 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.connector + +import uk.gov.hmrc.play.config.ServicesConfig + +trait HttpConnector extends ServicesConfig { + lazy val applicationName = getString("appName") +} diff --git a/app/uk/gov/hmrc/connector/TOTPConnector.scala b/app/uk/gov/hmrc/connector/TOTPConnector.scala new file mode 100644 index 000000000..40442b769 --- /dev/null +++ b/app/uk/gov/hmrc/connector/TOTPConnector.scala @@ -0,0 +1,44 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.connector + +import javax.inject.Inject +import play.api.http.Status.CREATED +import uk.gov.hmrc.config.WSHttp +import uk.gov.hmrc.http.{HeaderCarrier, HttpReads, HttpResponse} +import uk.gov.hmrc.models.JsonFormatters._ +import uk.gov.hmrc.models.TOTP + +import scala.concurrent.{ExecutionContext, Future} + +class TOTPConnector @Inject() extends HttpConnector { + val http = WSHttp + val serviceUrl = baseUrl("totp") + + def generateTotp()(implicit rds: HttpReads[HttpResponse], hc: HeaderCarrier, ec: ExecutionContext): Future[TOTP] = { + val url = s"$serviceUrl/time-based-one-time-password/secret" + + http.POSTEmpty[HttpResponse](url).map { result => + result.status match { + case CREATED => result.json.as[TOTP] + case _ => throw new RuntimeException(s"Unexpected response from $url: (${result.status}) ${result.body}") + } + } recover { + case e => throw new RuntimeException(s"Error response from $url: ${e.getMessage}") + } + } +} diff --git a/app/uk/gov/hmrc/connector/ThirdPartyDelegatedAuthorityConnector.scala b/app/uk/gov/hmrc/connector/ThirdPartyDelegatedAuthorityConnector.scala new file mode 100644 index 000000000..df057461d --- /dev/null +++ b/app/uk/gov/hmrc/connector/ThirdPartyDelegatedAuthorityConnector.scala @@ -0,0 +1,36 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.connector + +import javax.inject.Inject +import uk.gov.hmrc.config.WSHttp +import uk.gov.hmrc.http.{HeaderCarrier, NotFoundException} +import uk.gov.hmrc.models.HasSucceeded + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +class ThirdPartyDelegatedAuthorityConnector @Inject() extends HttpConnector { + lazy val serviceUrl = baseUrl("third-party-delegated-authority") + val http = WSHttp + + def revokeApplicationAuthorities(clientId: String)(implicit hc: HeaderCarrier): Future[HasSucceeded] = { + http.DELETE(s"$serviceUrl/authority/$clientId") map (_ => HasSucceeded) recover { + case _: NotFoundException => HasSucceeded + } + } +} diff --git a/app/uk/gov/hmrc/connector/WSO2APIStoreConnector.scala b/app/uk/gov/hmrc/connector/WSO2APIStoreConnector.scala new file mode 100644 index 000000000..dc2c92c22 --- /dev/null +++ b/app/uk/gov/hmrc/connector/WSO2APIStoreConnector.scala @@ -0,0 +1,312 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.connector + +import com.google.common.base.Charsets +import javax.inject.Inject +import play.api.Logger +import play.api.http.ContentTypes.FORM +import play.api.http.HeaderNames.{CONTENT_TYPE, COOKIE, SET_COOKIE} +import play.api.http.Status.OK +import play.api.libs.json._ +import play.utils.UriEncoding +import uk.gov.hmrc.config.WSHttp +import uk.gov.hmrc.http.{HeaderCarrier, HttpReads, HttpResponse} +import uk.gov.hmrc.models.RateLimitTier._ +import uk.gov.hmrc.models._ +import uk.gov.hmrc.scheduled.Retrying + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent._ +import scala.concurrent.duration._ +import scala.util.{Failure, Success, Try} + +class WSO2APIStoreConnector @Inject() extends HttpConnector { + + val http = WSHttp + val serviceUrl = s"${baseUrl("wso2-store")}/store/site/blocks" + val adminUsername: String = getConfString("wso2-store.username", "admin") + + def login(username: String, password: String)(implicit hc: HeaderCarrier): Future[String] = { + Logger.debug(s"User logging in: [$username]") + val url = s"$serviceUrl/user/login/ajax/login.jag" + val encodedPassword = UriEncoding.encodePathSegment(password, Charsets.UTF_8.name()) + val payload = + s"""action=login + |&username=$username + |&password=$encodedPassword + |""".stripMargin.replaceAll("\n", "") + + post(url, payload, headers()).map { response => + response.allHeaders(SET_COOKIE) mkString ";" + } + } + + def logout(cookie: String)(implicit hc: HeaderCarrier): Future[HasSucceeded] = { + Logger.debug("User logging out") + val url = s"$serviceUrl/user/login/ajax/login.jag?action=logout" + + get(url, headers(cookie)) map toHasSucceeded + } + + def createUser(username: String, password: String)(implicit hc: HeaderCarrier): Future[HasSucceeded] = { + Logger.debug(s"Creating user $username") + val url = s"$serviceUrl/user/sign-up/ajax/user-add.jag" + val payload = + s"""action=addUser + |&username=$username + |&password=$password + |&allFieldsValues=firstname|lastname|email + |""".stripMargin.replaceAll("\n", "") + + post(url, payload, headers()) map toHasSucceeded + } + + def createApplication(cookie: String, wso2ApplicationName: String) + (implicit hc: HeaderCarrier): Future[HasSucceeded] = { + Logger.debug(s"Creating application [$wso2ApplicationName]") + val url = s"$serviceUrl/application/application-add/ajax/application-add.jag" + val payload = + s"""action=addApplication + |&application=$wso2ApplicationName + |&tier=BRONZE_APPLICATION + |&description= + |&callbackUrl= + |""".stripMargin.replaceAll("\n", "") + + post(url, payload, headers(cookie)) map toHasSucceeded + } + + def updateApplication(cookie: String, wso2ApplicationName: String, rateLimitTier: RateLimitTier) + (implicit hc: HeaderCarrier): Future[HasSucceeded] = { + val wso2RateLimitTier: String = s"${rateLimitTier.toString.toUpperCase}_APPLICATION" + Logger.debug(s"Updating application [$wso2ApplicationName] - Setting rate limit tier to [$wso2RateLimitTier]") + val url = s"$serviceUrl/application/application-update/ajax/application-update.jag" + val payload = + s"""action=updateApplication + |&applicationOld=$wso2ApplicationName + |&applicationNew=$wso2ApplicationName + |&callbackUrlNew= + |&descriptionNew= + |&tier=$wso2RateLimitTier + |""".stripMargin.replaceAll("\n", "") + + post(url, payload, headers(cookie)) map toHasSucceeded + } + + def deleteApplication(cookie: String, wso2ApplicationName: String)(implicit hc: HeaderCarrier): Future[HasSucceeded] = { + Logger.debug(s"Deleting application [$wso2ApplicationName]") + val url = s"$serviceUrl/application/application-remove/ajax/application-remove.jag" + val payload = s"action=removeApplication&application=$wso2ApplicationName" + + post(url, payload, headers(cookie)) map toHasSucceeded + } + + def generateApplicationKey(cookie: String, wso2ApplicationName: String, environment: Environment.Value) + (implicit hc: HeaderCarrier): Future[EnvironmentToken] = { + Logger.debug(s"Generating $environment keys for $wso2ApplicationName") + val url = s"$serviceUrl/subscription/subscription-add/ajax/subscription-add.jag" + val payload = + s"""action=generateApplicationKey + |&application=$wso2ApplicationName + |&keytype=$environment + |&callbackUrl= + |&authorizedDomains=ALL + |&validityTime=-1 + |""".stripMargin.replaceAll("\n", "") + + post(url, payload, headers(cookie)).map { result => + extractKeys(result.json) { keys => + EnvironmentToken(keys.consumerKey, keys.consumerSecret, keys.accessToken) + } + } + } + + private def extractKeys[T](js: JsValue)(f: Keys => T): T = { + Try((js \ "error").as[Boolean]) match { + case Failure(e) => throw new RuntimeException(e.getMessage, e) + case Success(true) => throw new RuntimeException((js \ "message").as[String]) + case _ => f((js \ "data" \ "key").as[Keys]) + } + } + + private def parseRateLimitTier(tier: String): Option[RateLimitTier] = { + Try(RateLimitTier.withName(tier.replaceFirst("_APPLICATION", ""))).toOption + } + + private def extractRateLimitTier(js: JsValue): RateLimitTier = { + Try((js \ "error").as[Boolean]) match { + case Failure(e) => throw new RuntimeException(e.getMessage, e) + case Success(true) => throw new RuntimeException((js \ "message").as[String]) + case _ => + val tier = (js \ "application" \ "tier").as[String] + parseRateLimitTier(tier).getOrElse(throw new RuntimeException(s"Invalid rate limit tier: $tier")) + } + } + + private def getApplication(cookie: String, wso2ApplicationName: String)(implicit hc: HeaderCarrier) = { + Logger.debug(s"Fetching application [$wso2ApplicationName]") + val url = s"$serviceUrl/application/application-list/ajax/application-list.jag" + val uriParams = s"action=getApplicationByName&applicationName=$wso2ApplicationName" + + get(s"$url?$uriParams", headers(cookie)) + } + + def getApplicationRateLimitTier(cookie: String, wso2ApplicationName: String) + (implicit hc: HeaderCarrier): Future[RateLimitTier] = { + getApplication(cookie, wso2ApplicationName) map { response => extractRateLimitTier(response.json) } + } + + def addSubscription(cookie: String, wso2ApplicationName: String, api: WSO2API, rateLimitTier: Option[RateLimitTier], retryMax: Int) + (implicit hc: HeaderCarrier): Future[HasSucceeded] = { + + def normalise(rateLimitTier: Option[RateLimitTier]) = { + s"${rateLimitTier.getOrElse(RateLimitTier.BRONZE).toString.toUpperCase}_SUBSCRIPTION" + } + + // NOTE: + // WSO2 Store throws `org.wso2.carbon.apimgt.api.APIManagementException` if you try to add a subscription that already exists + // In WSO2 Store carbon log file you see `org.wso2.carbon.apimgt.api.SubscriptionAlreadyExistingException` + + Logger.debug(s"Application: [$wso2ApplicationName] is subscribing to [${api.name}-${api.version}]") + val url = s"$serviceUrl/subscription/subscription-add/ajax/subscription-add.jag" + val payload = + s"""action=addAPISubscription + |&name=${api.name} + |&version=${api.version} + |&provider=$adminUsername + |&tier=${normalise(rateLimitTier)} + |&applicationName=$wso2ApplicationName + |""".stripMargin.replaceAll("\n", "") + + def subscribe() = { + post(url, payload, headers(cookie)) map toHasSucceeded + } + + Retrying.retry(subscribe(), 180.milliseconds, retryMax) + } + + def removeSubscription(cookie: String, wso2ApplicationName: String, api: WSO2API, retryMax: Int) + (implicit hc: HeaderCarrier): Future[HasSucceeded] = { + + // NOTE: WSO2's removeSubscription API is idempotent - if requested to remove a subscription that doesn't exist, it will respond with no error + + Logger.debug(s"Application: [$wso2ApplicationName] is unsubscribing from [${api.name}-${api.version}]") + val url = s"$serviceUrl/subscription/subscription-remove/ajax/subscription-remove.jag" + val payload = + s"""action=removeSubscription + |&name=${api.name} + |&version=${api.version} + |&provider=$adminUsername + |&applicationName=$wso2ApplicationName + |""".stripMargin.replaceAll("\n", "") + + def unsubscribe() = { + post(url, payload, headers(cookie)) map toHasSucceeded + } + + Retrying.retry(unsubscribe(), 180.milliseconds, retryMax) + } + + def getSubscriptions(cookie: String, wso2ApplicationName: String) + (implicit hc: HeaderCarrier): Future[Seq[WSO2API]] = { + Logger.debug(s"Fetching subscriptions for application: [$wso2ApplicationName]") + val url = s"$serviceUrl/subscription/subscription-list/ajax/subscription-list.jag" + val payload = s"action=getSubscriptionByApplication&app=$wso2ApplicationName" + + post(url, payload, headers(cookie)).map { response => + (response.json \ "apis").as[Seq[JsValue]].map { apiJson => + WSO2API( + (apiJson \ "apiName").as[String], + (apiJson \ "apiVersion").as[String] + ) + } + } + } + + def getAllSubscriptions(cookie: String)(implicit hc: HeaderCarrier): Future[Map[String, Seq[WSO2API]]] = { + Logger.debug("Fetching subscriptions for all applications") + val url = s"$serviceUrl/subscription/subscription-list/ajax/subscription-list.jag" + val payload = "action=getAllSubscriptions" + + post(url, payload, headers(cookie)).map { response => + (response.json \ "subscriptions" \ "applications").as[Seq[JsValue]].map { wso2applicationJson => + val name = (wso2applicationJson \ "name").as[String] + val subscriptions = (wso2applicationJson \ "subscriptions").as[Seq[JsValue]].map { subscriptionJson => + WSO2API( + (subscriptionJson \ "name").as[String], + (subscriptionJson \ "version").as[String] + ) + } + name -> subscriptions + }.toMap + } + } + + private def post(url: String, body: String, headers: Seq[(String, String)]) + (implicit hc: HeaderCarrier): Future[HttpResponse] = { + Logger.debug(s"POST url=$url request=$body") + http.POSTString[HttpResponse](url, body, headers) + .map { resp => handleResponse(url, resp) } recover { + case e => throw new RuntimeException(s"Unexpected response from $url: ${e.getMessage}") + } + } + + private def get(url: String, headers: Seq[(String, String)]) + (implicit rds: HttpReads[HttpResponse], hc: HeaderCarrier, ec: ExecutionContext): Future[HttpResponse] = { + Logger.debug(s"GET url=$url") + val headerCarrier = hc.withExtraHeaders(headers: _*) + http.GET[HttpResponse](url)(rds, headerCarrier, ec) + .map { resp => handleResponse(url, resp) } + } + + private def handleResponse(url: String, response: HttpResponse): HttpResponse = { + response.status match { + case OK => + Logger.debug(s"Response=${response.body}") + Try((response.json \ "error").as[Boolean]) match { + case Success(false) => + response + case Success(true) => + Logger.warn(s"Error found after calling $url: ${response.body}") + throw new RuntimeException((response.json \ "message").as[String]) + case Failure(_) => + Logger.warn(s"Error found after calling $url: ${response.body}") + throw new RuntimeException(s"${response.body}") + } + case _ => + Logger.warn(s"Request $url failed. Response=${response.body}") + throw new RuntimeException(s"${response.body}") + } + } + + private def toHasSucceeded: HttpResponse => HasSucceeded = { _ => HasSucceeded } + + private def headers(): Seq[(String, String)] = { + Seq(CONTENT_TYPE -> FORM) + } + + private def headers(cookie: String): Seq[(String, String)] = { + headers :+ (COOKIE -> cookie) + } +} + +case class Keys(consumerKey: String, consumerSecret: String, accessToken: String) + +object Keys { + implicit val formats: Format[Keys] = Json.format[Keys] +} diff --git a/app/uk/gov/hmrc/controllers/AccessController.scala b/app/uk/gov/hmrc/controllers/AccessController.scala new file mode 100644 index 000000000..3f9da6049 --- /dev/null +++ b/app/uk/gov/hmrc/controllers/AccessController.scala @@ -0,0 +1,61 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.controllers + +import java.util.UUID +import javax.inject.Inject + +import play.api.libs.json.Json.toJson +import play.api.mvc.BodyParsers.parse.json +import uk.gov.hmrc.connector.AuthConnector +import uk.gov.hmrc.models.JsonFormatters._ +import uk.gov.hmrc.services.{AccessService, ApplicationService} + +import scala.concurrent.ExecutionContext.Implicits.global + +class AccessController @Inject()(accessService: AccessService, + val authConnector: AuthConnector, + val applicationService: ApplicationService) extends CommonController with AuthorisationWrapper { + + def readScopes(applicationId: UUID) = requiresGatekeeperForPrivilegedOrRopcApplications(applicationId).async { implicit request => + accessService.readScopes(applicationId) map { scopeResponse => + Ok(toJson(scopeResponse)) + } recover recovery + } + + def updateScopes(applicationId: UUID) = requiresGatekeeperForPrivilegedOrRopcApplications(applicationId).async(json) { implicit request => + withJsonBody[ScopeRequest] { scopeRequest => + accessService.updateScopes(applicationId, scopeRequest) map { scopeResponse => + Ok(toJson(scopeResponse)) + } recover recovery + } + } + + def readOverrides(applicationId: UUID) = requiresGatekeeperForStandardApplications(applicationId).async { implicit request => + accessService.readOverrides(applicationId) map { overrideResponse => + Ok(toJson(overrideResponse)) + } recover recovery + } + + def updateOverrides(applicationId: UUID) = requiresGatekeeperForStandardApplications(applicationId).async(json) { implicit request => + withJsonBody[OverridesRequest] { overridesRequest => + accessService.updateOverrides(applicationId, overridesRequest) map { overridesResponse => + Ok(toJson(overridesResponse)) + } recover recovery + } + } +} diff --git a/app/uk/gov/hmrc/controllers/ApplicationController.scala b/app/uk/gov/hmrc/controllers/ApplicationController.scala new file mode 100644 index 000000000..024493592 --- /dev/null +++ b/app/uk/gov/hmrc/controllers/ApplicationController.scala @@ -0,0 +1,289 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.controllers + +import java.util.UUID + +import javax.inject.Inject +import play.api.libs.json.Json.toJson +import play.api.libs.json._ +import play.api.mvc._ +import uk.gov.hmrc.config.AppContext +import uk.gov.hmrc.connector.AuthConnector +import uk.gov.hmrc.controllers.ErrorCode._ +import uk.gov.hmrc.http.NotFoundException +import uk.gov.hmrc.models.AccessType.{PRIVILEGED, ROPC} +import uk.gov.hmrc.models.AuthRole.APIGatekeeper +import uk.gov.hmrc.models.JsonFormatters._ +import uk.gov.hmrc.models._ +import uk.gov.hmrc.services.{ApplicationService, CredentialService, SubscriptionService} +import uk.gov.hmrc.util.http.HttpHeaders._ + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +class ApplicationController @Inject()(val applicationService: ApplicationService, + val authConnector: AuthConnector, + credentialService: CredentialService, + subscriptionService: SubscriptionService, + appContext: AppContext) extends CommonController with AuthorisationWrapper { + + val applicationCacheExpiry = appContext.fetchApplicationTtlInSecs + val subscriptionCacheExpiry = appContext.fetchSubscriptionTtlInSecs + + override implicit def hc(implicit request: RequestHeader) = { + def header(key: String) = request.headers.get(key) map (key -> _) + + val extraHeaders = Seq(header(LOGGED_IN_USER_NAME_HEADER), header(LOGGED_IN_USER_EMAIL_HEADER), header(SERVER_TOKEN_HEADER)).flatten + super.hc.withExtraHeaders(extraHeaders: _*) + } + + def create = requiresRoleFor(APIGatekeeper, PRIVILEGED, ROPC).async(BodyParsers.parse.json) { implicit request => + withJsonBody[CreateApplicationRequest] { application => + applicationService.create(application).map { + result => Created(toJson(result)) + } recover { + case e: ApplicationAlreadyExists => + Conflict(JsErrorResponse(APPLICATION_ALREADY_EXISTS, s"Application already exists with name: ${e.applicationName}")) + } recover recovery + } + } + + def update(applicationId: UUID) = requiresGatekeeperForPrivilegedOrRopcApplications(applicationId).async(BodyParsers.parse.json) { implicit request => + withJsonBody[UpdateApplicationRequest] { application => + applicationService.update(applicationId, application).map { result => + Ok(toJson(result)) + } recover recovery + } + } + + def updateRateLimitTier(applicationId: UUID) = requiresRole(APIGatekeeper).async(BodyParsers.parse.json) { implicit request => + withJsonBody[UpdateRateLimitTierRequest] { updateRateLimitTierRequest => + Try(RateLimitTier withName updateRateLimitTierRequest.rateLimitTier.toUpperCase()) match { + case Success(rateLimitTier) => + applicationService updateRateLimitTier(applicationId, rateLimitTier) map { _ => + NoContent + } recover recovery + case Failure(_) => Future.successful(UnprocessableEntity( + JsErrorResponse(INVALID_REQUEST_PAYLOAD, s"'${updateRateLimitTierRequest.rateLimitTier}' is an invalid rate limit tier"))) + } + } + } + + def updateCheck(applicationId: UUID) = requiresGatekeeperForPrivilegedOrRopcApplications(applicationId).async(BodyParsers.parse.json) { implicit request => + withJsonBody[CheckInformation] { checkInformation => + applicationService.updateCheck(applicationId, checkInformation).map { result => + Ok(toJson(result)) + } recover recovery + } + } + + def fetch(applicationId: UUID) = Action.async { + handleOption(applicationService.fetch(applicationId)) + } + + def fetchCredentials(applicationId: UUID) = Action.async { + handleOption(credentialService.fetchCredentials(applicationId)) + } + + def fetchWso2Credentials(clientId: String) = Action.async { + handleOption(credentialService.fetchWso2Credentials(clientId)) + } + + def addCollaborator(applicationId: UUID) = { + requiresGatekeeperForPrivilegedOrRopcApplications(applicationId).async(BodyParsers.parse.json) { implicit request => + withJsonBody[AddCollaboratorRequest] { collaboratorRequest => + applicationService.addCollaborator(applicationId, collaboratorRequest) map { + response => Ok(toJson(response)) + } recover { + case _: UserAlreadyExists => Conflict(JsErrorResponse(USER_ALREADY_EXISTS, + "This email address is already registered with different role, delete and add with desired role")) + + case _: InvalidEnumException => UnprocessableEntity(JsErrorResponse(INVALID_REQUEST_PAYLOAD, "Invalid Role")) + } recover recovery + } + } + } + + def deleteCollaborator(applicationId: UUID, email: String, admin: String, adminsToEmail: String) = { + + val adminsToEmailSet = adminsToEmail.split(",").toSet[String].map(_.trim).filter(_.nonEmpty) + + requiresGatekeeperForPrivilegedOrRopcApplications(applicationId).async { implicit request => + applicationService.deleteCollaborator(applicationId, email, admin, adminsToEmailSet) map (_ => NoContent) recover { + case _: ApplicationNeedsAdmin => Forbidden(JsErrorResponse(APPLICATION_NEEDS_ADMIN, "Application requires at least one admin")) + } recover recovery + } + } + + def addClientSecret(applicationId: java.util.UUID) = + requiresGatekeeperForPrivilegedOrRopcApplications(applicationId).async(BodyParsers.parse.json) { implicit request => + withJsonBody[ClientSecretRequest] { secret => + credentialService.addClientSecret(applicationId, secret) map { tokens => Ok(toJson(tokens)) + } recover { + case e: NotFoundException => handleNotFound(e.getMessage) + case _: InvalidEnumException => UnprocessableEntity(JsErrorResponse(INVALID_REQUEST_PAYLOAD, "Invalid environment")) + case _: ClientSecretsLimitExceeded => Forbidden(JsErrorResponse(CLIENT_SECRET_LIMIT_EXCEEDED, "Client secret limit has been exceeded")) + case e => handleException(e) + } + } + } + + def deleteClientSecrets(appId: java.util.UUID) = { + requiresGatekeeperForPrivilegedOrRopcApplications(appId).async(BodyParsers.parse.json) { implicit request => + withJsonBody[DeleteClientSecretsRequest] { secretsRequest => + credentialService.deleteClientSecrets(appId, secretsRequest.secrets).map(_ => NoContent) recover recovery + } + } + } + + def validateCredentials = Action.async(BodyParsers.parse.json) { implicit request => + withJsonBody[ValidationRequest] { vr => + credentialService.validateCredentials(vr) map { + case Some(e) => Ok(Json.obj("environment" -> e)) + case None => Unauthorized(JsErrorResponse(INVALID_CREDENTIALS, "Invalid client id or secret")) + } recover recovery + } + } + + def requestUplift(id: java.util.UUID) = Action.async(BodyParsers.parse.json) { implicit request => + withJsonBody[UpliftRequest] { upliftRequest => + applicationService.requestUplift(id, upliftRequest.applicationName, upliftRequest.requestedByEmailAddress) + .map(_ => NoContent) + } recover { + case _: InvalidStateTransition => + PreconditionFailed(JsErrorResponse(INVALID_STATE_TRANSITION, s"Application is not in state '${State.TESTING}'")) + case e: ApplicationAlreadyExists => + Conflict(JsErrorResponse(APPLICATION_ALREADY_EXISTS, s"Application already exists with name: ${e.applicationName}")) + } recover recovery + } + + private def handleOption[T](future: Future[Option[T]])(implicit writes: Writes[T]): Future[Result] = { + future.map { + case Some(v) => Ok(toJson(v)) + case None => handleNotFound("No application was found") + } recover recovery + } + + def queryDispatcher() = Action.async { implicit request => + val queryBy = request.queryString.keys.toList.sorted + val serverToken = hc.headers find (_._1 == SERVER_TOKEN_HEADER) map (_._2) + + def addHeaders(pred: Result => Boolean, headers: (String, String)*)(res: Result): Result = + if (pred(res)) res.withHeaders(headers: _*) else res + + (queryBy, serverToken) match { + case (_, Some(token)) => + fetchByServerToken(token) + .map(addHeaders(res => res.header.status == OK || res.header.status == NOT_FOUND, + CACHE_CONTROL -> s"max-age=$applicationCacheExpiry", VARY -> SERVER_TOKEN_HEADER)) + case ("clientId" :: _, _) => + fetchByClientId(request.queryString("clientId").head) + .map(addHeaders(_.header.status == OK, + CACHE_CONTROL -> s"max-age=$applicationCacheExpiry")) + case ("emailAddress" :: "environment" :: _, _) => + fetchAllForCollaboratorAndEnvironment(request.queryString("emailAddress").head, request.queryString("environment").head) + case ("emailAddress" :: _, _) => + fetchAllForCollaborator(request.queryString("emailAddress").head) + case ("subscribesTo" :: "version" :: _, _) => + fetchAllBySubscriptionVersion(APIIdentifier(request.queryString("subscribesTo").head, request.queryString("version").head)) + case ("subscribesTo" :: _, _) => + fetchAllBySubscription(request.queryString("subscribesTo").head) + case ("noSubscriptions" :: _, _) => + fetchAllWithNoSubscriptions() + case _ => fetchAll() + } + } + + private def fetchByServerToken(serverToken: String) = { + applicationService.fetchByServerToken(serverToken).map { + case Some(application) => Ok(toJson(application)) + case None => handleNotFound("No application was found for server token") + } recover recovery + } + + private def fetchByClientId(clientId: String) = { + applicationService.fetchByClientId(clientId).map { + case Some(application) => Ok(toJson(application)) + case None => handleNotFound("No application was found") + } recover recovery + } + + private def fetchAllForCollaborator(emailAddress: String) = { + applicationService.fetchAllForCollaborator(emailAddress).map(apps => Ok(toJson(apps))) recover recovery + } + + private def fetchAllForCollaboratorAndEnvironment(emailAddress: String, environment: String) = { + applicationService.fetchAllForCollaboratorAndEnvironment(emailAddress, environment).map(apps => Ok(toJson(apps))) recover recovery + } + + private def fetchAll() = { + applicationService.fetchAll().map(apps => Ok(toJson(apps))) recover recovery + } + + private def fetchAllBySubscription(apiContext: String) = { + applicationService.fetchAllBySubscription(apiContext).map(apps => Ok(toJson(apps))) recover recovery + } + + private def fetchAllBySubscriptionVersion(apiContext: APIIdentifier) = { + applicationService.fetchAllBySubscription(apiContext).map(apps => Ok(toJson(apps))) recover recovery + } + + def fetchAllWithNoSubscriptions() = { + applicationService.fetchAllWithNoSubscriptions().map(apps => Ok(toJson(apps))) recover recovery + } + + def fetchAllAPISubscriptions(): Action[AnyContent] = Action.async(implicit request => + subscriptionService.fetchAllSubscriptions() + .map(subs => Ok(toJson(subs.seq))) recover recovery + ) + + def fetchAllSubscriptions(applicationId: UUID) = Action.async { implicit request => + subscriptionService.fetchAllSubscriptionsForApplication(applicationId) + .map(subs => Ok(toJson(subs.seq))) recover recovery + } + + def isSubscribed(id: java.util.UUID, context: String, version: String) = Action.async { + val api = APIIdentifier(context, version) + subscriptionService.isSubscribed(id, api) map { + case true => Ok(toJson(api)).withHeaders(CACHE_CONTROL -> s"max-age=$subscriptionCacheExpiry") + case false => NotFound(JsErrorResponse(SUBSCRIPTION_NOT_FOUND, s"Application $id is not subscribed to $context $version")) + } recover recovery + } + + def createSubscriptionForApplication(applicationId: UUID) = requiresGatekeeperForPrivilegedOrRopcApplications(applicationId).async(BodyParsers.parse.json) { + implicit request => + withJsonBody[APIIdentifier] { api => + subscriptionService.createSubscriptionForApplication(applicationId, api).map(_ => NoContent) recover { + case e: SubscriptionAlreadyExistsException => Conflict(JsErrorResponse(SUBSCRIPTION_ALREADY_EXISTS, e.getMessage)) + } recover recovery + } + } + + def removeSubscriptionForApplication(applicationId: UUID, context: String, version: String) = { + requiresGatekeeperForPrivilegedOrRopcApplications(applicationId).async { implicit request => + subscriptionService.removeSubscriptionForApplication(applicationId, APIIdentifier(context, version)).map(_ => NoContent) recover recovery + } + } + + def verifyUplift(verificationCode: String) = Action.async { implicit request => + applicationService.verifyUplift(verificationCode) map (_ => NoContent) recover { + case e: InvalidUpliftVerificationCode => BadRequest(e.getMessage) + } recover recovery + } +} diff --git a/app/uk/gov/hmrc/controllers/AuthorisationWrapper.scala b/app/uk/gov/hmrc/controllers/AuthorisationWrapper.scala new file mode 100644 index 000000000..0848859ec --- /dev/null +++ b/app/uk/gov/hmrc/controllers/AuthorisationWrapper.scala @@ -0,0 +1,108 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.controllers + +import java.util.UUID + +import play.api.libs.json.Json +import play.api.mvc.Results._ +import play.api.mvc.{Action, Request, _} +import uk.gov.hmrc.connector.AuthConnector +import uk.gov.hmrc.controllers.ErrorCode._ +import uk.gov.hmrc.http.NotFoundException +import uk.gov.hmrc.models.AccessType.{AccessType, PRIVILEGED, ROPC, STANDARD} +import uk.gov.hmrc.models.AuthRole +import uk.gov.hmrc.models.AuthRole.APIGatekeeper +import uk.gov.hmrc.models.JsonFormatters._ +import uk.gov.hmrc.play.HeaderCarrierConverter +import uk.gov.hmrc.services.ApplicationService + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +trait AuthorisationWrapper { + + val authConnector: AuthConnector + val applicationService: ApplicationService + + def requiresRole(requiredRole: AuthRole): ActionBuilder[Request] = { + Action andThen AuthenticatedAction(requiredRole) + } + + def requiresRoleFor(authRole: AuthRole, accessTypes: AccessType*): ActionBuilder[Request] = + Action andThen PayloadBasedApplicationTypeFilter(authRole, accessTypes) + + def requiresRoleFor(uuid: UUID, authRole: AuthRole, accessTypes: AccessType*): ActionBuilder[Request] = + Action andThen RepositoryBasedApplicationTypeFilter(authRole, uuid, failOnAccessTypeMismatch = false, accessTypes) + + def requiresGatekeeperForStandardApplications(uuid: UUID): ActionBuilder[Request] = + Action andThen RepositoryBasedApplicationTypeFilter(APIGatekeeper, uuid, failOnAccessTypeMismatch = true, Seq(STANDARD)) + + def requiresGatekeeperForPrivilegedOrRopcApplications(uuid: UUID): ActionBuilder[Request] = + Action andThen RepositoryBasedApplicationTypeFilter(APIGatekeeper, uuid, failOnAccessTypeMismatch = false, Seq(PRIVILEGED, ROPC)) + + private case class AuthenticatedAction(requiredRole: AuthRole) extends AuthenticationFilter(requiredRole) { + def filter[A](input: Request[A]) = authenticate(input) + } + + private abstract class AuthenticationFilter(authRole: AuthRole) extends ActionFilter[Request] { + def authenticate[A](request: Request[A]) = { + val hc = HeaderCarrierConverter.fromHeadersAndSession(request.headers, None) + authConnector.authorized(authRole)(hc).map { + case true => None + case false => + Some(Unauthorized(JsErrorResponse(UNAUTHORIZED, + s"Action requires authority: '${authRole.scope}:${authRole.name}'"))) + } + } + } + + private case class PayloadBasedApplicationTypeFilter(requiredAuthRole: AuthRole, accessTypes: Seq[AccessType]) + extends ApplicationTypeFilter(requiredAuthRole, false, accessTypes) { + + override protected def deriveAccessType[A](request: Request[A]) = + Future((Json.parse(request.body.toString) \ "access" \ "accessType").asOpt[AccessType]) + } + + private case class RepositoryBasedApplicationTypeFilter(requiredAuthRole: AuthRole, applicationId: UUID, + failOnAccessTypeMismatch: Boolean, accessTypes: Seq[AccessType]) + extends ApplicationTypeFilter(requiredAuthRole, failOnAccessTypeMismatch, accessTypes) { + + override protected def deriveAccessType[A](request: Request[A]) = + applicationService.fetch(applicationId).map { + case Some(app) => Some(app.access.accessType) + case None => throw new NotFoundException(s"application $applicationId doesn't exist") + } + } + + private abstract class ApplicationTypeFilter(authRole: AuthRole, failOnAccessTypeMismatch: Boolean = false, + accessTypes: Seq[AccessType]) extends AuthenticationFilter(authRole) { + + override def filter[A](request: Request[A]) = + deriveAccessType(request) flatMap { + case Some(accessType) if accessTypes.contains(accessType) => authenticate(request) + case Some(_) if failOnAccessTypeMismatch => + Future.successful(Some(Results.Unauthorized(JsErrorResponse(APPLICATION_NOT_FOUND, "application access type mismatch")))) + + case _ => Future(None) + } recover { + case e: NotFoundException => Some(Results.NotFound(JsErrorResponse(APPLICATION_NOT_FOUND, e.getMessage))) + } + + protected def deriveAccessType[A](request: Request[A]): Future[Option[AccessType]] + } +} diff --git a/app/uk/gov/hmrc/controllers/CommonController.scala b/app/uk/gov/hmrc/controllers/CommonController.scala new file mode 100644 index 000000000..8f1e634af --- /dev/null +++ b/app/uk/gov/hmrc/controllers/CommonController.scala @@ -0,0 +1,58 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.controllers + +import play.api.Logger +import play.api.libs.json.{JsError, JsSuccess, JsValue, Reads} +import play.api.mvc.{Request, Result} +import uk.gov.hmrc.controllers.ErrorCode._ +import uk.gov.hmrc.models.ScopeNotFoundException +import uk.gov.hmrc.play.microservice.controller.BaseController + +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} +import uk.gov.hmrc.http.NotFoundException + +trait CommonController extends BaseController { + + override protected def withJsonBody[T] + (f: (T) => Future[Result])(implicit request: Request[JsValue], m: Manifest[T], reads: Reads[T]): Future[Result] = { + Try(request.body.validate[T]) match { + case Success(JsSuccess(payload, _)) => f(payload) + case Success(JsError(errs)) => Future.successful(UnprocessableEntity(JsErrorResponse(INVALID_REQUEST_PAYLOAD, JsError.toJson(errs)))) + case Failure(e) => Future.successful(UnprocessableEntity(JsErrorResponse(INVALID_REQUEST_PAYLOAD, e.getMessage))) + } + } + + private[controllers] def recovery: PartialFunction[Throwable, Result] = { + case e: NotFoundException => handleNotFound(e.getMessage) + case e: ScopeNotFoundException => NotFound(JsErrorResponse(SCOPE_NOT_FOUND, e.getMessage)) + case e: Throwable => + Logger.error(s"Error occurred: ${e.getMessage}", e) + handleException(e) + } + + private[controllers] def handleNotFound(message: String): Result = { + NotFound(JsErrorResponse(APPLICATION_NOT_FOUND, message)) + } + + private[controllers] def handleException(e: Throwable) = { + Logger.error(s"An unexpected error occurred: ${e.getMessage}", e) + InternalServerError(JsErrorResponse(UNKNOWN_ERROR, "An unexpected error occurred")) + } + +} diff --git a/app/uk/gov/hmrc/controllers/DocumentationController.scala b/app/uk/gov/hmrc/controllers/DocumentationController.scala new file mode 100644 index 000000000..e7a73e47f --- /dev/null +++ b/app/uk/gov/hmrc/controllers/DocumentationController.scala @@ -0,0 +1,47 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.controllers + +import javax.inject.Inject + +import controllers.AssetsBuilder +import play.api.http.HttpErrorHandler +import play.api.mvc.Action +import uk.gov.hmrc.config.AppContext +import uk.gov.hmrc.models.APIAccess +import uk.gov.hmrc.play.microservice.controller.BaseController +import uk.gov.hmrc.views.txt + +class DocumentationController @Inject()(httpErrorHandler: HttpErrorHandler, appContext: AppContext) + extends AssetsBuilder(httpErrorHandler) with BaseController { + + def definition = Action { + if(appContext.publishApiDefinition) { + Ok(txt.definition(appContext.apiContext, APIAccess.build(appContext.access))).withHeaders(CONTENT_TYPE -> JSON) + } else { + NotFound + } + } + + def raml(version: String, file: String) = Action { + if(appContext.publishApiDefinition) { + Ok(txt.application(appContext.apiContext)) + } else { + NotFound + } + } +} diff --git a/app/uk/gov/hmrc/controllers/GatekeeperController.scala b/app/uk/gov/hmrc/controllers/GatekeeperController.scala new file mode 100644 index 000000000..879d63de3 --- /dev/null +++ b/app/uk/gov/hmrc/controllers/GatekeeperController.scala @@ -0,0 +1,85 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.controllers + +import java.util.UUID +import javax.inject.Inject + +import play.api.libs.json.Json +import uk.gov.hmrc.connector.AuthConnector +import uk.gov.hmrc.controllers.ErrorCode._ +import uk.gov.hmrc.models.{AuthRole, InvalidStateTransition} +import uk.gov.hmrc.services.{ApplicationService, GatekeeperService} +import uk.gov.hmrc.models.JsonFormatters._ + +import scala.concurrent.ExecutionContext.Implicits.global + +class GatekeeperController @Inject()(val authConnector: AuthConnector, val applicationService: ApplicationService, + gatekeeperService: GatekeeperService) extends CommonController with AuthorisationWrapper { + + private lazy val badStateResponse = PreconditionFailed( + JsErrorResponse(INVALID_STATE_TRANSITION, "Application is not in state 'PENDING_GATEKEEPER_APPROVAL'")) + + private lazy val badResendResponse = PreconditionFailed( + JsErrorResponse(INVALID_STATE_TRANSITION, "Application is not in state 'PENDING_REQUESTER_VERIFICATION'")) + + + def approveUplift(id: UUID) = requiresRole(AuthRole.APIGatekeeper).async(parse.json) { + implicit request => + withJsonBody[ApproveUpliftRequest] { approveUpliftPayload => + gatekeeperService.approveUplift(id, approveUpliftPayload.gatekeeperUserId) + .map(_ => NoContent) + } recover { + case _: InvalidStateTransition => badStateResponse + } recover recovery + } + + def rejectUplift(id: UUID) = requiresRole(AuthRole.APIGatekeeper).async(parse.json) { + implicit request => + withJsonBody[RejectUpliftRequest] { + gatekeeperService.rejectUplift(id, _).map(_ => NoContent) + } recover { + case _: InvalidStateTransition => badStateResponse + } recover recovery + } + + def resendVerification(id: UUID) = requiresRole(AuthRole.APIGatekeeper).async(parse.json) { + implicit request => + withJsonBody[ResendVerificationRequest] { resendVerificationPayload => + gatekeeperService.resendVerification(id, resendVerificationPayload.gatekeeperUserId).map(_ => NoContent) + } recover { + case _: InvalidStateTransition => badResendResponse + } recover recovery + } + + def deleteApplication(id: UUID) = requiresRole(AuthRole.APIGatekeeper).async(parse.json) { + implicit request => + withJsonBody[DeleteApplicationRequest] { deleteApplicationPayload => + gatekeeperService.deleteApplication(id, deleteApplicationPayload).map(_ => NoContent) + } recover recovery + } + + def fetchAppsForGatekeeper = requiresRole(AuthRole.APIGatekeeper).async { + gatekeeperService.fetchNonTestingAppsWithSubmittedDate() map { + apps => Ok(Json.toJson(apps)) + } recover recovery + } + + def fetchAppById(id: UUID) = requiresRole(AuthRole.APIGatekeeper).async { + gatekeeperService.fetchAppWithHistory(id) map (app => Ok(Json.toJson(app))) recover recovery + } +} diff --git a/app/uk/gov/hmrc/controllers/Model.scala b/app/uk/gov/hmrc/controllers/Model.scala new file mode 100644 index 000000000..eccb7d36d --- /dev/null +++ b/app/uk/gov/hmrc/controllers/Model.scala @@ -0,0 +1,79 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.controllers + +import play.api.libs.json.Json.JsValueWrapper +import play.api.libs.json.{JsObject, Json} +import uk.gov.hmrc.models.Environment._ +import uk.gov.hmrc.models.{Collaborator, OverrideFlag} + +case class ValidationRequest(clientId: String, clientSecret: String) + +case class ClientSecretRequest(name: String) + +case class DeleteClientSecretsRequest(secrets: Seq[String]) + +case class UpliftRequest(applicationName: String, requestedByEmailAddress: String) + +case class ApproveUpliftRequest(gatekeeperUserId: String) + +case class RejectUpliftRequest(gatekeeperUserId: String, reason: String) + +case class ResendVerificationRequest(gatekeeperUserId: String) + +case class AddCollaboratorRequest(adminEmail: String, collaborator: Collaborator, isRegistered: Boolean, adminsToEmail: Set[String]) + +case class AddCollaboratorResponse(registeredUser: Boolean) + +case class ScopeRequest(scopes: Set[String]) + +case class ScopeResponse(scopes: Set[String]) + +case class OverridesRequest(overrides: Set[OverrideFlag]) + +case class OverridesResponse(overrides: Set[OverrideFlag]) + +case class UpdateRateLimitTierRequest(rateLimitTier: String) + +case class DeleteApplicationRequest(gatekeeperUserId: String, requestedByEmailAddress: String) + +object ErrorCode extends Enumeration { + type ErrorCode = Value + + val INVALID_REQUEST_PAYLOAD = Value("INVALID_REQUEST_PAYLOAD") + val UNAUTHORIZED = Value("UNAUTHORIZED") + val UNKNOWN_ERROR = Value("UNKNOWN_ERROR") + val APPLICATION_NOT_FOUND = Value("APPLICATION_NOT_FOUND") + val SCOPE_NOT_FOUND = Value("SCOPE_NOT_FOUND") + val INVALID_CREDENTIALS = Value("INVALID_CREDENTIALS") + val APPLICATION_ALREADY_EXISTS = Value("APPLICATION_ALREADY_EXISTS") + val SUBSCRIPTION_ALREADY_EXISTS = Value("SUBSCRIPTION_ALREADY_EXISTS") + val USER_ALREADY_EXISTS = Value("USER_ALREADY_EXISTS") + val APPLICATION_NEEDS_ADMIN = Value("APPLICATION_NEEDS_ADMIN") + val CLIENT_SECRET_LIMIT_EXCEEDED = Value("CLIENT_SECRET_LIMIT_EXCEEDED") + val INVALID_STATE_TRANSITION = Value("INVALID_STATE_TRANSITION") + val SUBSCRIPTION_NOT_FOUND = Value("SUBSCRIPTION_NOT_FOUND") +} + +object JsErrorResponse { + def apply(errorCode: ErrorCode.Value, message: JsValueWrapper): JsObject = + Json.obj( + "code" -> errorCode.toString, + "message" -> message + ) +} + diff --git a/app/uk/gov/hmrc/controllers/WSO2RestoreController.scala b/app/uk/gov/hmrc/controllers/WSO2RestoreController.scala new file mode 100644 index 000000000..b3457591c --- /dev/null +++ b/app/uk/gov/hmrc/controllers/WSO2RestoreController.scala @@ -0,0 +1,30 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.controllers + +import javax.inject.Inject + +import play.api.mvc.Action +import uk.gov.hmrc.services.WSO2RestoreService +import scala.concurrent.ExecutionContext.Implicits.global + +class WSO2RestoreController @Inject()(wso2RestoreService: WSO2RestoreService) extends CommonController { + + def restoreWSO2Data() = Action.async { + wso2RestoreService.restoreData().map(_ => NoContent) + } +} diff --git a/app/uk/gov/hmrc/models/APIDefinition.scala b/app/uk/gov/hmrc/models/APIDefinition.scala new file mode 100644 index 000000000..03d91d87d --- /dev/null +++ b/app/uk/gov/hmrc/models/APIDefinition.scala @@ -0,0 +1,70 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.models + +import java.util.UUID + +import play.api.Configuration +import play.api.libs.json._ +import uk.gov.hmrc.models.APIStatus.APIStatus + +case class APIDefinition(serviceName: String, + name: String, + context: String, + versions: Seq[APIVersion], + requiresTrust: Option[Boolean], + isTestSupport: Option[Boolean] = None) + +case class APIVersion(version: String, + status: APIStatus, + access: Option[APIAccess]) + +case class APIAccess(`type`: APIAccessType.Value, whitelistedApplicationIds: Option[Seq[String]]) + +object APIAccess { + def build(config: Option[Configuration]): APIAccess = APIAccess( + `type` = APIAccessType.PRIVATE, + whitelistedApplicationIds = config.flatMap(_.getStringSeq("whitelistedApplicationIds")).orElse(Some(Seq.empty))) +} + +object APIStatus extends Enumeration { + type APIStatus = Value + val ALPHA, BETA, STABLE, DEPRECATED, RETIRED = Value +} + +object APIAccessType extends Enumeration { + type APIAccessType = Value + val PRIVATE, PUBLIC = Value +} + +case class APISubscription(name: String, serviceName: String, context: String, versions: Seq[VersionSubscription], + requiresTrust: Option[Boolean], isTestSupport: Boolean = false) + +object APISubscription { + + def from(apiDefinition: APIDefinition, subscribedApis: Seq[APIIdentifier]): APISubscription = { + val versionSubscriptions: Seq[VersionSubscription] = apiDefinition.versions.map { v => + VersionSubscription(v, subscribedApis.exists(s => s.context == apiDefinition.context && s.version == v.version)) + } + APISubscription(apiDefinition.name, apiDefinition.serviceName, apiDefinition.context, versionSubscriptions, + apiDefinition.requiresTrust, apiDefinition.isTestSupport.getOrElse(false)) + } +} + +case class VersionSubscription(version: APIVersion, subscribed: Boolean) + +case class SubscriptionData(apiIdentifier: APIIdentifier, applications: Set[UUID]) diff --git a/app/uk/gov/hmrc/models/Application.scala b/app/uk/gov/hmrc/models/Application.scala new file mode 100644 index 000000000..847280e7c --- /dev/null +++ b/app/uk/gov/hmrc/models/Application.scala @@ -0,0 +1,432 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.models + +import java.security.MessageDigest +import java.util.UUID +import javax.inject.Inject + +import com.google.common.base.Charsets +import org.apache.commons.codec.binary.Base64 +import org.joda.time.DateTime +import play.api.libs.functional.syntax._ +import play.api.libs.json._ +import uk.gov.hmrc.config.AppContext +import uk.gov.hmrc.models.AccessType.{PRIVILEGED, ROPC, STANDARD} +import uk.gov.hmrc.models.Environment.Environment +import uk.gov.hmrc.models.RateLimitTier.{BRONZE, RateLimitTier} +import uk.gov.hmrc.models.Role.Role +import uk.gov.hmrc.models.State.{PRODUCTION, State, TESTING} +import uk.gov.hmrc.time.DateTimeUtils + + +trait ApplicationRequest { + val name: String + val description: Option[String] = None + val access: Access +} + +case class CreateApplicationRequest(override val name: String, + override val access: Access = Standard(Seq.empty, None, None, Set.empty), + override val description: Option[String] = None, + environment: Environment, + collaborators: Set[Collaborator]) extends ApplicationRequest { + + def normaliseCollaborators: CreateApplicationRequest = { + val normalised = collaborators.map(c => c.copy(emailAddress = c.emailAddress.toLowerCase)) + copy(collaborators = normalised) + } + + require(name.nonEmpty, s"name is required") + require(collaborators.exists(_.role == Role.ADMINISTRATOR), s"at least one ADMINISTRATOR collaborator is required") + require(collaborators.size == collaborators.map(_.emailAddress.toLowerCase).size, "duplicate email in collaborator") + access match { + case a: Standard => require(a.redirectUris.size <= 5, "maximum number of redirect URIs exceeded") + case _ => + } +} + +case class UpdateApplicationRequest(override val name: String, + override val access: Access = Standard(), + override val description: Option[String] = None) extends ApplicationRequest { + require(name.nonEmpty, s"name is required") + access match { + case a: Standard => require(a.redirectUris.size <= 5, "maximum number of redirect URIs exceeded") + case _ => + } +} + +case class ContactDetails(fullname: String, email: String, telephoneNumber: String) + +object ContactDetails { + implicit val formatContactDetails = Json.format[ContactDetails] +} + +case class TermsOfUseAgreement(emailAddress: String, timeStamp: DateTime, version: String) + +case class CheckInformation(contactDetails: Option[ContactDetails] = None, + confirmedName: Boolean = false, + apiSubscriptionsConfirmed: Boolean = false, + providedPrivacyPolicyURL: Boolean = false, + providedTermsAndConditionsURL: Boolean = false, + applicationDetails: Option[String] = None, + termsOfUseAgreements: Seq[TermsOfUseAgreement] = Seq.empty) + +case class ApplicationResponse(id: UUID, + clientId: String, + name: String, + deployedTo: String, + description: Option[String] = None, + collaborators: Set[Collaborator], + createdOn: DateTime, + redirectUris: Seq[String] = Seq.empty, + termsAndConditionsUrl: Option[String] = None, + privacyPolicyUrl: Option[String] = None, + access: Access = Standard(Seq.empty, None, None), + environment: Option[Environment] = None, + state: ApplicationState = ApplicationState(name = TESTING), + rateLimitTier: RateLimitTier = BRONZE, + trusted: Boolean = false, + checkInformation: Option[CheckInformation] = None) + +object ApplicationResponse { + private def getEnvironment(data: ApplicationData, clientId: Option[String]): Option[Environment] = { + clientId match { + case Some(data.tokens.production.clientId) => Some(Environment.PRODUCTION) + case Some(data.tokens.sandbox.clientId) => Some(Environment.SANDBOX) + case _ => None + } + } + + def apply(data: ApplicationData, clientId: Option[String], trusted: Boolean): ApplicationResponse = { + val redirectUris = data.access match { + case a: Standard => a.redirectUris + case _ => Seq() + } + val termsAndConditionsUrl = data.access match { + case a: Standard => a.termsAndConditionsUrl + case _ => None + } + val privacyPolicyUrl = data.access match { + case a: Standard => a.privacyPolicyUrl + case _ => None + } + + ApplicationResponse( + data.id, + data.tokens.production.clientId, + data.name, + data.environment, + data.description, + data.collaborators, + data.createdOn, + redirectUris, + termsAndConditionsUrl, + privacyPolicyUrl, + data.access, + getEnvironment(data, clientId), + data.state, + data.rateLimitTier.getOrElse(BRONZE), + trusted, + data.checkInformation) + } +} + +case class ApplicationData(id: UUID, + name: String, + normalisedName: String, + collaborators: Set[Collaborator], + description: Option[String] = None, + wso2Username: String, + wso2Password: String, + wso2ApplicationName: String, + tokens: ApplicationTokens, + state: ApplicationState, + access: Access = Standard(Seq.empty, None, None), + createdOn: DateTime = DateTimeUtils.now, + rateLimitTier: Option[RateLimitTier] = Some(BRONZE), + environment: String = Environment.PRODUCTION.toString, + checkInformation: Option[CheckInformation] = None) { + lazy val admins = collaborators.filter(_.role == Role.ADMINISTRATOR) +} + +case class CreateApplicationResponse(application: ApplicationResponse, totp: Option[TotpSecrets] = None) + +object AccessType extends Enumeration { + type AccessType = Value + val STANDARD, PRIVILEGED, ROPC = Value +} + +sealed trait Access { + val accessType: AccessType.Value +} + +case class Standard(redirectUris: Seq[String] = Seq.empty, + termsAndConditionsUrl: Option[String] = None, + privacyPolicyUrl: Option[String] = None, + overrides: Set[OverrideFlag] = Set.empty) extends Access { + override val accessType = STANDARD +} + +case class Privileged(totpIds: Option[TotpIds] = None, scopes: Set[String] = Set.empty) extends Access { + override val accessType = PRIVILEGED +} + +case class Ropc(scopes: Set[String] = Set.empty) extends Access { + override val accessType = ROPC +} + +sealed trait OverrideFlag { + val overrideType: OverrideType.Value +} + +case class PersistLogin() extends OverrideFlag { + val overrideType = OverrideType.PERSIST_LOGIN_AFTER_GRANT +} + +case class SuppressIvForAgents(scopes: Set[String]) extends OverrideFlag { + val overrideType = OverrideType.SUPPRESS_IV_FOR_AGENTS +} + +case class SuppressIvForOrganisations(scopes: Set[String]) extends OverrideFlag { + val overrideType = OverrideType.SUPPRESS_IV_FOR_ORGANISATIONS +} + +case class GrantWithoutConsent(scopes: Set[String]) extends OverrideFlag { + val overrideType = OverrideType.GRANT_WITHOUT_TAXPAYER_CONSENT +} + +object OverrideType extends Enumeration { + val PERSIST_LOGIN_AFTER_GRANT, GRANT_WITHOUT_TAXPAYER_CONSENT, SUPPRESS_IV_FOR_AGENTS, SUPPRESS_IV_FOR_ORGANISATIONS = Value +} + + +case class ApplicationWithUpliftRequest(id: UUID, + name: String, + submittedOn: DateTime, + state: State) + +case class ApplicationWithHistory(application: ApplicationResponse, history: Seq[StateHistoryResponse]) + +case class APIIdentifier(context: String, version: String) + +case class WSO2API(name: String, version: String) + +case class Collaborator(emailAddress: String, role: Role) + +case class ApplicationTokens(production: EnvironmentToken, + sandbox: EnvironmentToken) { + + def environmentToken(environment: Environment) = { + environment match { + case Environment.PRODUCTION => production + case _ => sandbox + } + } +} + +case class ClientSecret(name: String, + secret: String = UUID.randomUUID().toString, + createdOn: DateTime = DateTimeUtils.now) + +case class EnvironmentToken(clientId: String, + wso2ClientSecret: String, + accessToken: String, + clientSecrets: Seq[ClientSecret] = Seq(ClientSecret("Default"))) + +case class ApplicationTokensResponse(production: EnvironmentTokenResponse, + sandbox: EnvironmentTokenResponse) + +case class EnvironmentTokenResponse(clientId: String, + accessToken: String, + clientSecrets: Seq[ClientSecret]) + +case class Wso2Credentials(clientId: String, + accessToken: String, + wso2Secret: String) + +object Role extends Enumeration { + type Role = Value + val DEVELOPER, ADMINISTRATOR = Value + +} + +object Environment extends Enumeration { + type Environment = Value + val PRODUCTION, SANDBOX = Value + + def from(env: String) = Environment.values.find(e => e.toString == env.toUpperCase) +} + +object State extends Enumeration { + type State = Value + val TESTING, PENDING_GATEKEEPER_APPROVAL, PENDING_REQUESTER_VERIFICATION, PRODUCTION = Value + +} + +case class ApplicationState(name: State = TESTING, requestedByEmailAddress: Option[String] = None, + verificationCode: Option[String] = None, updatedOn: DateTime = DateTimeUtils.now) { + + final def requireState(requirement: State, transitionTo: State): Unit = { + if (name != requirement) { + throw new InvalidStateTransition(expectedFrom = requirement, invalidFrom = name, to = transitionTo) + } + } + + def toProduction = { + requireState(requirement = State.PENDING_REQUESTER_VERIFICATION, transitionTo = PRODUCTION) + copy(name = PRODUCTION, updatedOn = DateTimeUtils.now) + } + + def toTesting = copy(name = TESTING, requestedByEmailAddress = None, verificationCode = None, updatedOn = DateTimeUtils.now) + + def toPendingGatekeeperApproval(requestedByEmailAddress: String) = { + requireState(requirement = TESTING, transitionTo = State.PENDING_GATEKEEPER_APPROVAL) + + copy(name = State.PENDING_GATEKEEPER_APPROVAL, + updatedOn = DateTimeUtils.now, + requestedByEmailAddress = Some(requestedByEmailAddress)) + } + + def toPendingRequesterVerification = { + requireState(requirement = State.PENDING_GATEKEEPER_APPROVAL, transitionTo = State.PENDING_REQUESTER_VERIFICATION) + + def verificationCode(input: String = UUID.randomUUID().toString): String = { + def urlSafe(encoded: String) = encoded.replace("=", "").replace("/", "_").replace("+", "-") + + val digest = MessageDigest.getInstance("SHA-256") + urlSafe(new String(Base64.encodeBase64(digest.digest(input.getBytes(Charsets.UTF_8))), Charsets.UTF_8)) + } + + copy(name = State.PENDING_REQUESTER_VERIFICATION, verificationCode = Some(verificationCode()), updatedOn = DateTimeUtils.now) + } + +} + +class ApplicationResponseCreator @Inject()(appContext: AppContext) { + + def createApplicationResponse(applicationData: ApplicationData, totpSecrets: Option[TotpSecrets]) = { + CreateApplicationResponse(ApplicationResponse(applicationData, None, appContext.isTrusted(applicationData)), totpSecrets) + } + + private def getEnvironment(data: ApplicationData, clientId: Option[String]): Option[Environment] = { + clientId match { + case Some(data.tokens.production.clientId) => Some(Environment.PRODUCTION) + case Some(data.tokens.sandbox.clientId) => Some(Environment.SANDBOX) + case _ => None + } + } +} + +object ApplicationWithUpliftRequest { + def create(app: ApplicationData, upliftRequest: StateHistory): ApplicationWithUpliftRequest = { + if (upliftRequest.state != State.PENDING_GATEKEEPER_APPROVAL) { + throw new InconsistentDataState(s"cannot create with invalid state: ${upliftRequest.state}") + } + ApplicationWithUpliftRequest(app.id, app.name, upliftRequest.changedAt, app.state.name) + } + +} + +object ApplicationTokensResponse { + def create(applicationTokens: ApplicationTokens): ApplicationTokensResponse = { + ApplicationTokensResponse( + EnvironmentTokenResponse.create(applicationTokens.production), + EnvironmentTokenResponse.create(applicationTokens.sandbox) + ) + } +} + +object EnvironmentTokenResponse { + def create(environmentToken: EnvironmentToken): EnvironmentTokenResponse = { + EnvironmentTokenResponse(environmentToken.clientId, environmentToken.accessToken, environmentToken.clientSecrets) + } +} + +object ApplicationData { + + def create(application: CreateApplicationRequest, + wso2Username: String, + wso2Password: String, + wso2ApplicationName: String, + tokens: ApplicationTokens): ApplicationData = { + + val applicationState = (application.environment, application.access.accessType) match { + case (Environment.SANDBOX, _) => ApplicationState(PRODUCTION) + case (_, PRIVILEGED | ROPC) => ApplicationState(PRODUCTION, application.collaborators.headOption.map(_.emailAddress)) + case _ => ApplicationState(TESTING) + } + + ApplicationData( + UUID.randomUUID, + application.name, + application.name.toLowerCase, + application.collaborators, + application.description, + wso2Username, + wso2Password, + wso2ApplicationName, + tokens, + applicationState, + application.access, + environment = application.environment.toString) + } +} + +object WSO2API { + + def create(api: APIIdentifier) = { + WSO2API(name(api), api.version) + } + + private def name(api: APIIdentifier) = { + s"${api.context.replaceAll("/", "--")}--${api.version}" + } + +} + +object APIIdentifier { + + def create(wso2API: WSO2API) = { + APIIdentifier(context(wso2API), wso2API.version) + } + + private def context(wso2API: WSO2API) = { + wso2API.name.dropRight(s"--${wso2API.version}".length).replaceAll("--", "/") + } + +} + +object RateLimitTier extends Enumeration { + type RateLimitTier = Value + + val PLATINUM, GOLD, SILVER, BRONZE = Value +} + +sealed trait ApplicationStateChange + +case object UpliftRequested extends ApplicationStateChange + +case object UpliftApproved extends ApplicationStateChange + +case object UpliftRejected extends ApplicationStateChange + +case object UpliftVerified extends ApplicationStateChange + +case object VerificationResent extends ApplicationStateChange + +case object Deleted extends ApplicationStateChange \ No newline at end of file diff --git a/app/uk/gov/hmrc/models/Authorisation.scala b/app/uk/gov/hmrc/models/Authorisation.scala new file mode 100644 index 000000000..007885243 --- /dev/null +++ b/app/uk/gov/hmrc/models/Authorisation.scala @@ -0,0 +1,26 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.models + +import play.api.libs.json.Json + +case class AuthRole(scope: String, name: String) + +object AuthRole { + implicit val format = Json.format[AuthRole] + val APIGatekeeper = AuthRole("api", "gatekeeper") +} diff --git a/app/uk/gov/hmrc/models/Developer.scala b/app/uk/gov/hmrc/models/Developer.scala new file mode 100644 index 000000000..fbdaad42a --- /dev/null +++ b/app/uk/gov/hmrc/models/Developer.scala @@ -0,0 +1,30 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.models + +import org.joda.time.DateTime +import play.api.libs.json.Json + +case class UserResponse(email: String, + firstName: String, + lastName: String, + registrationTime: DateTime, + lastModified: DateTime) + +object UserResponse { + implicit val format = Json.format[UserResponse] +} \ No newline at end of file diff --git a/app/uk/gov/hmrc/models/Errors.scala b/app/uk/gov/hmrc/models/Errors.scala new file mode 100644 index 000000000..29417d28a --- /dev/null +++ b/app/uk/gov/hmrc/models/Errors.scala @@ -0,0 +1,41 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.models + +import uk.gov.hmrc.models.State.State + +case class InvalidUpliftVerificationCode(code: String) extends RuntimeException(s"Invalid verification code '$code'") + +class UserAlreadyExists extends RuntimeException + +class ApplicationNeedsAdmin extends RuntimeException + +class ClientSecretsLimitExceeded extends RuntimeException + +class InvalidStateTransition(invalidFrom: State, to: State, expectedFrom: State) + extends RuntimeException(s"Transition to '$to' state requires the application to be in '$expectedFrom' state, but it was in '$invalidFrom'") + +class InconsistentDataState(message: String) extends RuntimeException(message) + +case class ApplicationAlreadyExists(applicationName: String) extends RuntimeException + +case class SubscriptionAlreadyExistsException(name: String, api: APIIdentifier) + extends RuntimeException(s"Application: '$name' is already Subscribed to API: ${api.context}: ${api.version}") + +case class ScopeNotFoundException(scope: String) extends RuntimeException(s"Scope '$scope' not found") + +case class OverrideNotFoundException(anOverride: String) extends RuntimeException(s"Override '$anOverride' not found") diff --git a/app/uk/gov/hmrc/models/HasSucceeded.scala b/app/uk/gov/hmrc/models/HasSucceeded.scala new file mode 100644 index 000000000..968f9d648 --- /dev/null +++ b/app/uk/gov/hmrc/models/HasSucceeded.scala @@ -0,0 +1,24 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.models + +/** + * Used instead of Unit where a method has nothing to return because Unit prevents + * Scala's type checking from working on mappings of Future[Unit]. + */ +trait HasSucceeded +object HasSucceeded extends HasSucceeded diff --git a/app/uk/gov/hmrc/models/JsonFormatters.scala b/app/uk/gov/hmrc/models/JsonFormatters.scala new file mode 100644 index 000000000..4361e46b2 --- /dev/null +++ b/app/uk/gov/hmrc/models/JsonFormatters.scala @@ -0,0 +1,231 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.models + +import play.api.libs.functional.syntax._ +import play.api.libs.json._ +import uk.gov.hmrc.controllers._ +import uk.gov.hmrc.models.AccessType.{PRIVILEGED, ROPC, STANDARD} +import uk.gov.hmrc.models.OverrideType._ +import uk.gov.hmrc.mongo.json.ReactiveMongoFormats +import uk.gov.hmrc.play.json.Union +import uk.gov.hmrc.services.WSO2RestoreData + +import scala.language.implicitConversions + +object JsonFormatters { + implicit val formatRole = EnumJson.enumFormat(Role) + implicit val formatEnvironment = EnumJson.enumFormat(Environment) + implicit val formatAccessType = EnumJson.enumFormat(AccessType) + implicit val formatState = EnumJson.enumFormat(State) + implicit val formatRateLimitTier = EnumJson.enumFormat(RateLimitTier) + + private implicit val formatGrantWithoutConsent = Json.format[GrantWithoutConsent] + private implicit val formatPersistLogin = Format[PersistLogin]( + Reads { _ => JsSuccess(PersistLogin()) }, + Writes { _ => Json.obj() }) + private implicit val formatSuppressIvForAgents = Json.format[SuppressIvForAgents] + private implicit val formatSuppressIvForOrganisations = Json.format[SuppressIvForOrganisations] + + implicit val formatOverride = Union.from[OverrideFlag]("overrideType") + .and[GrantWithoutConsent](GRANT_WITHOUT_TAXPAYER_CONSENT.toString) + .and[PersistLogin](PERSIST_LOGIN_AFTER_GRANT.toString) + .and[SuppressIvForAgents](SUPPRESS_IV_FOR_AGENTS.toString) + .and[SuppressIvForOrganisations](SUPPRESS_IV_FOR_ORGANISATIONS.toString) + .format + + implicit val formatTotp = Json.format[TOTP] + implicit val formatTotpIds = Json.format[TotpIds] + implicit val formatTotpSecrets = Json.format[TotpSecrets] + + private implicit val formatStandard = Json.format[Standard] + private implicit val formatPrivileged = Json.format[Privileged] + private implicit val formatRopc = Json.format[Ropc] + + implicit val formatAccess = Union.from[Access]("accessType") + .and[Standard](STANDARD.toString) + .and[Privileged](PRIVILEGED.toString) + .and[Ropc](ROPC.toString) + .format + + implicit val formatTermsOfUseAgreement = Json.format[TermsOfUseAgreement] + + val checkInformationReads: Reads[CheckInformation] = ( + (JsPath \ "contactDetails").readNullable[ContactDetails] and + (JsPath \ "confirmedName").read[Boolean] and + ((JsPath \ "apiSubscriptionsConfirmed").read[Boolean] or Reads.pure(false)) and + (JsPath \ "providedPrivacyPolicyURL").read[Boolean] and + (JsPath \ "providedTermsAndConditionsURL").read[Boolean] and + (JsPath \ "applicationDetails").readNullable[String] and + ((JsPath \ "termsOfUseAgreements").read[Seq[TermsOfUseAgreement]] or Reads.pure(Seq.empty[TermsOfUseAgreement])) + )(CheckInformation.apply _) + + implicit val checkInformationFormat = { + Format(checkInformationReads, Json.writes[CheckInformation]) + } + + implicit val formatAPIStatus = APIStatusJson.apiStatusFormat(APIStatus) + implicit val formatAPIAccessType = EnumJson.enumFormat(APIAccessType) + implicit val formatAPIAccess = Json.format[APIAccess] + implicit val formatAPIVersion = Json.format[APIVersion] + implicit val formatVersionSubscription = Json.format[VersionSubscription] + implicit val formatApiSubscription = Json.format[APISubscription] + + val apiDefinitionReads: Reads[APIDefinition] = ( + (JsPath \ "serviceName").read[String] and + (JsPath \ "name").read[String] and + (JsPath \ "context").read[String] and + (JsPath \ "versions").read[Seq[APIVersion]] and + (JsPath \ "requiresTrust").readNullable[Boolean] and + (JsPath \ "isTestSupport").readNullable[Boolean] + ) (APIDefinition.apply _) + + implicit val formatAPIDefinition = { + Format(apiDefinitionReads, Json.writes[APIDefinition]) + } + + implicit val formatApplicationState = Json.format[ApplicationState] + implicit val formatApiIdentifier = Json.format[APIIdentifier] + implicit val formatCollaborator = Json.format[Collaborator] + implicit val formatClientSecret = Json.format[ClientSecret] + implicit val formatEnvironmentToken = Json.format[EnvironmentToken] + implicit val formatApplicationTokens = Json.format[ApplicationTokens] + implicit val formatSubscriptionData = Json.format[SubscriptionData] + implicit val formatApplicationData = Json.format[ApplicationData] + implicit val formatCreateApplicationRequest = Json.format[CreateApplicationRequest] + implicit val formatUpdateApplicationRequest = Json.format[UpdateApplicationRequest] + implicit val formatApplicationResponse = Json.format[ApplicationResponse] + implicit val formatUpdateRateLimitTierRequest = Json.format[UpdateRateLimitTierRequest] + implicit val formatApplicationWithHistory = Json.format[ApplicationWithHistory] + implicit val formatEnvironmentTokenResponse = Json.format[EnvironmentTokenResponse] + implicit val formatApplicationTokensResponse = Json.format[ApplicationTokensResponse] + implicit val formatWso2Credentials = Json.format[Wso2Credentials] + + implicit val formatValidationRequest = Json.format[ValidationRequest] + implicit val formatClientSecretRequest = Json.format[ClientSecretRequest] + implicit val formatUpliftRequest = Json.format[UpliftRequest] + implicit val formatApproveUpliftRequest = Json.format[ApproveUpliftRequest] + implicit val formatRejectUpliftRequest = Json.format[RejectUpliftRequest] + implicit val formatResendVerificationRequest = Json.format[ResendVerificationRequest] + implicit val formatAddCollaboratorRequest = Json.format[AddCollaboratorRequest] + implicit val formatAddCollaboratorResponse = Json.format[AddCollaboratorResponse] + implicit val formatScopeRequest = Json.format[ScopeRequest] + implicit val formatScopeResponse = Json.format[ScopeResponse] + implicit val formatOverridesRequest = Json.format[OverridesRequest] + implicit val formatOverridesResponse = Json.format[OverridesResponse] + implicit val formatApplicationWithUpliftRequest = Json.format[ApplicationWithUpliftRequest] + implicit val formatDeleteApplicationRequest = Json.format[DeleteApplicationRequest] + implicit val formatDeleteClientSecretRequest = Json.format[DeleteClientSecretsRequest] + + implicit val createApplicationResponseWrites: Writes[CreateApplicationResponse] = ( + JsPath.write[ApplicationResponse] and (JsPath \ "totp").write[Option[TotpSecrets]] + )(unlift(CreateApplicationResponse.unapply)) +} + + +object MongoFormat { + implicit val dateFormat = ReactiveMongoFormats.dateTimeFormats + + implicit val formatTermsOfUseAgreement = Json.format[TermsOfUseAgreement] + + val checkInformationReads: Reads[CheckInformation] = ( + (JsPath \ "contactDetails").readNullable[ContactDetails] and + (JsPath \ "confirmedName").read[Boolean] and + ((JsPath \ "apiSubscriptionsConfirmed").read[Boolean] or Reads.pure(false)) and + (JsPath \ "providedPrivacyPolicyURL").read[Boolean] and + (JsPath \ "providedTermsAndConditionsURL").read[Boolean] and + (JsPath \ "applicationDetails").readNullable[String] and + ((JsPath \ "termsOfUseAgreements").read[Seq[TermsOfUseAgreement]] or Reads.pure(Seq.empty[TermsOfUseAgreement])) + )(CheckInformation.apply _) + + implicit val checkInformationFormat = { + Format(checkInformationReads, Json.writes[CheckInformation]) + } + + implicit val formatAccessType = JsonFormatters.formatAccessType + implicit val formatRole = JsonFormatters.formatRole + implicit val formatState = JsonFormatters.formatState + implicit val formatRateLimitTier = JsonFormatters.formatRateLimitTier + implicit val formatAccess = JsonFormatters.formatAccess + implicit val formatApplicationState = Json.format[ApplicationState] + implicit val formatCollaborator = Json.format[Collaborator] + implicit val formatClientSecret = Json.format[ClientSecret] + implicit val formatEnvironmentToken = Json.format[EnvironmentToken] + implicit val formatApplicationTokens = Json.format[ApplicationTokens] + implicit val formatApiIdentifier = Json.format[APIIdentifier] + implicit val formatSubscriptionData = Json.format[SubscriptionData] + implicit val formatApplicationData = Json.format[ApplicationData] + implicit val formatWSO2RestoreData = Json.format[WSO2RestoreData] +} + + +object EnumJson { + + def enumReads[E <: Enumeration](enum: E): Reads[E#Value] = new Reads[E#Value] { + def reads(json: JsValue): JsResult[E#Value] = json match { + case JsString(s) => { + try { + JsSuccess(enum.withName(s)) + } catch { + case _: NoSuchElementException => + throw new InvalidEnumException(enum.getClass.getSimpleName, s) + } + } + case _ => JsError("String value expected") + } + } + + implicit def enumWrites[E <: Enumeration]: Writes[E#Value] = new Writes[E#Value] { + def writes(v: E#Value): JsValue = JsString(v.toString) + } + + implicit def enumFormat[E <: Enumeration](enum: E): Format[E#Value] = { + Format(enumReads(enum), enumWrites) + } + +} + +class InvalidEnumException(className: String, input:String) + extends RuntimeException(s"Enumeration expected of type: '$className', but it does not contain '$input'") + +object APIStatusJson { + + def apiStatusReads[APIStatus](apiStatus: APIStatus): Reads[APIStatus.Value] = new Reads[APIStatus.Value] { + def reads(json: JsValue): JsResult[APIStatus.Value] = json match { + case JsString("PROTOTYPED") => JsSuccess(APIStatus.BETA) + case JsString("PUBLISHED") => JsSuccess(APIStatus.STABLE) + case JsString(s) => { + try { + JsSuccess(APIStatus.withName(s)) + } catch { + case _: NoSuchElementException => + JsError(s"Enumeration expected of type: APIStatus, but it does not contain '$s'") + } + } + case _ => JsError("String value expected") + } + } + + implicit def apiStatusWrites: Writes[APIStatus.Value] = new Writes[APIStatus.Value] { + def writes(v: APIStatus.Value): JsValue = JsString(v.toString) + } + + implicit def apiStatusFormat[APIStatus](apiStatus: APIStatus): Format[APIStatus.Value] = { + Format(apiStatusReads(apiStatus), apiStatusWrites) + } + +} \ No newline at end of file diff --git a/app/uk/gov/hmrc/models/StateHistory.scala b/app/uk/gov/hmrc/models/StateHistory.scala new file mode 100644 index 000000000..4b32dc281 --- /dev/null +++ b/app/uk/gov/hmrc/models/StateHistory.scala @@ -0,0 +1,65 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.models + +import java.util.UUID + +import org.joda.time.DateTime +import play.api.libs.json.Json +import uk.gov.hmrc.models.ActorType.ActorType +import uk.gov.hmrc.models.State.State +import uk.gov.hmrc.mongo.json.ReactiveMongoFormats +import uk.gov.hmrc.time.DateTimeUtils + +object ActorType extends Enumeration { + type ActorType = Value + val COLLABORATOR, GATEKEEPER, SCHEDULED_JOB = Value + + implicit val format = EnumJson.enumFormat(ActorType) +} + +case class Actor(id: String, actorType: ActorType) + +case class StateHistory(applicationId: UUID, + state: State, + actor: Actor, + previousState: Option[State] = None, + notes: Option[String] = None, + changedAt: DateTime = DateTimeUtils.now) + +object StateHistory { + implicit def dateTimeOrdering: Ordering[DateTime] = Ordering.fromLessThan(_ isBefore _) + + implicit val format1 = EnumJson.enumFormat(State) + implicit val format2 = Json.format[Actor] + implicit val dateFormat = ReactiveMongoFormats.dateTimeFormats + implicit val format = Json.format[StateHistory] +} + +case class StateHistoryResponse(applicationId: UUID, + state: State, + actor: Actor, + notes: Option[String], + changedAt: DateTime) + +object StateHistoryResponse { + def from(sh: StateHistory) = StateHistoryResponse(sh.applicationId, sh.state, sh.actor, sh.notes, sh.changedAt) + + implicit val formatState = EnumJson.enumFormat(State) + implicit val formatActor = Json.format[Actor] + implicit val format = Json.format[StateHistoryResponse] +} diff --git a/app/uk/gov/hmrc/models/TOTP.scala b/app/uk/gov/hmrc/models/TOTP.scala new file mode 100644 index 000000000..fc18639cf --- /dev/null +++ b/app/uk/gov/hmrc/models/TOTP.scala @@ -0,0 +1,25 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.models + + +case class TOTP(secret: String, id: String) +case class ApplicationTotps(production: TOTP, sandbox: TOTP) + +case class TotpIds(production: String, sandbox: String) + +case class TotpSecrets(production: String, sandbox: String) diff --git a/app/uk/gov/hmrc/repository/ApplicationRepository.scala b/app/uk/gov/hmrc/repository/ApplicationRepository.scala new file mode 100644 index 000000000..3950cfac9 --- /dev/null +++ b/app/uk/gov/hmrc/repository/ApplicationRepository.scala @@ -0,0 +1,219 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.repository + +import java.util.UUID +import javax.inject.{Inject, Singleton} + +import org.joda.time.DateTime +import play.api.libs.json.Json._ +import play.api.libs.json._ +import play.modules.reactivemongo.ReactiveMongoComponent +import reactivemongo.api.commands.Command +import reactivemongo.bson.{BSONArray, BSONBoolean, BSONDocument, BSONObjectID} +import reactivemongo.core.commands.{Match, PipelineOperator, Project} +import reactivemongo.play.json.ImplicitBSONHandlers._ +import reactivemongo.play.json.JSONSerializationPack +import uk.gov.hmrc.models.MongoFormat._ +import uk.gov.hmrc.models._ +import uk.gov.hmrc.mongo.ReactiveRepository +import uk.gov.hmrc.mongo.json.ReactiveMongoFormats +import uk.gov.hmrc.util.mongo.IndexHelper._ + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +@Singleton +class ApplicationRepository @Inject()(mongo: ReactiveMongoComponent) + extends ReactiveRepository[ApplicationData, BSONObjectID]("application", mongo.mongoConnector.db, + MongoFormat.formatApplicationData, ReactiveMongoFormats.objectIdFormats) { + + implicit val dateFormat = ReactiveMongoFormats.dateTimeFormats + + private val applicationProjection = Project( + "id" -> BSONBoolean(true), + "name" -> BSONBoolean(true), + "normalisedName" -> BSONBoolean(true), + "collaborators" -> BSONBoolean(true), + "description" -> BSONBoolean(true), + "wso2Username" -> BSONBoolean(true), + "wso2Password" -> BSONBoolean(true), + "wso2ApplicationName" -> BSONBoolean(true), + "tokens" -> BSONBoolean(true), + "state" -> BSONBoolean(true), + "access" -> BSONBoolean(true), + "createdOn" -> BSONBoolean(true), + "rateLimitTier" -> BSONBoolean(true), + "environment" -> BSONBoolean(true)) + + override def indexes = Seq( + createSingleFieldAscendingIndex( + indexFieldKey = "state.verificationCode", + indexName = Some("verificationCodeIndex") + ), + createAscendingIndex( + indexName = Some("stateName_stateUpdatedOn_Index"), + isUnique = false, + isBackground = true, + indexFieldsKey = List("state.name", "state.updatedOn"): _* + ), + createSingleFieldAscendingIndex( + indexFieldKey = "id", + indexName = Some("applicationIdIndex"), + isUnique = true + ), + createSingleFieldAscendingIndex( + indexFieldKey = "normalisedName", + indexName = Some("applicationNormalisedNameIndex") + ), + createSingleFieldAscendingIndex( + indexFieldKey = "tokens.production.clientId", + indexName = Some("productionTokenClientIdIndex"), + isUnique = true + ), + createSingleFieldAscendingIndex( + indexFieldKey = "tokens.sandbox.clientId", + indexName = Some("sandboxTokenClientIdIndex"), + isUnique = true + ), + createSingleFieldAscendingIndex( + indexFieldKey = "access.overrides", + indexName = Some("accessOverridesIndex") + ), + createSingleFieldAscendingIndex( + indexFieldKey = "access.accessType", + indexName = Some("accessTypeIndex") + ), + createSingleFieldAscendingIndex( + indexFieldKey = "collaborators.emailAddress", + indexName = Some("collaboratorsEmailAddressIndex") + ) + ) + + def save(application: ApplicationData): Future[ApplicationData] = { + collection.find(Json.obj("id" -> application.id.toString)).one[BSONDocument].flatMap { + case Some(document) => collection.update(selector = BSONDocument("_id" -> document.get("_id")), update = application) + case None => collection.insert(application) + }.map(_ => application) + } + + def fetchStandardNonTestingApps(): Future[Seq[ApplicationData]] = { + collection.find(Json.obj("$and" -> Json.arr( + Json.obj("state.name" -> Json.obj("$ne" -> State.TESTING)), + Json.obj("access.accessType" -> Json.obj("$eq" -> AccessType.STANDARD)) + ))).cursor[ApplicationData]().collect[Seq]() + } + + def fetch(id: UUID): Future[Option[ApplicationData]] = { + collection.find(Json.obj("id" -> id)).one[ApplicationData] + } + + def fetchNonTestingApplicationByName(name: String): Future[Option[ApplicationData]] = { + collection.find(Json.obj("$and" -> Json.arr( + Json.obj("normalisedName" -> name.toLowerCase), + Json.obj("state.name" -> Json.obj("$ne" -> State.TESTING))))).one[ApplicationData] + } + + + def fetchVerifiableUpliftBy(verificationCode: String): Future[Option[ApplicationData]] = { + collection.find(Json.obj("state.verificationCode" -> verificationCode)).one[ApplicationData] + } + + + def fetchAllByStatusDetails(state: State.State, updatedBefore: DateTime): Future[Seq[ApplicationData]] = { + find("state.name" -> state, "state.updatedOn" -> Json.obj("$lte" -> updatedBefore)) + } + + + def fetchByClientId(clientId: String): Future[Option[ApplicationData]] = { + collection + .find( + Json.obj("$or" -> Json.arr( + Json.obj("tokens.production.clientId" -> clientId), + Json.obj("tokens.sandbox.clientId" -> clientId)))) + .one[ApplicationData] + } + + def fetchByServerToken(serverToken: String): Future[Option[ApplicationData]] = { + collection + .find( + Json.obj("$or" -> Json.arr( + Json.obj("tokens.production.accessToken" -> serverToken), + Json.obj("tokens.sandbox.accessToken" -> serverToken)))) + .one[ApplicationData] + } + + def fetchAllForEmailAddress(emailAddress: String): Future[Seq[ApplicationData]] = { + find("collaborators.emailAddress" -> emailAddress) + } + + def fetchAllForEmailAddressAndEnvironment(emailAddress: String, environment: String): Future[Seq[ApplicationData]] = { + find("collaborators.emailAddress" -> emailAddress, "environment" -> environment) + } + + private def processResults[T](json: JsObject)(implicit fjs: Reads[T]): Future[T] = { + (json \ "result").validate[T] match { + case JsSuccess(result, _) => Future.successful(result) + case JsError(errors) => Future.failed(new RuntimeException((json \ "errmsg").asOpt[String].getOrElse(errors.mkString(",")))) + } + } + + private def lookupByAPI(operators: PipelineOperator*): Future[Seq[ApplicationData]] = { + val lookup: BSONDocument = BSONDocument( + "$lookup" -> BSONDocument( + "from" -> "subscription", + "localField" -> "id", + "foreignField" -> "applications", + "as" -> "subscribedApis")) + + val commandDoc: BSONDocument = BSONDocument( + "aggregate" -> "application", + "pipeline" -> BSONArray(lookup +: operators.map(_.makePipe))) + + val runner = Command.run(JSONSerializationPack) + runner.apply(collection.db, runner.rawCommand(commandDoc)) + .one[JsObject] + .flatMap(processResults[Seq[ApplicationData]]) + } + + def fetchAllForContext(apiContext: String): Future[Seq[ApplicationData]] = + lookupByAPI( + Match(BSONDocument("subscribedApis.apiIdentifier.context" -> apiContext)), + applicationProjection) + + def fetchAllForApiIdentifier(apiIdentifier: APIIdentifier): Future[Seq[ApplicationData]] = + lookupByAPI( + Match(BSONDocument("subscribedApis.apiIdentifier" -> BSONDocument("context" -> apiIdentifier.context, "version" -> apiIdentifier.version))), + applicationProjection) + + def fetchAllWithNoSubscriptions(): Future[Seq[ApplicationData]] = + lookupByAPI( + Match(BSONDocument("subscribedApis" -> BSONDocument("$size" -> 0))), + applicationProjection) + + def fetchAll(): Future[Seq[ApplicationData]] = { + collection.find(Json.obj()).cursor[ApplicationData]().collect[Seq]() + } + + def delete(id: UUID): Future[HasSucceeded] = { + collection.remove(Json.obj("id" -> id)).map(_ => HasSucceeded) + } +} + +sealed trait ApplicationModificationResult +final case class SuccessfulApplicationModificationResult(numberOfDocumentsUpdated: Int) extends ApplicationModificationResult +final case class UnsuccessfulApplicationModificationResult(message: Option[String]) extends ApplicationModificationResult diff --git a/app/uk/gov/hmrc/repository/StateHistoryRepository.scala b/app/uk/gov/hmrc/repository/StateHistoryRepository.scala new file mode 100644 index 000000000..12fadacae --- /dev/null +++ b/app/uk/gov/hmrc/repository/StateHistoryRepository.scala @@ -0,0 +1,81 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.repository + +import java.util.UUID +import javax.inject.{Inject, Singleton} + +import play.api.libs.json.Json +import play.api.libs.json.Json._ +import play.modules.reactivemongo.ReactiveMongoComponent +import reactivemongo.bson.BSONObjectID +import reactivemongo.play.json.ImplicitBSONHandlers._ +import uk.gov.hmrc.models.JsonFormatters._ +import uk.gov.hmrc.models.State.State +import uk.gov.hmrc.models.StateHistory.dateTimeOrdering +import uk.gov.hmrc.models.{HasSucceeded, StateHistory} +import uk.gov.hmrc.mongo.ReactiveRepository +import uk.gov.hmrc.mongo.json.ReactiveMongoFormats +import uk.gov.hmrc.util.mongo.IndexHelper._ + +import scala.collection.Seq +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +@Singleton +class StateHistoryRepository @Inject()(mongo: ReactiveMongoComponent) + extends ReactiveRepository[StateHistory, BSONObjectID]("stateHistory", mongo.mongoConnector.db, StateHistory.format, ReactiveMongoFormats.objectIdFormats) { + + implicit val dateFormat = ReactiveMongoFormats.dateTimeFormats + + override def indexes = Seq( + createSingleFieldAscendingIndex( + indexFieldKey = "applicationId", + indexName = Some("applicationId") + ), + createSingleFieldAscendingIndex( + indexFieldKey = "state", + indexName = Some("state") + ), + createAscendingIndex( + indexName = Some("applicationId_state"), + isUnique = false, + isBackground = true, + indexFieldsKey = List("applicationId", "state"): _* + ) + ) + + def insert(stateHistory: StateHistory): Future[StateHistory] = { + collection.insert(stateHistory).map(_ => stateHistory) + } + + def fetchByState(state: State): Future[Seq[StateHistory]] = { + find("state" -> state) + } + + def fetchByApplicationId(applicationId: UUID): Future[Seq[StateHistory]] = { + find("applicationId" -> applicationId) + } + + def fetchLatestByStateForApplication(applicationId: UUID, state: State): Future[Option[StateHistory]] = { + find("applicationId" -> applicationId, "state" -> state).map(_.sortBy(_.changedAt).lastOption) + } + + def deleteByApplicationId(applicationId: UUID): Future[HasSucceeded] = { + collection.remove(Json.obj("applicationId" -> applicationId)).map(_ => HasSucceeded) + } +} diff --git a/app/uk/gov/hmrc/repository/SubscriptionRepository.scala b/app/uk/gov/hmrc/repository/SubscriptionRepository.scala new file mode 100644 index 000000000..13554f00e --- /dev/null +++ b/app/uk/gov/hmrc/repository/SubscriptionRepository.scala @@ -0,0 +1,97 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.repository + +import java.util.UUID +import javax.inject.{Inject, Singleton} + +import play.api.libs.json.Json._ +import play.api.libs.json.{JsValue, Json} +import play.modules.reactivemongo.ReactiveMongoComponent +import reactivemongo.bson.BSONObjectID +import reactivemongo.play.json.ImplicitBSONHandlers._ +import uk.gov.hmrc.models.MongoFormat._ +import uk.gov.hmrc.models._ +import uk.gov.hmrc.mongo.ReactiveRepository +import uk.gov.hmrc.mongo.json.ReactiveMongoFormats +import uk.gov.hmrc.util.mongo.IndexHelper._ + +import scala.collection.Seq +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +@Singleton +class SubscriptionRepository @Inject()(mongo: ReactiveMongoComponent) + extends ReactiveRepository[SubscriptionData, BSONObjectID]("subscription", mongo.mongoConnector.db, + MongoFormat.formatSubscriptionData, ReactiveMongoFormats.objectIdFormats) { + + implicit val dateFormat = ReactiveMongoFormats.dateTimeFormats + + override def indexes = Seq( + createSingleFieldAscendingIndex( + indexFieldKey = "apiIdentifier.context", + indexName = Some("context") + ), + createAscendingIndex( + indexName = Some("context_version"), + isUnique = true, + isBackground = true, + indexFieldsKey = List("apiIdentifier.context", "apiIdentifier.version"): _* + ), + createSingleFieldAscendingIndex( + indexFieldKey = "applications", + indexName = Some("applications") + ) + ) + + def isSubscribed(applicationId: UUID, apiIdentifier: APIIdentifier) = { + collection.count(Some(Json.obj("$and" -> Json.arr( + Json.obj("applications" -> applicationId.toString), + Json.obj("apiIdentifier.context" -> apiIdentifier.context), + Json.obj("apiIdentifier.version" -> apiIdentifier.version))))) map { + case 1 => true + case _ => false + } + } + + def getSubscriptions(applicationId: UUID): Future[Seq[APIIdentifier]] = { + collection.find(Json.obj("applications" -> applicationId.toString)).cursor[JsValue]().collect[Seq]() map { + _ map (doc => (doc \ "apiIdentifier").as[APIIdentifier]) + } + } + + private def makeSelector(apiIdentifier: APIIdentifier) = { + Json.obj("$and" -> Json.arr( + Json.obj("apiIdentifier.context" -> apiIdentifier.context), + Json.obj("apiIdentifier.version" -> apiIdentifier.version))) + } + + def add(applicationId: UUID, apiIdentifier: APIIdentifier) = { + collection.update( + makeSelector(apiIdentifier), + Json.obj("$addToSet" -> Json.obj("applications" -> applicationId)), + upsert = true + ).map(_ => HasSucceeded) + } + + def remove(applicationId: UUID, apiIdentifier: APIIdentifier) = { + collection.update( + makeSelector(apiIdentifier), + Json.obj("$pull" -> Json.obj("applications" -> applicationId)) + ).map(_ => HasSucceeded) + } +} diff --git a/app/uk/gov/hmrc/scheduled/JobConfig.scala b/app/uk/gov/hmrc/scheduled/JobConfig.scala new file mode 100644 index 000000000..380394f6e --- /dev/null +++ b/app/uk/gov/hmrc/scheduled/JobConfig.scala @@ -0,0 +1,62 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.scheduled + +import play.api.Logger +import uk.gov.hmrc.lock.LockKeeper +import uk.gov.hmrc.play.scheduling.{ExclusiveScheduledJob, ScheduledJob} + +import scala.concurrent.{ExecutionContext, Future} +import scala.concurrent.duration.FiniteDuration + +case class JobConfig(initialDelay: FiniteDuration, interval: FiniteDuration, enabled: Boolean) { + override def toString() = s"JobConfig{initialDelay=$initialDelay interval=$interval enabled=$enabled}" +} + +trait ScheduledMongoJob extends ExclusiveScheduledJob with ScheduledJobState { + + val lockKeeper: LockKeeper + + def runJob(implicit ec: ExecutionContext): Future[RunningOfJobSuccessful] + + override def executeInMutex(implicit ec: ExecutionContext): Future[Result] = { + lockKeeper tryLock { + runJob + } map { + case Some(_) => Result(s"$name Job ran successfully.") + case _ => Result(s"$name did not run because repository was locked by another instance of the scheduler.") + } recover { + case failure: RunningOfJobFailed => { + Logger.error("The execution of the job failed.", failure.wrappedCause) + failure.asResult + } + } + } +} + +trait ScheduledJobState { e: ScheduledJob => + sealed trait RunningOfJobSuccessful + + case object RunningOfJobSuccessful extends RunningOfJobSuccessful + + case class RunningOfJobFailed(jobName: String, wrappedCause: Throwable) extends RuntimeException { + def asResult = { + Result(s"The execution of scheduled job $jobName failed with error '${wrappedCause.getMessage}'. " + + s"The next execution of the job will do retry.") + } + } +} \ No newline at end of file diff --git a/app/uk/gov/hmrc/scheduled/RefreshSubscriptionsScheduledJob.scala b/app/uk/gov/hmrc/scheduled/RefreshSubscriptionsScheduledJob.scala new file mode 100644 index 000000000..42e8e3561 --- /dev/null +++ b/app/uk/gov/hmrc/scheduled/RefreshSubscriptionsScheduledJob.scala @@ -0,0 +1,65 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.scheduled + +import javax.inject.Inject + +import org.joda.time.Duration +import play.api.Logger +import play.modules.reactivemongo.MongoDbConnection +import uk.gov.hmrc.config.AppContext +import uk.gov.hmrc.http.HeaderCarrier +import uk.gov.hmrc.lock.{LockKeeper, LockRepository} +import uk.gov.hmrc.services.SubscriptionService + +import scala.concurrent.duration.FiniteDuration +import scala.concurrent.{ExecutionContext, Future} + +class RefreshSubscriptionsScheduledJob @Inject()(val lockKeeper: RefreshSubscriptionsJobLockKeeper, + subscriptionService: SubscriptionService, + appContext: AppContext) extends ScheduledMongoJob { + + override def name: String = "RefreshSubscriptionsScheduledJob" + + override def interval: FiniteDuration = appContext.refreshSubscriptionsJobConfig.interval + + override def initialDelay: FiniteDuration = appContext.refreshSubscriptionsJobConfig.initialDelay + + override def runJob(implicit ec: ExecutionContext): Future[RunningOfJobSuccessful] = { + implicit val hc: HeaderCarrier = HeaderCarrier() + + Logger.info("Starting RefreshSubscriptionsJob") + + subscriptionService.refreshSubscriptions() map { modified => + Logger.info(s"$modified subscriptions have been refreshed") + RunningOfJobSuccessful + } recoverWith { + case e: Throwable => + Logger.error("Could not refresh subscriptions", e) + Future.failed(RunningOfJobFailed(name, e)) + } + } +} + +class RefreshSubscriptionsJobLockKeeper extends LockKeeper { + override def repo: LockRepository = new LockRepository()(new MongoDbConnection {}.db) + + override def lockId: String = "RefreshSubscriptionsScheduledJob" + + override val forceLockReleaseAfter: Duration = Duration.standardHours(2) + +} \ No newline at end of file diff --git a/app/uk/gov/hmrc/scheduled/Retrying.scala b/app/uk/gov/hmrc/scheduled/Retrying.scala new file mode 100644 index 000000000..71e3dccdd --- /dev/null +++ b/app/uk/gov/hmrc/scheduled/Retrying.scala @@ -0,0 +1,38 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.scheduled + +import akka.pattern.after +import play.api.libs.concurrent.Akka +import play.api.Play.current + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.concurrent.duration.FiniteDuration + +// Taken from https://gist.github.com/viktorklang/9414163 + +object Retrying { + + def retry[T](f: => Future[T], delay: FiniteDuration, retries: Int = 0): Future[T] = { + f recoverWith { + case _: RuntimeException if retries > 0 => + after(delay, Akka.system.scheduler)(retry(f, delay, retries - 1)) + } + } + +} diff --git a/app/uk/gov/hmrc/scheduled/UpliftVerificationExpiryJob.scala b/app/uk/gov/hmrc/scheduled/UpliftVerificationExpiryJob.scala new file mode 100644 index 000000000..8ed43cdfc --- /dev/null +++ b/app/uk/gov/hmrc/scheduled/UpliftVerificationExpiryJob.scala @@ -0,0 +1,78 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.scheduled + +import javax.inject.Inject + +import org.joda.time.{DateTime, Duration} +import play.api.Logger +import play.modules.reactivemongo.ReactiveMongoComponent +import uk.gov.hmrc.config.AppContext +import uk.gov.hmrc.lock.{LockKeeper, LockRepository} +import uk.gov.hmrc.models.ActorType.SCHEDULED_JOB +import uk.gov.hmrc.models._ +import uk.gov.hmrc.repository.{ApplicationRepository, StateHistoryRepository} +import uk.gov.hmrc.time.DateTimeUtils + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.duration.FiniteDuration +import scala.concurrent.{ExecutionContext, Future} + +class UpliftVerificationExpiryJob @Inject()(val lockKeeper: UpliftVerificationExpiryJobLockKeeper, + applicationRepository: ApplicationRepository, + stateHistoryRepository: StateHistoryRepository, + appContext: AppContext) extends ScheduledMongoJob { + + val upliftVerificationValidity: FiniteDuration = appContext.upliftVerificationValidity + + override def name: String = "UpliftVerificationExpiryJob" + + override def interval: FiniteDuration = appContext.upliftVerificationExpiryJobConfig.interval + + override def initialDelay: FiniteDuration = appContext.upliftVerificationExpiryJobConfig.initialDelay + + private def transitionAppBackToTesting(app: ApplicationData): Future[ApplicationData] = { + Logger.info(s"Set status back to testing for app{id=${app.id},name=${app.name},state." + + s"requestedByEmailAddress='${app.state.requestedByEmailAddress.getOrElse("")}',state.updatedOn='${app.state.updatedOn}}'") + for { + updatedApp <- applicationRepository.save(app.copy(state = app.state.toTesting)) + _ <- stateHistoryRepository.insert(StateHistory(app.id, State.TESTING, + Actor("UpliftVerificationExpiryJob", SCHEDULED_JOB), Some(State.PENDING_REQUESTER_VERIFICATION))) + } yield updatedApp + } + + override def runJob(implicit ec: ExecutionContext): Future[RunningOfJobSuccessful] = { + val expiredTime: DateTime = DateTimeUtils.now.minusDays(upliftVerificationValidity.toDays.toInt) + Logger.info(s"Move back applications to TESTING having status 'PENDING_REQUESTER_VERIFICATION' with timestamp earlier than $expiredTime") + val result: Future[RunningOfJobSuccessful.type] = for { + expiredApps <- applicationRepository.fetchAllByStatusDetails(state = State.PENDING_REQUESTER_VERIFICATION, updatedBefore = expiredTime) + _ = Logger.info(s"Found ${expiredApps.size} applications") + _ <- Future.sequence(expiredApps.map(transitionAppBackToTesting)) + } yield RunningOfJobSuccessful + result.recoverWith { + case e: Throwable => Future.failed(RunningOfJobFailed(name, e)) + } + } +} + +class UpliftVerificationExpiryJobLockKeeper @Inject()(mongo: ReactiveMongoComponent) extends LockKeeper { + override def repo: LockRepository = new LockRepository()(mongo.mongoConnector.db) + + override def lockId: String = "UpliftVerificationExpiryScheduler" + + override val forceLockReleaseAfter: Duration = Duration.standardMinutes(5) +} \ No newline at end of file diff --git a/app/uk/gov/hmrc/services/AccessService.scala b/app/uk/gov/hmrc/services/AccessService.scala new file mode 100644 index 000000000..5de2894f8 --- /dev/null +++ b/app/uk/gov/hmrc/services/AccessService.scala @@ -0,0 +1,109 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.services + +import java.util.UUID +import javax.inject.Inject + +import uk.gov.hmrc.controllers.{OverridesRequest, OverridesResponse, ScopeRequest, ScopeResponse} +import uk.gov.hmrc.http.{HeaderCarrier, NotFoundException} +import uk.gov.hmrc.models.AccessType.{PRIVILEGED, ROPC} +import uk.gov.hmrc.models._ +import uk.gov.hmrc.repository.ApplicationRepository +import uk.gov.hmrc.services.AuditAction.{OverrideAdded, OverrideRemoved, ScopeAdded, ScopeRemoved} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.concurrent.Future.{failed, sequence, successful} + +class AccessService @Inject()(applicationRepository: ApplicationRepository, auditService: AuditService) { + + def readScopes(applicationId: UUID): Future[ScopeResponse] = + fetchApp(applicationId) map getScopes map ScopeResponse + + def updateScopes(applicationId: UUID, scopeRequest: ScopeRequest) + (implicit headerCarrier: HeaderCarrier): Future[ScopeResponse] = { + + def updateWithScopes(applicationData: ApplicationData, newScopes: Set[String]): ApplicationData = { + val updatedAccess = privilegedOrRopc[Access](applicationData, { + getPrivilegedAccess(_).copy(scopes = newScopes) + }, { + getRopcAccess(_).copy(scopes = newScopes) + }) + applicationData.copy(access = updatedAccess) + } + + for { + originalApplicationData <- fetchApp(applicationId) + oldScopes = getScopes(originalApplicationData) + newScopes = scopeRequest.scopes + persistedApplicationData <- applicationRepository.save(updateWithScopes(originalApplicationData, newScopes)) + _ = sequence(oldScopes diff newScopes map ScopeRemoved.details map (auditService.audit(ScopeRemoved, _))) + _ = sequence(newScopes diff oldScopes map ScopeAdded.details map (auditService.audit(ScopeAdded, _))) + } yield ScopeResponse(getScopes(persistedApplicationData)) + } + + def readOverrides(applicationId: UUID): Future[OverridesResponse] = + fetchApp(applicationId) map getOverrides map OverridesResponse + + def updateOverrides(applicationId: UUID, overridesRequest: OverridesRequest) + (implicit headerCarrier: HeaderCarrier): Future[OverridesResponse] = { + + def updateWithOverrides(applicationData: ApplicationData, newOverrides: Set[OverrideFlag]): ApplicationData = + applicationData.copy(access = getStandardAccess(applicationData).copy(overrides = newOverrides)) + + for { + originalApplicationData <- fetchApp(applicationId) + oldOverrides = getOverrides(originalApplicationData) + newOverrides = overridesRequest.overrides + persistedApplicationData <- applicationRepository.save(updateWithOverrides(originalApplicationData, newOverrides)) + _ = sequence(oldOverrides diff newOverrides map OverrideRemoved.details map (auditService.audit(OverrideRemoved, _))) + _ = sequence(newOverrides diff oldOverrides map OverrideAdded.details map (auditService.audit(OverrideAdded, _))) + } yield OverridesResponse(getOverrides(persistedApplicationData)) + } + + private def fetchApp(applicationId: UUID): Future[ApplicationData] = + applicationRepository.fetch(applicationId).flatMap { + case Some(applicationData) => successful(applicationData) + case None => failed(new NotFoundException(s"application not found for id: $applicationId")) + } + + private def getPrivilegedAccess(applicationData: ApplicationData): Privileged = + applicationData.access.asInstanceOf[Privileged] + + private def getRopcAccess(applicationData: ApplicationData): Ropc = + applicationData.access.asInstanceOf[Ropc] + + private def getScopes(applicationData: ApplicationData): Set[String] = + privilegedOrRopc[Set[String]](applicationData, { + getPrivilegedAccess(_).scopes + }, { + getRopcAccess(_).scopes + }) + + private def privilegedOrRopc[T](applicationData: ApplicationData, privilegedFunction: ApplicationData => T, ropcFunction: ApplicationData => T) = + AccessType.withName(applicationData.access.accessType.toString()) match { + case PRIVILEGED => privilegedFunction(applicationData) + case ROPC => ropcFunction(applicationData) + } + + private def getStandardAccess(applicationData: ApplicationData): Standard = + applicationData.access.asInstanceOf[Standard] + + private def getOverrides(applicationData: ApplicationData): Set[OverrideFlag] = + getStandardAccess(applicationData).overrides +} diff --git a/app/uk/gov/hmrc/services/ApplicationService.scala b/app/uk/gov/hmrc/services/ApplicationService.scala new file mode 100644 index 000000000..9f9477058 --- /dev/null +++ b/app/uk/gov/hmrc/services/ApplicationService.scala @@ -0,0 +1,505 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.services + +import java.util.UUID +import java.util.concurrent.TimeUnit + +import javax.inject.Inject +import org.joda.time.Duration.standardMinutes +import play.api.Logger +import play.api.Play.current +import play.api.libs.concurrent.Akka +import play.modules.reactivemongo.MongoDbConnection +import uk.gov.hmrc.config.AppContext +import uk.gov.hmrc.connector.{EmailConnector, TOTPConnector} +import uk.gov.hmrc.controllers.{AddCollaboratorRequest, AddCollaboratorResponse} +import uk.gov.hmrc.http.{ForbiddenException, HeaderCarrier, HttpResponse, NotFoundException} +import uk.gov.hmrc.lock.{LockKeeper, LockMongoRepository, LockRepository} +import uk.gov.hmrc.models.AccessType._ +import uk.gov.hmrc.models.ActorType.{COLLABORATOR, GATEKEEPER} +import uk.gov.hmrc.models.RateLimitTier.RateLimitTier +import uk.gov.hmrc.models.Role._ +import uk.gov.hmrc.models.State.{PENDING_GATEKEEPER_APPROVAL, PENDING_REQUESTER_VERIFICATION, State, TESTING} +import uk.gov.hmrc.models._ +import uk.gov.hmrc.repository.{ApplicationRepository, StateHistoryRepository, SubscriptionRepository} +import uk.gov.hmrc.services.AuditAction._ +import uk.gov.hmrc.util.CredentialGenerator +import uk.gov.hmrc.util.http.HttpHeaders._ + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.concurrent.Future.{failed, sequence, successful} +import scala.concurrent.duration.Duration +import scala.util.Failure + + +class ApplicationService @Inject()(applicationRepository: ApplicationRepository, + stateHistoryRepository: StateHistoryRepository, + subscriptionRepository: SubscriptionRepository, + auditService: AuditService, + emailConnector: EmailConnector, + totpConnector: TOTPConnector, + lockKeeper: ApplicationLockKeeper, + wso2APIStore: WSO2APIStore, + applicationResponseCreator: ApplicationResponseCreator, + credentialGenerator: CredentialGenerator, + appContext: AppContext) { + + def create[T <: ApplicationRequest](application: T)(implicit hc: HeaderCarrier): Future[CreateApplicationResponse] = { + lockKeeper.tryLock { + createApp(application) + } flatMap { + case Some(x) => + Logger.info(s"Application ${application.name} has been created successfully") + Future(x) + case None => + Logger.warn(s"Application creation is locked. Retry scheduled for ${application.name}") + akka.pattern.after(Duration(3, TimeUnit.SECONDS), using = Akka.system.scheduler) { + create(application) + } + } + } + + def update[T <: ApplicationRequest](id: UUID, application: T)(implicit hc: HeaderCarrier): Future[ApplicationResponse] = { + updateApp(id)(application) map (app => ApplicationResponse(data = app, clientId = None, trusted = appContext.isTrusted(app))) + } + + def updateCheck(id: UUID, checkInformation: CheckInformation): Future[ApplicationResponse] = { + for { + existing <- fetchApp(id) + savedApp <- applicationRepository.save(existing.copy(checkInformation = Some(checkInformation))) + } yield ApplicationResponse(data = savedApp, clientId = None, trusted = appContext.isTrusted(savedApp)) + } + + def addCollaborator(applicationId: UUID, request: AddCollaboratorRequest)(implicit hc: HeaderCarrier) = { + + def validateCollaborator(app: ApplicationData, email: String, role: Role): Collaborator = { + val normalised = email.toLowerCase + if (app.collaborators.exists(_.emailAddress == normalised)) throw new UserAlreadyExists + + Collaborator(normalised, role) + } + + def addUser(app: ApplicationData, collaborator: Collaborator): Future[Set[Collaborator]] = { + val updated = app.collaborators + collaborator + applicationRepository.save(app.copy(collaborators = updated)) map (_.collaborators) + } + + def sendNotificationEmails(applicationName: String, collaborator: Collaborator, registeredUser: Boolean, + adminsToEmail: Set[String])(implicit hc: HeaderCarrier): Future[HttpResponse] = { + def roleForEmail(role: Role) = { + role match { + case ADMINISTRATOR => "admin" + case DEVELOPER => "developer" + case _ => throw new RuntimeException(s"Unexpected role $role") + } + } + + val role: String = roleForEmail(collaborator.role) + + if (adminsToEmail.nonEmpty) { + emailConnector.sendAddedCollaboratorNotification(collaborator.emailAddress, role, applicationName, adminsToEmail) + } + + emailConnector.sendAddedCollaboratorConfirmation(role, applicationName, Set(collaborator.emailAddress)) + } + + for { + app <- fetchApp(applicationId) + collaborator = validateCollaborator(app, request.collaborator.emailAddress, request.collaborator.role) + _ <- addUser(app, collaborator) + _ = auditService.audit(CollaboratorAdded, AuditHelper.applicationId(app.id) ++ CollaboratorAdded.details(collaborator)) + _ = sendNotificationEmails(app.name, collaborator, request.isRegistered, request.adminsToEmail) + } yield AddCollaboratorResponse(request.isRegistered) + } + + def updateRateLimitTier(applicationId: UUID, rateLimitTier: RateLimitTier)(implicit hc: HeaderCarrier): Future[ApplicationData] = { + + // NOTE: due to limitations in WSO2 API Manager, all requests to WSO2 Store need to be executed sequentially + + Logger.info(s"Trying to update the rate limit tier to $rateLimitTier for application $applicationId") + + fetchApp(applicationId) flatMap { app => + + def updateWso2Subscriptions(): Future[Seq[HasSucceeded]] = { + wso2APIStore.getSubscriptions(app.wso2Username, app.wso2Password, app.wso2ApplicationName) flatMap { originalApis => + sequence(originalApis map { api => + wso2APIStore.resubscribeApi(originalApis, app.wso2Username, app.wso2Password, app.wso2ApplicationName, api, rateLimitTier) + }) + } + } + + def updateWso2Application(): Future[HasSucceeded] = { + for { + _ <- wso2APIStore.updateApplication(app.wso2Username, app.wso2Password, app.wso2ApplicationName, rateLimitTier) + _ <- wso2APIStore.checkApplicationRateLimitTier(app.wso2Username, app.wso2Password, app.wso2ApplicationName, rateLimitTier) + } yield HasSucceeded + } + + def updateMongoApplication(): Future[ApplicationData] = { + applicationRepository.save(app.copy(rateLimitTier = Some(rateLimitTier))) + } + + /* + NOTE: + The rate-limit-tier update is not an atomic operation in WSO2 Store. + The application rate-limit-tier is updated in the Mongo collection only if these steps pass successfully: + 1) the rate-limit-tier is updated in WSO2 Store + 2) all subscriptions are updated (removed and then added back, with the new rate limit tier) in WSO2 Store + If the rate-limit-tier update fails in WSO2 Store, we need to re-run again the whole rate-limit-tier operation. + If the subscriptions update fails in WSO2 Store, we need to manually remove and then add back the subscriptions first, + and then re-run again the whole rate-limit-tier operation. + + There is a scheduled job (RefreshSubscriptionsScheduledJob) that runs automatically. + This job synchronises the subscriptions from WSO2 Store to Mongo. + Hence, if the rate-limit-tier operation fails while adding back a subscription in WSO2 Store, + that subscription will be lost also in Mongo when RefreshSubscriptionsScheduledJob finishes its execution. + */ + + for { + _ <- updateWso2Application() + _ <- updateWso2Subscriptions() + updatedApp <- updateMongoApplication() + } yield updatedApp + } + + } + + def deleteCollaborator(applicationId: UUID, collaborator: String, admin: String, adminsToEmail: Set[String]) + (implicit hc: HeaderCarrier): Future[Set[Collaborator]] = { + def deleteUser(app: ApplicationData): Future[ApplicationData] = { + val updatedCollaborators = app.collaborators.filterNot(_.emailAddress equalsIgnoreCase collaborator) + if (!hasAdmin(updatedCollaborators)) failed(new ApplicationNeedsAdmin) + else applicationRepository.save(app.copy(collaborators = updatedCollaborators)) + } + + def sendEmails(applicationName: String, collaboratorEmail: String, adminEmail: String, adminsToEmail: Set[String]): Future[HttpResponse] = { + if (adminsToEmail.nonEmpty) emailConnector.sendRemovedCollaboratorNotification(collaboratorEmail, applicationName, adminsToEmail) + emailConnector.sendRemovedCollaboratorConfirmation(applicationName, Set(collaboratorEmail)) + } + + def audit(collaborator: Option[Collaborator]) = + collaborator match { + case Some(c) => auditService.audit(CollaboratorRemoved, AuditHelper.applicationId(applicationId) ++ CollaboratorRemoved.details(c)) + case None => Logger.warn(s"Failed to audit collaborator removal for: $collaborator") + } + + for { + app <- fetchApp(applicationId) + updated <- deleteUser(app) + _ = audit(app.collaborators.find(_.emailAddress == collaborator.toLowerCase)) + _ = recoverAll(sendEmails(app.name, collaborator.toLowerCase, admin, adminsToEmail)) + } yield updated.collaborators + } + + private def hasAdmin(updated: Set[Collaborator]): Boolean = { + updated.exists(_.role == Role.ADMINISTRATOR) + } + + def fetchByClientId(clientId: String): Future[Option[ApplicationResponse]] = { + applicationRepository.fetchByClientId(clientId) map { + _.map(application => ApplicationResponse(data = application, clientId = Some(clientId), trusted = appContext.isTrusted(application))) + } + } + + def fetchByServerToken(serverToken: String): Future[Option[ApplicationResponse]] = { + applicationRepository.fetchByServerToken(serverToken) map { + _.map(application => ApplicationResponse(data = application, clientId = Some(application.id.toString), trusted = appContext.isTrusted(application))) + } + } + + def fetchAllForCollaborator(emailAddress: String): Future[Seq[ApplicationResponse]] = { + applicationRepository.fetchAllForEmailAddress(emailAddress).map { + _.map(application => ApplicationResponse(data = application, clientId = None, trusted = appContext.isTrusted(application))) + } + } + + def fetchAllForCollaboratorAndEnvironment(emailAddress: String, environment: String): Future[Seq[ApplicationResponse]] = { + applicationRepository.fetchAllForEmailAddressAndEnvironment(emailAddress, environment).map { + _.map(application => ApplicationResponse(data = application, clientId = None, trusted = appContext.isTrusted(application))) + } + } + + def fetchAll(): Future[Seq[ApplicationResponse]] = { + applicationRepository.findAll().map { + _.map(application => ApplicationResponse(data = application, clientId = None, trusted = appContext.isTrusted(application))) + } + } + + def fetchAllBySubscription(apiContext: String): Future[Seq[ApplicationResponse]] = { + applicationRepository.fetchAllForContext(apiContext) map { + _.map(application => ApplicationResponse(data = application, clientId = None, trusted = appContext.isTrusted(application))) + } + } + + def fetchAllBySubscription(apiIdentifier: APIIdentifier): Future[Seq[ApplicationResponse]] = { + applicationRepository.fetchAllForApiIdentifier(apiIdentifier) map { + _.map(application => ApplicationResponse(data = application, clientId = None, trusted = appContext.isTrusted(application))) + } + } + + def fetchAllWithNoSubscriptions(): Future[Seq[ApplicationResponse]] = { + applicationRepository.fetchAllWithNoSubscriptions() map { + _.map(application => ApplicationResponse(data = application, clientId = None, trusted = appContext.isTrusted(application))) + } + } + + def fetch(applicationId: UUID): Future[Option[ApplicationResponse]] = { + applicationRepository.fetch(applicationId) map { + _.map(application => ApplicationResponse(data = application, clientId = None, trusted = appContext.isTrusted(application))) + } + } + + def requestUplift(applicationId: UUID, applicationName: String, + requestedByEmailAddress: String)(implicit hc: HeaderCarrier): Future[ApplicationStateChange] = { + + def uplift(existing: ApplicationData) = existing.copy( + name = applicationName, + normalisedName = applicationName.toLowerCase, + state = existing.state.toPendingGatekeeperApproval(requestedByEmailAddress)) + + for { + app <- fetchApp(applicationId) + upliftedApp = uplift(app) + _ <- assertAppHasUniqueName(applicationName, app.access.accessType, Some(app)) + updatedApp <- applicationRepository.save(upliftedApp) + _ <- insertStateHistory( + app, + PENDING_GATEKEEPER_APPROVAL, Some(TESTING), + requestedByEmailAddress, COLLABORATOR, + (a: ApplicationData) => applicationRepository.save(a) + ) + _ = Logger.info(s"UPLIFT01: uplift request (pending) application:${app.name} appId:${app.id} appState:${app.state.name} " + + s"appRequestedByEmailAddress:${app.state.requestedByEmailAddress}") + _ = auditService.audit(ApplicationUpliftRequested, + AuditHelper.applicationId(applicationId) ++ AuditHelper.calculateAppNameChange(app, updatedApp)) + } yield UpliftRequested + } + + private def assertAppHasUniqueName(submittedAppName: String, accessType: AccessType, existingApp: Option[ApplicationData] = None) + (implicit hc: HeaderCarrier) = { + for { + appWithSameName <- applicationRepository.fetchNonTestingApplicationByName(submittedAppName) + _ = if (appWithSameName.isDefined) { + accessType match { + case PRIVILEGED => auditService.audit(CreatePrivilegedApplicationRequestDeniedDueToNonUniqueName, + Map("applicationName" -> submittedAppName)) + case ROPC => auditService.audit(CreateRopcApplicationRequestDeniedDueToNonUniqueName, + Map("applicationName" -> submittedAppName)) + case _ => auditService.audit(ApplicationUpliftRequestDeniedDueToNonUniqueName, + AuditHelper.applicationId(existingApp.get.id) ++ Map("applicationName" -> submittedAppName)) + } + throw ApplicationAlreadyExists(submittedAppName) + } + } yield () + } + + private def createApp(req: ApplicationRequest)(implicit hc: HeaderCarrier): Future[CreateApplicationResponse] = { + val application = req.asInstanceOf[CreateApplicationRequest].normaliseCollaborators + Logger.info(s"Creating application ${application.name}") + + val wso2Username = credentialGenerator.generate() + val wso2Password = credentialGenerator.generate() + val wso2ApplicationName = credentialGenerator.generate() + + def createInWso2(): Future[ApplicationTokens] = { + wso2APIStore.createApplication(wso2Username, wso2Password, wso2ApplicationName) + } + + def saveApplication(tokens: ApplicationTokens, ids: Option[TotpIds]): Future[ApplicationData] = { + + def newPrivilegedAccess = { + application.access.asInstanceOf[Privileged].copy(totpIds = ids) + } + + val updatedApplication = ids match { + case Some(t) if application.access.accessType == PRIVILEGED => application.copy(access = newPrivilegedAccess) + case _ => application + } + + val applicationData = ApplicationData.create(updatedApplication, wso2Username, wso2Password, wso2ApplicationName, tokens) + + applicationRepository.save(applicationData) + } + + def generateApplicationTotps(accessType: AccessType): Future[Option[ApplicationTotps]] = { + + def generateTotps() = { + val productionTotpFuture = totpConnector.generateTotp() + val sandboxTotpFuture = totpConnector.generateTotp() + for { + productionTotp <- productionTotpFuture + sandboxTotp <- sandboxTotpFuture + } yield Some(ApplicationTotps(productionTotp, sandboxTotp)) + } + + accessType match { + case PRIVILEGED => generateTotps() + case _ => Future(None) + } + } + + def createStateHistory(appData: ApplicationData) = { + val actor = appData.access.accessType match { + case PRIVILEGED | ROPC => Actor("", GATEKEEPER) + case _ => Actor(loggedInUser, COLLABORATOR) + } + insertStateHistory(appData, appData.state.name, None, actor.id, actor.actorType, (a: ApplicationData) => applicationRepository.delete(a.id)) + } + + def extractTotpIds(applicationTotps: Option[ApplicationTotps]): Option[TotpIds] = { + applicationTotps.map { t => TotpIds(t.production.id, t.sandbox.id) } + } + + def extractTotpSecrets(applicationTotps: Option[ApplicationTotps]): Option[TotpSecrets] = { + applicationTotps.map { t => TotpSecrets(t.production.secret, t.sandbox.secret) } + } + + val f = for { + _ <- application.access.accessType match { + case PRIVILEGED => assertAppHasUniqueName(application.name, PRIVILEGED) + case ROPC => assertAppHasUniqueName(application.name, ROPC) + case _ => successful(Unit) + } + + applicationTotps <- generateApplicationTotps(application.access.accessType) + wso2App <- createInWso2() + appData <- saveApplication(wso2App, extractTotpIds(applicationTotps)) + _ <- createStateHistory(appData) + _ = auditAppCreated(appData) + } yield applicationResponseCreator.createApplicationResponse(appData, extractTotpSecrets(applicationTotps)) + + f andThen { + case Failure(e) => + wso2APIStore.deleteApplication(wso2Username, wso2Password, wso2ApplicationName) + .map(_ => Logger.info(s"deleted application: [$wso2ApplicationName]")) + } + } + + private def auditAppCreated(app: ApplicationData)(implicit hc: HeaderCarrier) = + auditService.audit(AppCreated, Map( + "applicationId" -> app.id.toString, + "newApplicationName" -> app.name, + "newApplicationDescription" -> app.description.getOrElse("") + )) + + private def updateApp(id: UUID)(application: ApplicationRequest)(implicit hc: HeaderCarrier): Future[ApplicationData] = { + Logger.info(s"Updating application ${application.name}") + + def updatedAccess(existing: ApplicationData): Access = + existing.access match { + case Standard(_, _, _, o: Set[OverrideFlag]) => application.access.asInstanceOf[Standard].copy(overrides = o) + case _ => application.access + } + + def updatedApplication(existing: ApplicationData): ApplicationData = + existing.copy( + name = application.name, + normalisedName = application.name.toLowerCase, + description = application.description, + access = updatedAccess(existing) + ) + + def checkAccessType(existing: ApplicationData): Unit = + if (existing.access.accessType != application.access.accessType) { + throw new ForbiddenException("Updating the access type of an application is not allowed") + } + + for { + existing <- fetchApp(id) + _ = checkAccessType(existing) + savedApp <- applicationRepository.save(updatedApplication(existing)) + _ = AuditHelper.calculateAppChanges(existing, savedApp).foreach(Function.tupled(auditService.audit)) + } yield savedApp + } + + private def fetchApp(applicationId: UUID) = { + val notFoundException = new NotFoundException(s"application not found for id: $applicationId") + applicationRepository.fetch(applicationId).flatMap { + case None => failed(notFoundException) + case Some(app) => successful(app) + } + } + + def verifyUplift(verificationCode: String)(implicit hc: HeaderCarrier): Future[ApplicationStateChange] = { + + def verifyProduction(app: ApplicationData) = { + Logger.info(s"Application uplift for '${app.name}' has been verified already. No update was executed.") + successful(UpliftVerified) + } + + def findLatestUpliftRequester(appId: UUID): Future[String] = for { + history <- stateHistoryRepository.fetchLatestByStateForApplication(appId, State.PENDING_GATEKEEPER_APPROVAL) + state = history.getOrElse(throw new RuntimeException(s"Pending state not found for application: $appId")) + } yield state.actor.id + + def audit(app: ApplicationData) = + findLatestUpliftRequester(app.id) flatMap { email => + auditService.audit(ApplicationUpliftVerified, AuditHelper.applicationId(app.id), Map("upliftRequestedByEmail" -> email)) + } + + def verifyPending(app: ApplicationData) = for { + _ <- applicationRepository.save(app.copy(state = app.state.toProduction)) + _ <- insertStateHistory(app, State.PRODUCTION, Some(PENDING_REQUESTER_VERIFICATION), + app.state.requestedByEmailAddress.get, COLLABORATOR, (a: ApplicationData) => applicationRepository.save(a)) + _ = Logger.info(s"UPLIFT02: Application uplift for application:${app.name} appId:${app.id} has been verified successfully") + _ = audit(app) + } yield UpliftVerified + + for { + app <- applicationRepository.fetchVerifiableUpliftBy(verificationCode) + .map(_.getOrElse(throw InvalidUpliftVerificationCode(verificationCode))) + + result <- app.state.name match { + case State.PRODUCTION => verifyProduction(app) + case PENDING_REQUESTER_VERIFICATION => verifyPending(app) + case _ => throw InvalidUpliftVerificationCode(verificationCode) + } + } yield result + + } + + private def insertStateHistory(snapshotApp: ApplicationData, newState: State, oldState: Option[State], + requestedBy: String, actorType: ActorType.ActorType, rollback: ApplicationData => Any) = { + val stateHistory = StateHistory(snapshotApp.id, newState, Actor(requestedBy, actorType), oldState) + stateHistoryRepository.insert(stateHistory) andThen { + case Failure(e) => + rollback(snapshotApp) + } + } + + val unit = (): Unit + + val recoverAll: Future[_] => Future[_] = { + _ recover { + case e: Throwable => Logger.error(e.getMessage); unit + } + } + + private def loggedInUser(implicit hc: HeaderCarrier) = hc.headers find (_._1 == LOGGED_IN_USER_EMAIL_HEADER) map (_._2) getOrElse "" +} + +class ApplicationLockKeeper extends LockKeeper { + override def repo: LockRepository = { + LockMongoRepository(new MongoDbConnection {}.db) + } + + override def lockId: String = "create-third-party-application" + + override val forceLockReleaseAfter = standardMinutes(1) +} diff --git a/app/uk/gov/hmrc/services/AuditService.scala b/app/uk/gov/hmrc/services/AuditService.scala new file mode 100644 index 000000000..7a02c42a7 --- /dev/null +++ b/app/uk/gov/hmrc/services/AuditService.scala @@ -0,0 +1,261 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.services + +import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import java.util.UUID + +import javax.inject.Inject +import uk.gov.hmrc.http.HeaderCarrier +import uk.gov.hmrc.models.{ApplicationData, Collaborator, OverrideFlag, Standard} +import uk.gov.hmrc.play.audit.AuditExtensions.auditHeaderCarrier +import uk.gov.hmrc.play.audit.http.connector.{AuditConnector, AuditResult} +import uk.gov.hmrc.play.audit.model.DataEvent +import uk.gov.hmrc.services.AuditAction.{AppNameChanged, AppPrivacyPolicyUrlChanged, AppRedirectUrisChanged, AppTermsAndConditionsUrlChanged} +import uk.gov.hmrc.util.http.HttpHeaders._ + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +class AuditService @Inject()(val auditConnector: AuditConnector) { + + def audit(action: AuditAction, data: Map[String, String])(implicit hc: HeaderCarrier): Future[AuditResult] = + audit(action, data, Map.empty) + + def audit(action: AuditAction, data: Map[String, String], + tags: Map[String, String])(implicit hc: HeaderCarrier): Future[AuditResult] = + auditConnector.sendEvent(new DataEvent( + auditSource = "third-party-application", + auditType = action.auditType, + tags = hc.toAuditTags(action.name, "-") ++ userContext(hc) ++ tags, + detail = hc.toAuditDetails(data.toSeq: _*) + )) + + private def userContext(hc: HeaderCarrier) = + userContextFromHeaders(hc.headers.toMap) + + private def userContextFromHeaders(headers: Map[String, String]) = { + def mapHeader(mapping: (String, String)): Option[(String, String)] = + headers.get(mapping._1) map (mapping._2 -> URLDecoder.decode(_, StandardCharsets.UTF_8.toString)) + + val email = mapHeader(LOGGED_IN_USER_EMAIL_HEADER -> "developerEmail") + val name = mapHeader(LOGGED_IN_USER_NAME_HEADER -> "developerFullName") + + Seq(email, name).flatten.toMap + } +} + +sealed trait AuditAction { + val auditType: String + val name: String +} + +object AuditAction { + + case object AppCreated extends AuditAction { + val name = "application has been created" + val auditType = "ApplicationCreated" + } + + case object AppNameChanged extends AuditAction { + val name = "Application name changed" + val auditType = "ApplicationNameChanged" + } + + case object AppRedirectUrisChanged extends AuditAction { + val name = "Application redirect URIs changed" + val auditType = "ApplicationRedirectUrisChanged" + } + + case object AppTermsAndConditionsUrlChanged extends AuditAction { + val name = "Application terms and conditions url changed" + val auditType = "ApplicationTermsAndConditionsUrlChanged" + } + + case object AppPrivacyPolicyUrlChanged extends AuditAction { + val name = "Application Privacy Policy url Changed" + val auditType = "ApplicationPrivacyPolicyUrlChanged" + } + + case object Subscribed extends AuditAction { + val name = "Application Subscribed to API" + val auditType = "ApplicationSubscribedToAPI" + } + + case object Unsubscribed extends AuditAction { + val name = "Application Unsubscribed From API" + val auditType = "ApplicationUnsubscribedFromAPI" + } + + case object ClientSecretAdded extends AuditAction { + val name = "Application Client Secret Added" + val auditType = "ApplicationClientSecretAdded" + } + + case object ClientSecretRemoved extends AuditAction { + val name = "Application Client Secret Removed" + val auditType = "ApplicationClientSecretRemoved" + } + + case object ApplicationUpliftRequested extends AuditAction { + val name = "application uplift to production has been requested" + val auditType = "ApplicationUpliftRequested" + } + + case object ApplicationUpliftRequestDeniedDueToNonUniqueName extends AuditAction { + val name = "application uplift to production request has been denied, due to non-unique name" + val auditType = "ApplicationUpliftRequestDeniedDueToNonUniqueName" + } + + case object ApplicationUpliftVerified extends AuditAction { + val name = "application uplift to production completed - the verification link sent to the uplift requester has been visited" + val auditType = "ApplicationUpliftedToProduction" + } + + case object ApplicationUpliftApproved extends AuditAction { + val name = "application name approved - as part of the application uplift to production" + val auditType = "ApplicationNameApprovedByGatekeeper" + } + + case object ApplicationUpliftRejected extends AuditAction { + val name = "application name declined - as part of the application uplift production" + val auditType = "ApplicationNameDeclinedByGatekeeper" + } + + case object ApplicationVerficationResent extends AuditAction { + val name = "verification email has been resent" + val auditType = "VerificationEmailResentByGatekeeper" + } + + case object CreatePrivilegedApplicationRequestDeniedDueToNonUniqueName extends AuditAction { + val name = "create privileged application request has been denied, due to non-unique name" + val auditType = "CreatePrivilegedApplicationRequestDeniedDueToNonUniqueName" + } + + case object CreateRopcApplicationRequestDeniedDueToNonUniqueName extends AuditAction { + val name = "create ropc application request has been denied, due to non-unique name" + val auditType = "CreateRopcApplicationRequestDeniedDueToNonUniqueName" + } + + case object CollaboratorAdded extends AuditAction { + val name = "Collaborator added to an application" + val auditType = "CollaboratorAddedToApplication" + + def details(collaborator: Collaborator) = Map( + "newCollaboratorEmail" -> collaborator.emailAddress, + "newCollaboratorType" -> collaborator.role.toString) + } + + case object CollaboratorRemoved extends AuditAction { + val name = "Collaborator removed from an application" + val auditType = "CollaboratorRemovedFromApplication" + + def details(collaborator: Collaborator) = Map( + "removedCollaboratorEmail" -> collaborator.emailAddress, + "removedCollaboratorType" -> collaborator.role.toString) + } + + case object ScopeAdded extends AuditAction { + val name = "Scope added to an application" + val auditType = "ScopeAddedToApplication" + + def details(scope: String) = Map("newScope" -> scope) + } + + case object ScopeRemoved extends AuditAction { + val name = "Scope removed from an application" + val auditType = "ScopeRemovedFromApplication" + + def details(scope: String) = Map("removedScope" -> scope) + } + + case object OverrideAdded extends AuditAction { + val name = "Override added to an application" + val auditType = "OverrideAddedToApplication" + + def details(anOverride: OverrideFlag) = Map("newOverride" -> anOverride.overrideType.toString) + } + + case object OverrideRemoved extends AuditAction { + val name = "Override removed from an application" + val auditType = "OverrideRemovedFromApplication" + + def details(anOverride: OverrideFlag) = Map("removedOverride" -> anOverride.overrideType.toString) + } + + case object ApplicationDeleted extends AuditAction { + val name = "Application has been deleted" + val auditType = "ApplicationDeleted" + } + +} + +object AuditHelper { + + def applicationId(applicationId: UUID) = Map("applicationId" -> applicationId.toString) + + def calculateAppNameChange(previous: ApplicationData, updated: ApplicationData) = + if (previous.name != updated.name) Map("newApplicationName" -> updated.name) + else Map.empty + + def gatekeeperActionDetails(app: ApplicationData) = + Map("applicationId" -> app.id.toString, + "applicationName" -> app.name, + "upliftRequestedByEmail" -> app.state.requestedByEmailAddress.getOrElse("-"), + "applicationAdmins" -> app.admins.map(_.emailAddress).mkString(", ") + ) + + def calculateAppChanges(previous: ApplicationData, updated: ApplicationData) = { + val common = Map( + "applicationId" -> updated.id.toString) + + val genericEvents = Set(calcNameChange(previous, updated)) + + val standardEvents = (previous.access, updated.access) match { + case (p: Standard, u: Standard) => Set( + calcRedirectUriChange(p, u), + calcTermsAndConditionsChange(p, u), + calcPrivacyPolicyChange(p, u)) + case _ => Set.empty + } + + (standardEvents ++ genericEvents) + .flatten + .map(auditEvent => (auditEvent._1, auditEvent._2 ++ common)) + } + + private def when[A](pred: Boolean, ret: => A): Option[A] = + if (pred) Some(ret) else None + + private def calcRedirectUriChange(a: Standard, b: Standard) = { + val redirectUris = b.redirectUris.mkString(",") + when(a.redirectUris != b.redirectUris, AppRedirectUrisChanged -> Map("newRedirectUris" -> redirectUris)) + } + + private def calcNameChange(a: ApplicationData, b: ApplicationData) = + when(a.name != b.name, + AppNameChanged -> Map("newApplicationName" -> b.name)) + + private def calcTermsAndConditionsChange(a: Standard, b: Standard) = + when(a.termsAndConditionsUrl != b.termsAndConditionsUrl, + AppTermsAndConditionsUrlChanged -> Map("newTermsAndConditionsUrl" -> b.termsAndConditionsUrl.getOrElse(""))) + + private def calcPrivacyPolicyChange(a: Standard, b: Standard) = + when(a.privacyPolicyUrl != b.privacyPolicyUrl, + AppPrivacyPolicyUrlChanged -> Map("newPrivacyPolicyUrl" -> b.privacyPolicyUrl.getOrElse(""))) +} \ No newline at end of file diff --git a/app/uk/gov/hmrc/services/CredentialService.scala b/app/uk/gov/hmrc/services/CredentialService.scala new file mode 100644 index 000000000..f11c1a8b0 --- /dev/null +++ b/app/uk/gov/hmrc/services/CredentialService.scala @@ -0,0 +1,127 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.services + +import java.util.UUID +import javax.inject.Inject + +import uk.gov.hmrc.config.AppContext +import uk.gov.hmrc.controllers.{ClientSecretRequest, ValidationRequest} +import uk.gov.hmrc.http.{HeaderCarrier, NotFoundException} +import uk.gov.hmrc.models.Environment._ +import uk.gov.hmrc.models._ +import uk.gov.hmrc.repository.ApplicationRepository +import uk.gov.hmrc.services.AuditAction._ + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +class CredentialService @Inject()(applicationRepository: ApplicationRepository, auditService: AuditService, + appContext: AppContext, applicationResponseCreator: ApplicationResponseCreator) { + + val clientSecretLimit = appContext.clientSecretLimit + + def fetch(applicationId: UUID): Future[Option[ApplicationResponse]] = { + applicationRepository.fetch(applicationId) map (_.map(app => ApplicationResponse(data = app, clientId = None, trusted = appContext.isTrusted(app)))) + } + + def fetchCredentials(applicationId: UUID): Future[Option[ApplicationTokensResponse]] = { + applicationRepository.fetch(applicationId) map (_.map { app => + ApplicationTokensResponse.create(app.tokens) + }) + } + + def fetchWso2Credentials(clientId: String): Future[Option[Wso2Credentials]] = { + applicationRepository.fetchByClientId(clientId) map (_.flatMap { app => + Seq(app.tokens.production, app.tokens.sandbox) + .find(_.clientId == clientId) + .map(token => Wso2Credentials(token.clientId, token.accessToken, token.wso2ClientSecret)) + }) + } + + def addClientSecret(id: java.util.UUID, secretRequest: ClientSecretRequest)(implicit hc: HeaderCarrier): Future[ApplicationTokensResponse] = { + for { + app <- fetchApp(id) + secret = ClientSecret(secretRequest.name) + updatedApp = addClientSecretToApp(app, secret) + savedApp <- applicationRepository.save(updatedApp) + _ = auditService.audit(ClientSecretAdded, Map("applicationId" -> app.id.toString, + "newClientSecret" -> secret.secret, "clientSecretType" -> "PRODUCTION")) + } yield ApplicationTokensResponse.create(savedApp.tokens) + } + + private def addClientSecretToApp(application: ApplicationData, secret: ClientSecret) = { + val environmentTokens = application.tokens.environmentToken(Environment.PRODUCTION) + if (environmentTokens.clientSecrets.size >= clientSecretLimit) { + throw new ClientSecretsLimitExceeded + } + val updatedEnvironmentTokens = environmentTokens.copy(clientSecrets = environmentTokens.clientSecrets :+ secret) + + application.copy(tokens = ApplicationTokens(updatedEnvironmentTokens, application.tokens.sandbox)) + + } + + def deleteClientSecrets(id: java.util.UUID, secrets: Seq[String])(implicit hc: HeaderCarrier): Future[ApplicationTokensResponse] = { + + def audit(clientSecret: ClientSecret) = { + auditService.audit(ClientSecretRemoved, Map("applicationId" -> id.toString, + "removedClientSecret" -> clientSecret.secret)) + } + + def updateApp(app: ApplicationData): (ApplicationData, Set[ClientSecret]) = { + val numberOfSecretsToDelete = secrets.length + val existingSecrets= app.tokens.production.clientSecrets + val updatedSecrets = existingSecrets.filterNot(secret => secrets.contains(secret.secret)) + if (existingSecrets.length - updatedSecrets.length != numberOfSecretsToDelete) { + throw new NotFoundException ("Cannot find all secrets to delete") + } + if (updatedSecrets.isEmpty) { + throw new IllegalArgumentException ("Cannot delete all client secrets") + } + val updatedProductionToken = app.tokens.production.copy(clientSecrets = updatedSecrets) + val updatedTokens = app.tokens.copy(production = updatedProductionToken) + val updatedApp = app.copy(tokens = updatedTokens) + val removedSecrets = existingSecrets.toSet -- updatedSecrets.toSet + (updatedApp, removedSecrets) + } + + for { + app <- fetchApp(id) + (updatedApp, removedSecrets) = updateApp(app) + _ <- applicationRepository.save(updatedApp) + _ <- Future.traverse(removedSecrets)(audit) + } yield ApplicationTokensResponse.create(updatedApp.tokens) + } + + def validateCredentials(validation: ValidationRequest): Future[Option[Environment]] = { + applicationRepository.fetchByClientId(validation.clientId) map (_.flatMap { app => + Seq(app.tokens.production -> PRODUCTION, app.tokens.sandbox -> SANDBOX) + .find(t => + t._1.clientId == validation.clientId && t._1.clientSecrets.exists(_.secret == validation.clientSecret) + ).map(_._2) + }) + } + + private def fetchApp(applicationId: UUID) = { + val notFoundException = new NotFoundException(s"application not found for id: $applicationId") + applicationRepository.fetch(applicationId).flatMap { + case None => Future.failed(notFoundException) + case Some(app) => Future.successful(app) + } + } + +} diff --git a/app/uk/gov/hmrc/services/DataUtil.scala b/app/uk/gov/hmrc/services/DataUtil.scala new file mode 100644 index 000000000..40587a5a0 --- /dev/null +++ b/app/uk/gov/hmrc/services/DataUtil.scala @@ -0,0 +1,39 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.services + +object DataUtil { + /** + * Joins two maps based on common keys and throws exception if a key is not found in any map + * @param map1 Map[K, T] + * @param map2 Map[K, S] + * @param mapper function taking (T, S) and returns R + * @param map1Error error function returning exception if K is not found in map1 + * @param map2Error error function returning exception if K is not found in map2 + * @tparam K Key to join the maps + * @tparam T value type in map1 + * @tparam S value type in map2 + * @tparam R return value type + * @return returns a Seq of R + */ + def zipper[K, T, S, R](map1: Map[K, T], map2: Map[K, S], mapper: (T, S) => R, + map1Error: K => Exception, map2Error: K => Exception): Seq[R] = { + val results = for (key <- map1.keys ++ map2.keys) + yield mapper(map1.getOrElse(key, throw map1Error(key)), map2.getOrElse(key, throw map2Error(key))) + results.toSeq + } +} diff --git a/app/uk/gov/hmrc/services/GatekeeperService.scala b/app/uk/gov/hmrc/services/GatekeeperService.scala new file mode 100644 index 000000000..91fe64e8a --- /dev/null +++ b/app/uk/gov/hmrc/services/GatekeeperService.scala @@ -0,0 +1,224 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.services + +import java.util.UUID + +import javax.inject.Inject +import play.api.Logger +import uk.gov.hmrc.config.AppContext +import uk.gov.hmrc.connector.{ApiSubscriptionFieldsConnector, EmailConnector, ThirdPartyDelegatedAuthorityConnector} +import uk.gov.hmrc.controllers.{DeleteApplicationRequest, RejectUpliftRequest} +import uk.gov.hmrc.http.{HeaderCarrier, NotFoundException} +import uk.gov.hmrc.models.ActorType._ +import uk.gov.hmrc.models.State.{State, _} +import uk.gov.hmrc.models.StateHistory.dateTimeOrdering +import uk.gov.hmrc.models._ +import uk.gov.hmrc.play.audit.http.connector.AuditResult +import uk.gov.hmrc.repository.{ApplicationRepository, StateHistoryRepository, SubscriptionRepository} +import uk.gov.hmrc.services.AuditAction._ + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.concurrent.Future._ +import scala.util.Failure + +class GatekeeperService @Inject()(applicationRepository: ApplicationRepository, + stateHistoryRepository: StateHistoryRepository, + subscriptionRepository: SubscriptionRepository, + auditService: AuditService, + emailConnector: EmailConnector, + apiSubscriptionFieldsConnector: ApiSubscriptionFieldsConnector, + wso2APIStore: WSO2APIStore, + applicationResponseCreator: ApplicationResponseCreator, + appContext: AppContext, + thirdPartyDelegatedAuthorityConnector: ThirdPartyDelegatedAuthorityConnector) { + + def fetchNonTestingAppsWithSubmittedDate(): Future[Seq[ApplicationWithUpliftRequest]] = { + def appError(id: UUID) = new InconsistentDataState(s"App not found for id: $id") + + def historyError(id: UUID) = new InconsistentDataState(s"History not found for id: $id") + + def latestUpliftRequestState(histories: Seq[StateHistory]) = { + for ((id, history) <- histories.groupBy(_.applicationId)) + yield id -> history.maxBy(_.changedAt) + } + + val appsFuture = applicationRepository.fetchStandardNonTestingApps() + val stateHistoryFuture = stateHistoryRepository.fetchByState(PENDING_GATEKEEPER_APPROVAL) + for { + apps <- appsFuture + appIds = apps.map(_.id) + histories <- stateHistoryFuture.map(_.filter(h => appIds.contains(h.applicationId))) + appsMap = apps.groupBy(_.id).mapValues(_.head) + historyMap = latestUpliftRequestState(histories) + } yield DataUtil.zipper(appsMap, historyMap, ApplicationWithUpliftRequest.create, appError, historyError) + } + + def fetchAppWithHistory(id: UUID): Future[ApplicationWithHistory] = { + for { + app <- fetchApp(id) + history <- stateHistoryRepository.fetchByApplicationId(id) + } yield { + ApplicationWithHistory(ApplicationResponse(data = app, + clientId = None, + trusted = appContext.isTrusted(app)), + history.map(StateHistoryResponse.from)) + } + } + + def approveUplift(applicationId: UUID, gatekeeperUserId: String)(implicit hc: HeaderCarrier): Future[ApplicationStateChange] = { + def approve(existing: ApplicationData) = existing.copy(state = existing.state.toPendingRequesterVerification) + + def sendEmails(app: ApplicationData) = { + val requesterEmail = app.state.requestedByEmailAddress.getOrElse(throw new RuntimeException("no requestedBy email found")) + val verificationCode = app.state.verificationCode.getOrElse(throw new RuntimeException("no verification code found")) + val recipients = app.admins.map(_.emailAddress) - requesterEmail + + if (recipients.nonEmpty) emailConnector.sendApplicationApprovedNotification(app.name, recipients) + + emailConnector.sendApplicationApprovedAdminConfirmation(app.name, verificationCode, Set(requesterEmail)) + } + + for { + app <- fetchApp(applicationId) + newApp <- applicationRepository.save(approve(app)) + _ <- insertStateHistory(app, PENDING_REQUESTER_VERIFICATION, Some(PENDING_GATEKEEPER_APPROVAL), + gatekeeperUserId, GATEKEEPER, applicationRepository.save) + _ = Logger.info(s"UPLIFT04: Approved uplift application:${app.name} appId:${app.id} appState:${app.state.name}" + + s" appRequestedByEmailAddress:${app.state.requestedByEmailAddress} gatekeeperUserId:${gatekeeperUserId}") + _ = auditGatekeeperAction(gatekeeperUserId, app, ApplicationUpliftApproved) + _ = recoverAll(sendEmails(newApp)) + } yield UpliftApproved + + } + + def rejectUplift(applicationId: UUID, request: RejectUpliftRequest)(implicit hc: HeaderCarrier): Future[ApplicationStateChange] = { + def reject(existing: ApplicationData) = { + existing.state.requireState(State.PENDING_GATEKEEPER_APPROVAL, State.TESTING) + existing.copy(state = existing.state.toTesting) + } + + def sendEmails(app: ApplicationData, reason: String) = + emailConnector.sendApplicationRejectedNotification(app.name, app.admins.map(_.emailAddress), reason) + + for { + app <- fetchApp(applicationId) + newApp <- applicationRepository.save(reject(app)) + _ <- insertStateHistory(app, TESTING, Some(PENDING_GATEKEEPER_APPROVAL), + request.gatekeeperUserId, GATEKEEPER, applicationRepository.save, Some(request.reason)) + _ = Logger.info(s"UPLIFT03: Rejected uplift application:${app.name} appId:${app.id} appState:${app.state.name}" + + s" appRequestedByEmailAddress:${app.state.requestedByEmailAddress} reason:${request.reason}" + + s" gatekeeperUserId:${request.gatekeeperUserId}") + _ = auditGatekeeperAction(request.gatekeeperUserId, app, ApplicationUpliftRejected, Map("reason" -> request.reason)) + _ = recoverAll(sendEmails(newApp, request.reason)) + } yield UpliftRejected + } + + def resendVerification(applicationId: UUID, gatekeeperUserId: String)(implicit hc: HeaderCarrier): Future[ApplicationStateChange] = { + def rejectIfNotPendingVerification(existing: ApplicationData) = { + existing.state.requireState(State.PENDING_REQUESTER_VERIFICATION, State.PENDING_REQUESTER_VERIFICATION) + existing + } + + def sendEmails(app: ApplicationData) = { + val requesterEmail = app.state.requestedByEmailAddress.getOrElse(throw new RuntimeException("no requestedBy email found")) + val verificationCode = app.state.verificationCode.getOrElse(throw new RuntimeException("no verification code found")) + emailConnector.sendApplicationApprovedAdminConfirmation(app.name, verificationCode, Set(requesterEmail)) + } + + for { + app <- fetchApp(applicationId) + _ = rejectIfNotPendingVerification(app) + _ = auditGatekeeperAction(gatekeeperUserId, app, ApplicationVerficationResent) + _ = recoverAll(sendEmails(app)) + } yield UpliftApproved + + } + + def deleteApplication(applicationId: UUID, request: DeleteApplicationRequest)(implicit hc: HeaderCarrier): Future[ApplicationStateChange] = { + Logger.info(s"Deleting application $applicationId") + + def deleteSubscriptions(app: ApplicationData): Future[HasSucceeded] = { + def deleteSubscription(subscription: APIIdentifier) = { + for { + _ <- wso2APIStore.removeSubscription(app.wso2Username, app.wso2Password, app.wso2ApplicationName, subscription) + _ <- subscriptionRepository.remove(app.id, subscription) + } yield HasSucceeded + } + + for { + subscriptions <- wso2APIStore.getSubscriptions(app.wso2Username, app.wso2Password, app.wso2ApplicationName) + _ <- traverse(subscriptions)(deleteSubscription) + _ <- apiSubscriptionFieldsConnector.deleteSubscriptions(app.tokens.production.clientId) + } yield HasSucceeded + } + + def sendEmails(app: ApplicationData) = { + val requesterEmail = request.requestedByEmailAddress + val recipients = app.admins.map(_.emailAddress) + emailConnector.sendApplicationDeletedNotification(app.name, requesterEmail, recipients) + } + + (for { + app <- fetchApp(applicationId) + _ <- deleteSubscriptions(app) + _ <- thirdPartyDelegatedAuthorityConnector.revokeApplicationAuthorities(app.tokens.production.clientId) + _ <- wso2APIStore.deleteApplication(app.wso2Username, app.wso2Password, app.wso2ApplicationName) + _ <- applicationRepository.delete(applicationId) + _ <- stateHistoryRepository.deleteByApplicationId(applicationId) + _ = auditGatekeeperAction(request.gatekeeperUserId, app, ApplicationDeleted, Map("requestedByEmailAddress" -> request.requestedByEmailAddress)) + _ = recoverAll(sendEmails(app)) + } yield Deleted).recover { + case e: NotFoundException => Deleted + } + } + + + private def fetchApp(applicationId: UUID): Future[ApplicationData] = { + lazy val notFoundException = new NotFoundException(s"application not found for id: $applicationId") + applicationRepository.fetch(applicationId).flatMap { + case None => Future.failed(notFoundException) + case Some(app) => Future.successful(app) + } + } + + private def auditGatekeeperAction(gatekeeperId: String, app: ApplicationData, action: AuditAction, + extra: Map[String, String] = Map.empty)(implicit hc: HeaderCarrier): Future[AuditResult] = { + auditService.audit(action, AuditHelper.gatekeeperActionDetails(app) ++ extra, + Map("gatekeeperId" -> gatekeeperId)) + } + + private def insertStateHistory(snapshotApp: ApplicationData, newState: State, oldState: Option[State], + requestedBy: String, actorType: ActorType.ActorType, + rollback: ApplicationData => Any, + notes: Option[String] = None): Future[StateHistory] = { + val stateHistory = StateHistory(snapshotApp.id, newState, Actor(requestedBy, actorType), oldState, notes) + stateHistoryRepository.insert(stateHistory) andThen { + case Failure(e) => + rollback(snapshotApp) + } + } + + val unit: Unit = () + + val recoverAll: Future[_] => Future[_] = { + _ recover { + case e: Throwable => Logger.error(e.getMessage); unit + } + } +} diff --git a/app/uk/gov/hmrc/services/SubscriptionService.scala b/app/uk/gov/hmrc/services/SubscriptionService.scala new file mode 100644 index 000000000..6b3317591 --- /dev/null +++ b/app/uk/gov/hmrc/services/SubscriptionService.scala @@ -0,0 +1,161 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.services + +import java.util.UUID +import javax.inject.Inject + +import play.api.Logger +import uk.gov.hmrc.config.AppContext +import uk.gov.hmrc.connector.APIDefinitionConnector +import uk.gov.hmrc.http.{HeaderCarrier, NotFoundException} +import uk.gov.hmrc.models._ +import uk.gov.hmrc.repository.{ApplicationRepository, SubscriptionRepository} +import uk.gov.hmrc.services.AuditAction._ +import uk.gov.hmrc.models.JsonFormatters._ + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.concurrent.Future.{failed, sequence, successful} + +class SubscriptionService @Inject()(applicationRepository: ApplicationRepository, + subscriptionRepository: SubscriptionRepository, + apiDefinitionConnector: APIDefinitionConnector, + auditService: AuditService, + wso2APIStore: WSO2APIStore, + appContext: AppContext){ + + val trustedApplications = appContext.trustedApplications + + def fetchAllSubscriptions(): Future[List[SubscriptionData]] = subscriptionRepository.findAll() + + def fetchAllSubscriptionsForApplication(applicationId: UUID)(implicit hc: HeaderCarrier) = { + val fetchApis: Future[Seq[APIDefinition]] = apiDefinitionConnector.fetchAllAPIs(applicationId) map { + apis => apis.filter(api => trustedApplications.contains(applicationId.toString) || !api.requiresTrust.getOrElse(false)) + } + + for { + apis <- fetchApis + subscriptions <- fetchSubscriptions(applicationId) + } yield apis.map(api => APISubscription.from(api, subscriptions)) + } + + def isSubscribed(applicationId: UUID, api: APIIdentifier): Future[Boolean] = { + subscriptionRepository.isSubscribed(applicationId, api) + } + + def createSubscriptionForApplication(applicationId: UUID, apiIdentifier: APIIdentifier)(implicit hc: HeaderCarrier): Future[HasSucceeded] = { + + val versionSubscriptionFuture: Future[Option[VersionSubscription]] = fetchAllSubscriptionsForApplication(applicationId) map { apis => + apis.find(_.context == apiIdentifier.context) flatMap (_.versions.find(_.version.version == apiIdentifier.version)) + } + + val fetchAppFuture = fetchApp(applicationId) + + def checkVersionSubscription(app: ApplicationData, versionSubscriptionMaybe: Option[VersionSubscription]): Unit = { + versionSubscriptionMaybe match { + case None => throw new NotFoundException(s"API $apiIdentifier is not available for application $applicationId") + case Some(versionSubscription) if versionSubscription.subscribed => throw SubscriptionAlreadyExistsException(app.name, apiIdentifier) + case _ => + } + } + + for { + versionSubscription <- versionSubscriptionFuture + app <- fetchAppFuture + _ = checkVersionSubscription(app, versionSubscription) + _ <- wso2APIStore.addSubscription(app.wso2Username, app.wso2Password, app.wso2ApplicationName, apiIdentifier, app.rateLimitTier) map { _ => + auditSubscription(Subscribed, app, apiIdentifier) + } + _ <- subscriptionRepository.add(applicationId, apiIdentifier) + } yield HasSucceeded + } + + def removeSubscriptionForApplication(applicationId: UUID, apiIdentifier: APIIdentifier)(implicit hc: HeaderCarrier): Future[HasSucceeded] = + for { + app <- fetchApp(applicationId) + _ <- wso2APIStore.removeSubscription(app.wso2Username, app.wso2Password, app.wso2ApplicationName, apiIdentifier) map { _ => + auditSubscription(Unsubscribed, app, apiIdentifier) + } + _ <- subscriptionRepository.remove(applicationId, apiIdentifier) + } yield HasSucceeded + + private def fetchSubscriptions(applicationId: UUID)(implicit hc: HeaderCarrier): Future[Seq[APIIdentifier]] = fetchApp(applicationId) flatMap { app => + wso2APIStore.getSubscriptions(app.wso2Username, app.wso2Password, app.wso2ApplicationName) + } + + private def auditSubscription(action: AuditAction, app: ApplicationData, api: APIIdentifier)(implicit hc: HeaderCarrier): Unit = { + auditService.audit(action, Map( + "applicationId" -> app.id.toString, + "apiVersion" -> api.version, + "apiContext" -> api.context + )) + } + + private def fetchApp(applicationId: UUID) = { + val notFoundException = new NotFoundException(s"Application not found for id: $applicationId") + applicationRepository.fetch(applicationId).flatMap { + case Some(app) => successful(app) + case _ => failed(notFoundException) + } + } + + def refreshSubscriptions()(implicit hc: HeaderCarrier): Future[Int] = { + def updateSubscriptions(app: ApplicationData, subscriptionsToAdd: Seq[APIIdentifier], subscriptionsToRemove: Seq[APIIdentifier]): Future[Int] = { + val addSubscriptions = sequence(subscriptionsToAdd.map { sub => + Logger.warn(s"Inconsistency in subscription collection. Adding subscription in Mongo. appId=${app.id} context=${sub.context} version=${sub.version}") + subscriptionRepository.add(app.id, sub) + }) + val removeSubscriptions = sequence(subscriptionsToRemove.map { sub => + Logger.warn(s"Inconsistency in subscription collection. Removing subscription in Mongo. appId=${app.id} context=${sub.context} version=${sub.version}") + subscriptionRepository.remove(app.id, sub) + }) + + for { + added <- addSubscriptions + removed <- removeSubscriptions + } yield added.size + removed.size + } + + //Processing applications 1 by 1 as WSO2 times out when too many subscriptions calls are made simultaneously + def processApplicationsOneByOne(apps: Seq[ApplicationData], total: Int = 0)(implicit hc: HeaderCarrier): Future[Int] = { + apps match { + case app :: tail => processApplication(app) flatMap (modified => processApplicationsOneByOne(tail, modified + total)) + case Nil => successful(total) + } + } + + def processApplication(app: ApplicationData)(implicit hc: HeaderCarrier): Future[Int] = { + + val mongoSubscriptionsFuture = subscriptionRepository.getSubscriptions(app.id) + + for { + mongoSubscriptions <- mongoSubscriptionsFuture + wso2Subscriptions <- wso2APIStore.getSubscriptions(app.wso2Username, app.wso2Password, app.wso2ApplicationName) + subscriptionsToAdd = wso2Subscriptions.filterNot(mongoSubscriptions.contains(_)) + subscriptionsToRemove = mongoSubscriptions.filterNot(wso2Subscriptions.contains(_)) + sub <- updateSubscriptions(app, subscriptionsToAdd, subscriptionsToRemove) + } yield sub + } + + for { + apps <- applicationRepository.findAll() + _ = Logger.info(s"Found ${apps.length} applications for subscriptions refresh.") + subs <- processApplicationsOneByOne(apps) + } yield subs + } +} diff --git a/app/uk/gov/hmrc/services/WSO2APIStore.scala b/app/uk/gov/hmrc/services/WSO2APIStore.scala new file mode 100644 index 000000000..b8e2d335f --- /dev/null +++ b/app/uk/gov/hmrc/services/WSO2APIStore.scala @@ -0,0 +1,255 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.services + +import javax.inject.Inject + +import uk.gov.hmrc.connector.WSO2APIStoreConnector +import uk.gov.hmrc.http.HeaderCarrier +import uk.gov.hmrc.models.RateLimitTier.RateLimitTier +import uk.gov.hmrc.models._ +import uk.gov.hmrc.scheduled.Retrying + +import scala.collection._ +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.concurrent.duration._ + +trait WSO2APIStore { + + def createApplication(wso2Username: String, wso2Password: String, wso2ApplicationName: String) + (implicit hc: HeaderCarrier): Future[ApplicationTokens] + + def removeSubscription(wso2Username: String, wso2Password: String, wso2ApplicationName: String, api: APIIdentifier) + (implicit hc: HeaderCarrier): Future[HasSucceeded] + + def addSubscription(wso2Username: String, wso2Password: String, wso2ApplicationName: String, api: APIIdentifier, rateLimitTier: Option[RateLimitTier]) + (implicit hc: HeaderCarrier): Future[HasSucceeded] + + def deleteApplication(wso2Username: String, wso2Password: String, wso2ApplicationName: String) + (implicit hc: HeaderCarrier): Future[HasSucceeded] + + def getSubscriptions(wso2Username: String, wso2Password: String, wso2ApplicationName: String) + (implicit hc: HeaderCarrier): Future[Seq[APIIdentifier]] + + def getAllSubscriptions(wso2Username: String, wso2Password: String) + (implicit hc: HeaderCarrier): Future[Map[String, Seq[APIIdentifier]]] + + def resubscribeApi(originalApis: Seq[APIIdentifier], wso2Username: String, wso2Password: String, wso2ApplicationName: String, api: APIIdentifier, rateLimitTier: RateLimitTier) + (implicit hc: HeaderCarrier): Future[HasSucceeded] + + def updateApplication(wso2Username: String, wso2Password: String, wso2ApplicationName: String, rateLimitTier: RateLimitTier) + (implicit hc: HeaderCarrier): Future[HasSucceeded] + + def checkApplicationRateLimitTier(wso2Username: String, wso2Password: String, wso2ApplicationName: String, expectedRateLimitTier: RateLimitTier) + (implicit hc: HeaderCarrier): Future[HasSucceeded] + +} + +class RealWSO2APIStore @Inject()(wso2APIStoreConnector: WSO2APIStoreConnector) extends WSO2APIStore { + + val resubscribeMaxRetries = 5 + + override def createApplication(wso2Username: String, wso2Password: String, wso2ApplicationName: String) + (implicit hc: HeaderCarrier): Future[ApplicationTokens] = + for { + _ <- wso2APIStoreConnector.createUser(wso2Username, wso2Password) + cookie <- wso2APIStoreConnector.login(wso2Username, wso2Password) + _ <- wso2APIStoreConnector.createApplication(cookie, wso2ApplicationName) + // NOTE: the application keys can't be generated in parallel due to limitations in WSO2 API Manager + sandboxKeys <- wso2APIStoreConnector.generateApplicationKey(cookie, wso2ApplicationName, Environment.SANDBOX) + prodKeys <- wso2APIStoreConnector.generateApplicationKey(cookie, wso2ApplicationName, Environment.PRODUCTION) + _ <- wso2APIStoreConnector.logout(cookie) + } yield ApplicationTokens(prodKeys, sandboxKeys) + + override def checkApplicationRateLimitTier(wso2Username: String, wso2Password: String, wso2ApplicationName: String, expectedRateLimitTier: RateLimitTier) + (implicit hc: HeaderCarrier): Future[HasSucceeded] = { + + def check(cookie: String) = { + wso2APIStoreConnector.getApplicationRateLimitTier(cookie, wso2ApplicationName).flatMap { actualTier => + if (actualTier == expectedRateLimitTier) { + Future.successful(HasSucceeded) + } else { + Future.failed(new RuntimeException(s"Rate limit tier did not change for application $wso2ApplicationName. " + + s"Expected $expectedRateLimitTier, but found $actualTier.")) + } + } + } + + for { + cookie <- wso2APIStoreConnector.login(wso2Username, wso2Password) + _ <- Retrying.retry(check(cookie), 100.milliseconds, 1) + _ <- wso2APIStoreConnector.logout(cookie) + } yield HasSucceeded + } + + override def updateApplication(wso2Username: String, wso2Password: String, wso2ApplicationName: String, rateLimitTier: RateLimitTier) + (implicit hc: HeaderCarrier): Future[HasSucceeded] = + withLogin(wso2Username, wso2Password) { + wso2APIStoreConnector.updateApplication(_, wso2ApplicationName, rateLimitTier) + } + + override def deleteApplication(wso2Username: String, wso2Password: String, wso2ApplicationName: String) + (implicit hc: HeaderCarrier): Future[HasSucceeded] = + withLogin(wso2Username, wso2Password) { + wso2APIStoreConnector.deleteApplication(_, wso2ApplicationName) + } + + override def addSubscription(wso2Username: String, wso2Password: String, wso2ApplicationName: String, api: APIIdentifier, rateLimitTier: Option[RateLimitTier]) + (implicit hc: HeaderCarrier): Future[HasSucceeded] = + withLogin(wso2Username, wso2Password) { + wso2APIStoreConnector.addSubscription(_, wso2ApplicationName, WSO2API.create(api), rateLimitTier, 0) + } + + override def removeSubscription(wso2Username: String, wso2Password: String, wso2ApplicationName: String, api: APIIdentifier) + (implicit hc: HeaderCarrier): Future[HasSucceeded] = + withLogin(wso2Username: String, wso2Password) { + wso2APIStoreConnector.removeSubscription(_, wso2ApplicationName, WSO2API.create(api), 0) + } + + override def resubscribeApi(originalApis: Seq[APIIdentifier], wso2Username: String, wso2Password: String, wso2ApplicationName: String, api: APIIdentifier, rateLimitTier: RateLimitTier) + (implicit hc: HeaderCarrier): Future[HasSucceeded] = + withLogin(wso2Username: String, wso2Password) { cookie => + val wso2Api: WSO2API = WSO2API.create(api) + + object Wso2ApiState extends Enumeration { + type Wso2ApiState = Value + val API_ADDED, API_REMOVED = Value + } + + import Wso2ApiState._ + + def isApiUpdatedInWso2Store(wso2Apis: Seq[WSO2API], expectedWso2ApiState: Wso2ApiState): Boolean = expectedWso2ApiState match { + case API_ADDED => wso2Apis.contains(wso2Api) + case API_REMOVED => !wso2Apis.contains(wso2Api) + } + + def check(expectedWso2ApiState: Wso2ApiState) = { + wso2APIStoreConnector.getSubscriptions(cookie, wso2ApplicationName).flatMap { apis: Seq[WSO2API] => + if (isApiUpdatedInWso2Store(apis, expectedWso2ApiState)) { + Future.successful(HasSucceeded) + } else { + + // NOTE: in case of failure, you would expect this code to rollback WSO2 Store to the original subscriptions. + // But since the rollback could fail, we decided it needs to be fixed manually. + + Future.failed(new RuntimeException(s"Application $wso2ApplicationName has $wso2Api subscription in a wrong state. " + + s"Expected $expectedWso2ApiState. " + + s"The subscriptions of application $wso2ApplicationName are now incorrect. Please fix them manually. " + + s"Original subscriptions: $originalApis, current subscriptions: $apis.")) + } + } + } + + // NOTE: The steps below need to be executed sequentially, otherwise WSO2 Store could fail. + + // NOTE: When subscriptions are added or removed in WSO2 Store, sometimes the requests fail with a MySQL deadlock. + // Thus, we retry those requests to WSO2 Store. + + for { + _ <- wso2APIStoreConnector.removeSubscription(cookie, wso2ApplicationName, wso2Api, resubscribeMaxRetries) + _ <- Retrying.retry(check(Wso2ApiState.API_REMOVED), 100.milliseconds, 1) + _ <- wso2APIStoreConnector.addSubscription(cookie, wso2ApplicationName, wso2Api, Some(rateLimitTier), resubscribeMaxRetries) + _ <- Retrying.retry(check(Wso2ApiState.API_ADDED), 100.milliseconds, 1) + } yield HasSucceeded + + } + + override def getSubscriptions(wso2Username: String, wso2Password: String, wso2ApplicationName: String) + (implicit hc: HeaderCarrier): Future[Seq[APIIdentifier]] = + for { + cookie <- wso2APIStoreConnector.login(wso2Username, wso2Password) + subscriptions <- wso2APIStoreConnector.getSubscriptions(cookie, wso2ApplicationName) + _ <- wso2APIStoreConnector.logout(cookie) + } yield subscriptions.map(APIIdentifier.create) + + override def getAllSubscriptions(wso2Username: String, wso2Password: String) + (implicit hc: HeaderCarrier): Future[Map[String, Seq[APIIdentifier]]] = + for { + cookie <- wso2APIStoreConnector.login(wso2Username, wso2Password) + subscriptions <- wso2APIStoreConnector.getAllSubscriptions(cookie) + _ <- wso2APIStoreConnector.logout(cookie) + } yield subscriptions.mapValues { subs => subs.map(APIIdentifier.create) } + + private def withLogin[A](wso2Username: String, wso2Password: String)(action: String => Future[A]) + (implicit hc: HeaderCarrier): Future[HasSucceeded] = + for { + cookie <- wso2APIStoreConnector.login(wso2Username, wso2Password) + _ <- action(cookie) + logoutSucceeded <- wso2APIStoreConnector.logout(cookie) + } yield logoutSucceeded + +} + +object StubAPIStore extends WSO2APIStore { + + lazy val dummyProdTokens = EnvironmentToken("dummyProdId", "dummyValue", "dummyValue") + lazy val dummyTestTokens = EnvironmentToken("dummyTestId", "dummyValue", "dummyValue") + lazy val dummyApplicationTokens = ApplicationTokens(dummyProdTokens, dummyTestTokens) + lazy val stubApplications: concurrent.Map[String, mutable.ListBuffer[APIIdentifier]] = concurrent.TrieMap() + + override def createApplication(wso2Username: String, wso2Password: String, wso2ApplicationName: String) + (implicit hc: HeaderCarrier) = Future.successful { + stubApplications += (wso2ApplicationName -> mutable.ListBuffer.empty) + dummyApplicationTokens + } + + override def removeSubscription(wso2Username: String, wso2Password: String, wso2ApplicationName: String, api: APIIdentifier) + (implicit hc: HeaderCarrier) = Future.successful{ + stubApplications.get(wso2ApplicationName).map(subscriptions => subscriptions -= api) + HasSucceeded + } + + override def addSubscription(wso2Username: String, wso2Password: String, wso2ApplicationName: String, api: APIIdentifier, rateLimitTier: Option[RateLimitTier]) + (implicit hc: HeaderCarrier) = Future.successful { + stubApplications.putIfAbsent(wso2ApplicationName, mutable.ListBuffer.empty) + stubApplications.get(wso2ApplicationName).map(subscriptions => subscriptions += api) + HasSucceeded + } + + override def deleteApplication(wso2Username: String, wso2Password: String, wso2ApplicationName: String) + (implicit hc: HeaderCarrier) = Future.successful { + stubApplications -= wso2ApplicationName + HasSucceeded + } + + override def getSubscriptions(wso2Username: String, wso2Password: String, wso2ApplicationName: String) + (implicit hc: HeaderCarrier) = Future.successful { + stubApplications.getOrElse(wso2ApplicationName, Nil) + } + + override def getAllSubscriptions(wso2Username: String, wso2Password: String) + (implicit hc: HeaderCarrier) = Future.successful { + stubApplications.mapValues { _.toSeq } + } + + override def resubscribeApi(originalApis: Seq[APIIdentifier], wso2Username: String, wso2Password: String, + wso2ApplicationName: String, api: APIIdentifier, rateLimitTier: RateLimitTier) + (implicit hc: HeaderCarrier): Future[HasSucceeded] = { + Future.successful(HasSucceeded) + } + + override def updateApplication(wso2Username: String, wso2Password: String, wso2ApplicationName: String, rateLimitTier: RateLimitTier) + (implicit hc: HeaderCarrier): Future[HasSucceeded] = { + Future.successful(HasSucceeded) + } + + override def checkApplicationRateLimitTier(wso2Username: String, wso2Password: String, wso2ApplicationName: String, expectedRateLimitTier: RateLimitTier) + (implicit hc: HeaderCarrier): Future[HasSucceeded] = { + Future.successful(HasSucceeded) + } +} diff --git a/app/uk/gov/hmrc/services/WSO2RestoreService.scala b/app/uk/gov/hmrc/services/WSO2RestoreService.scala new file mode 100644 index 000000000..c47501868 --- /dev/null +++ b/app/uk/gov/hmrc/services/WSO2RestoreService.scala @@ -0,0 +1,135 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.services + +import java.util.UUID +import javax.inject.{Inject, Singleton} + +import play.api.Logger +import play.modules.reactivemongo.ReactiveMongoComponent +import reactivemongo.bson.{BSONDocument, BSONObjectID} +import reactivemongo.play.json.ImplicitBSONHandlers._ +import uk.gov.hmrc.connector.WSO2APIStoreConnector +import uk.gov.hmrc.http.HeaderCarrier +import uk.gov.hmrc.models.MongoFormat._ +import uk.gov.hmrc.models._ +import uk.gov.hmrc.mongo.ReactiveRepository +import uk.gov.hmrc.mongo.json.ReactiveMongoFormats +import uk.gov.hmrc.repository.{ApplicationRepository, SubscriptionRepository} +import uk.gov.hmrc.util.mongo.IndexHelper._ + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +@Singleton +class WSO2RestoreService @Inject()(wso2APIStoreConnector: WSO2APIStoreConnector, + wso2APIStore: WSO2APIStore, + subscriptionRepository: SubscriptionRepository, + applicationRepository: ApplicationRepository, + migrationRepository: WSO2RestoreRepository) { + + implicit val hc: uk.gov.hmrc.http.HeaderCarrier = HeaderCarrier() + + def restoreData(): Future[Seq[HasSucceeded]] = { + Logger.info("Starting the WSO2 applications and subscriptions restore job") + val eventualApplicationDatas = applicationRepository.fetchAll() + val eventualAppsToMigrate = migrationRepository.fetchAllUnfinished() + val a: Future[Seq[ApplicationData]] = for { + appsToMigrate: Seq[WSO2RestoreData] <- eventualAppsToMigrate + appDatas: Seq[ApplicationData] <- eventualApplicationDatas + appToMigrate = appDatas.filter(app => appsToMigrate.map(_.appId).contains(app.id)) + } yield appToMigrate + a.flatMap(_.foldLeft(Future.successful(Seq[HasSucceeded]()))((fs, appData) => + fs.flatMap((seq) => restoreApp(appData).map(_ ++ seq)) + )) + } + + private def restoreApp(appData: ApplicationData): Future[Seq[HasSucceeded]] = { + Logger.info(s"Starting to restore ${appData.name}.") + wso2APIStore.createApplication(appData.wso2Username, appData.wso2Password, appData.wso2ApplicationName).flatMap(_ => + migrationRepository.save(WSO2RestoreData(appData.id, None, None, None, None, Some(false))).flatMap( + _ => { + val succeeded: Future[Seq[Future[HasSucceeded]]] = subscriptionRepository.getSubscriptions(appData.id).map( + _.map(addSubscription(appData, _)) + ) + succeeded recover { + case _ => Logger.info("Error dealing with subscriptions.") + } + + Logger.info(s"Finished restoring application ${appData.name} and its subscriptions.") + saveFinished(appData).flatMap(_ => succeeded.flatMap(a => Future.sequence(a))) + } + ) + ) + } + + private def saveFinished(appData: ApplicationData) = { + migrationRepository.save( + WSO2RestoreData(appData.id, + Some(appData.wso2ApplicationName), + Some(appData.tokens.production.clientId), + Some(appData.tokens.production.wso2ClientSecret), + Some(appData.tokens.production.accessToken), + Some(true) + ) + ) + } + + private def addSubscription(appData: ApplicationData, apiIdentifier: APIIdentifier) = { + Logger.info(s"Trying to subscribe application ${appData.name} to API $apiIdentifier") + wso2APIStore.addSubscription(appData.wso2Username, + appData.wso2Password, + appData.wso2ApplicationName, + apiIdentifier, + appData.rateLimitTier) + } +} + +case class WSO2RestoreData(appId: UUID, + wso2ApplicationName: Option[String], + clientId: Option[String], + wso2ClientSecret: Option[String], + accessToken: Option[String], + finished: Option[Boolean]) + +@Singleton +class WSO2RestoreRepository @Inject()(mongo: ReactiveMongoComponent) + extends ReactiveRepository[WSO2RestoreData, BSONObjectID]("migration", mongo.mongoConnector.db, + MongoFormat.formatWSO2RestoreData, ReactiveMongoFormats.objectIdFormats) { + + override def indexes = Seq( + createSingleFieldAscendingIndex( + indexFieldKey = "appId", + indexName = Some("applicationIdIndex") + ), + createSingleFieldAscendingIndex( + indexFieldKey = "finished", + indexName = Some("finishedIndex") + ) + ) + + def save(migrationData: WSO2RestoreData) = { + collection.find(BSONDocument("appId" -> migrationData.appId.toString)).one[BSONDocument].flatMap { + case Some(document) => collection.update(selector = BSONDocument("_id" -> document.get("_id")), update = migrationData) + case None => collection.insert(migrationData) + } + } + + def fetchAllUnfinished(): Future[Seq[WSO2RestoreData]] = { + collection.find(BSONDocument("finished" -> false)).cursor[WSO2RestoreData]().collect[Seq]() + } +} diff --git a/app/uk/gov/hmrc/util/CredentialGenerator.scala b/app/uk/gov/hmrc/util/CredentialGenerator.scala new file mode 100644 index 000000000..99b1a9083 --- /dev/null +++ b/app/uk/gov/hmrc/util/CredentialGenerator.scala @@ -0,0 +1,23 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.util + +import scala.util.Random + +class CredentialGenerator { + def generate(): String = Random.alphanumeric.take(10).mkString // scalastyle:ignore +} diff --git a/app/uk/gov/hmrc/util/http/HttpHeaders.scala b/app/uk/gov/hmrc/util/http/HttpHeaders.scala new file mode 100644 index 000000000..6df583d8e --- /dev/null +++ b/app/uk/gov/hmrc/util/http/HttpHeaders.scala @@ -0,0 +1,26 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.util.http + +import play.api.http.HeaderNames + +object HttpHeaders extends HeaderNames { + val X_REQUEST_ID_HEADER = "X-Request-ID" + val LOGGED_IN_USER_EMAIL_HEADER = "X-email-address" + val LOGGED_IN_USER_NAME_HEADER = "X-name" + val SERVER_TOKEN_HEADER = "X-server-token" +} \ No newline at end of file diff --git a/app/uk/gov/hmrc/util/mongo/IndexHelper.scala b/app/uk/gov/hmrc/util/mongo/IndexHelper.scala new file mode 100644 index 000000000..38e89e333 --- /dev/null +++ b/app/uk/gov/hmrc/util/mongo/IndexHelper.scala @@ -0,0 +1,33 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.util.mongo + +import reactivemongo.api.indexes.{Index, IndexType} +import reactivemongo.api.indexes.IndexType.Ascending + +object IndexHelper { + + def createIndex(indexFieldsKey: Seq[(String, IndexType)], indexName: Option[String], isUnique: Boolean = false, isBackground: Boolean = true): Index = + Index(key = indexFieldsKey, name = indexName, unique = isUnique, background = isBackground) + + def createSingleFieldAscendingIndex(indexFieldKey: String, indexName: Option[String], isUnique: Boolean = false, isBackground: Boolean = true): Index = + Index(key = Seq(indexFieldKey -> Ascending), name = indexName, unique = isUnique, background = isBackground) + + def createAscendingIndex(indexName: Option[String], isUnique: Boolean, isBackground: Boolean, indexFieldsKey: String*): Index = + Index(key = indexFieldsKey.map { _ -> Ascending }, name = indexName, unique = isUnique, background = isBackground) + +} diff --git a/app/uk/gov/hmrc/views/application.scala.txt b/app/uk/gov/hmrc/views/application.scala.txt new file mode 100644 index 000000000..ac6c88322 --- /dev/null +++ b/app/uk/gov/hmrc/views/application.scala.txt @@ -0,0 +1,98 @@ +@(apiContext: String)#%RAML 1.0 +--- + +title: Third Party Application +version: 1.0 +protocols: [ HTTPS ] +baseUri: https://api.service.hmrc.gov.uk/ + +mediaType: [ application/json ] + +uses: + sec: https://developer.service.hmrc.gov.uk/api-documentation/assets/common/modules/securitySchemes.raml + headers: https://developer.service.hmrc.gov.uk/api-documentation/assets/common/modules/headers.raml + annotations: https://developer.service.hmrc.gov.uk/api-documentation/assets/common/modules/annotations.raml + types: https://developer.service.hmrc.gov.uk/api-documentation/assets/common/modules/types.raml + +/@apiContext: + /developer/applications: + get: + queryParameters: + emailAddress: + type: string + is: [headers.acceptHeader] + securedBy: [ sec.x-application ] + + /application: + post: + is: [headers.acceptHeader] + securedBy: [ sec.x-application ] + + /{id}: + uriParameters: + id: + type: string + post: + is: [headers.acceptHeader] + securedBy: [ sec.x-application ] + get: + is: [headers.acceptHeader] + securedBy: [ sec.x-application ] + + /collaborator: + post: + is: [headers.acceptHeader] + securedBy: [ sec.x-application ] + + /{emailAddress}: + delete: + queryParameters: + admin: + type: string + is: [headers.acceptHeader] + securedBy: [ sec.x-application ] + + /subscription: + get: + is: [headers.acceptHeader] + securedBy: [ sec.x-application ] + post: + is: [headers.acceptHeader] + securedBy: [ sec.x-application ] + delete: + queryParameters: + context: + type: string + version: + type: string + is: [headers.acceptHeader] + securedBy: [ sec.x-application ] + + /client-secret: + post: + is: [headers.acceptHeader] + securedBy: [ sec.x-application ] + + /revoke-client-secrets: + post: + is: [headers.acceptHeader] + securedBy: [ sec.x-application ] + + /credentials: + get: + is: [headers.acceptHeader] + securedBy: [ sec.x-application ] + + /request-uplift: + post: + is: [headers.acceptHeader] + securedBy: [ sec.x-application ] + + /verify-uplift: + /{verificationCode}: + uriParameters: + verificationCode: + type: string + post: + is: [headers.acceptHeader] + securedBy: [ sec.x-application ] diff --git a/app/uk/gov/hmrc/views/definition.scala.txt b/app/uk/gov/hmrc/views/definition.scala.txt new file mode 100644 index 000000000..187b7c212 --- /dev/null +++ b/app/uk/gov/hmrc/views/definition.scala.txt @@ -0,0 +1,20 @@ +@import uk.gov.hmrc.models.APIAccess +@import uk.gov.hmrc.models.JsonFormatters._ +@import play.api.libs.json.Json +@(apiContext: String, access: APIAccess) +{ + "scopes":[], + "api": { + "name": "Third Party Application", + "description": "Internal API for use by the developer hub", + "context": "@apiContext", + "versions": [ + { + "version": "1.0", + "status": "STABLE", + "endpointsEnabled": true, + "access": @Json.toJson(access) + } + ] + } +} diff --git a/build.sbt b/build.sbt new file mode 100644 index 000000000..d242e6135 --- /dev/null +++ b/build.sbt @@ -0,0 +1,92 @@ +import play.core.PlayVersion +import play.sbt.PlayImport._ +import sbt.Keys._ +import sbt.Tests.{Group, SubProcess} +import sbt._ +import uk.gov.hmrc.DefaultBuildSettings._ +import uk.gov.hmrc.PublishingSettings._ +import uk.gov.hmrc.sbtdistributables.SbtDistributablesPlugin._ + +lazy val appName = "third-party-application" + +lazy val appDependencies: Seq[ModuleID] = compile ++ test + +lazy val compile = Seq( + ws, + "uk.gov.hmrc" %% "microservice-bootstrap" % "8.2.0", + "uk.gov.hmrc" %% "mongo-lock" % "5.1.0", + "uk.gov.hmrc" %% "play-reactivemongo" % "6.2.0", + "uk.gov.hmrc" %% "play-scheduling" % "4.1.0", + "uk.gov.hmrc" %% "play-json-union-formatter" % "1.3.0", + "uk.gov.hmrc" %% "play-hmrc-api" % "2.0.0" +) +lazy val test = Seq( + "uk.gov.hmrc" %% "reactivemongo-test" % "3.1.0" % "test,it", + "uk.gov.hmrc" %% "hmrctest" % "3.0.0" % "test,it", + "org.pegdown" % "pegdown" % "1.6.0" % "test,it", + "org.scalaj" %% "scalaj-http" % "2.3.0" % "test,it", + "org.scalatest" %% "scalatest" % "2.2.6" % "test,it", + "org.scalatestplus.play" %% "scalatestplus-play" % "2.0.0" % "test,it", + "com.typesafe.play" %% "play-test" % PlayVersion.current % "test,it", + "com.github.tomakehurst" % "wiremock" % "1.58" % "test,it", + "org.mockito" % "mockito-core" % "1.9.5" % "test,it" +) +lazy val plugins: Seq[Plugins] = Seq(_root_.play.sbt.PlayScala, SbtAutoBuildPlugin, SbtGitVersioning, SbtDistributablesPlugin, SbtArtifactory) +lazy val playSettings: Seq[Setting[_]] = Seq.empty + +lazy val microservice = (project in file(".")) + .enablePlugins(plugins: _*) + .settings(playSettings: _*) + .settings(scalaSettings: _*) + .settings(publishingSettings: _*) + .settings(defaultSettings(): _*) + .settings( + name := appName, + libraryDependencies ++= appDependencies, + retrieveManaged := true, + routesGenerator := InjectedRoutesGenerator, + majorVersion := 0 + ) + .settings( + unmanagedSourceDirectories in Compile += baseDirectory.value / "common", + unmanagedResourceDirectories in Compile += baseDirectory.value / "resources" + ) + .settings(playPublishingSettings: _*) + .settings(inConfig(TemplateTest)(Defaults.testSettings): _*) + .configs(IntegrationTest) + .settings(inConfig(TemplateItTest)(Defaults.itSettings): _*) + .settings( + Keys.fork in IntegrationTest := false, + unmanagedSourceDirectories in IntegrationTest <<= (baseDirectory in IntegrationTest) (base => Seq(base / "test")), + addTestReportOption(IntegrationTest, "int-test-reports"), + testGrouping in IntegrationTest := oneForkedJvmPerTest((definedTests in IntegrationTest).value), + parallelExecution in IntegrationTest := false) + .settings( + resolvers += Resolver.jcenterRepo + ) + .settings(scalacOptions ++= Seq("-deprecation", "-feature")) + .settings(ivyScala := ivyScala.value map (_.copy(overrideScalaVersion = true))) + +lazy val allPhases = "tt->test;test->test;test->compile;compile->compile" +lazy val allItPhases = "tit->it;it->it;it->compile;compile->compile" + +lazy val TemplateTest = config("tt") extend Test +lazy val TemplateItTest = config("tit") extend IntegrationTest +lazy val playPublishingSettings: Seq[sbt.Setting[_]] = Seq( + + credentials += SbtCredentials, + + publishArtifact in(Compile, packageDoc) := false, + publishArtifact in(Compile, packageSrc) := false +) ++ + publishAllArtefacts + +def oneForkedJvmPerTest(tests: Seq[TestDefinition]) = + tests map { + test => Group(test.name, Seq(test), SubProcess(ForkOptions(runJVMOptions = Seq("-Dtest.name=" + test.name)))) + } + +// Coverage configuration +coverageMinimum := 87 +coverageFailOnMinimum := true +coverageExcludedPackages := ";com.kenshoo.play.metrics.*;.*definition.*;prod.*;testOnlyDoNotUseInAppConf.*;app.*;uk.gov.hmrc.BuildInfo" diff --git a/conf/app.routes b/conf/app.routes new file mode 100644 index 000000000..9762d2400 --- /dev/null +++ b/conf/app.routes @@ -0,0 +1,49 @@ +# microservice specific routes + +GET /developer/applications @uk.gov.hmrc.controllers.ApplicationController.queryDispatcher() + +GET /application @uk.gov.hmrc.controllers.ApplicationController.queryDispatcher() +GET /application/wso2-credentials @uk.gov.hmrc.controllers.ApplicationController.fetchWso2Credentials(clientId: String) +GET /application/subscriptions @uk.gov.hmrc.controllers.ApplicationController.fetchAllAPISubscriptions() + +GET /application/:id @uk.gov.hmrc.controllers.ApplicationController.fetch(id: java.util.UUID) +POST /application @uk.gov.hmrc.controllers.ApplicationController.create +POST /application/:id @uk.gov.hmrc.controllers.ApplicationController.update(id: java.util.UUID) + +GET /application/:id/access/scopes @uk.gov.hmrc.controllers.AccessController.readScopes(id: java.util.UUID) +PUT /application/:id/access/scopes @uk.gov.hmrc.controllers.AccessController.updateScopes(id: java.util.UUID) + +GET /application/:id/access/overrides @uk.gov.hmrc.controllers.AccessController.readOverrides(id: java.util.UUID) +PUT /application/:id/access/overrides @uk.gov.hmrc.controllers.AccessController.updateOverrides(id: java.util.UUID) + +GET /application/:id/credentials @uk.gov.hmrc.controllers.ApplicationController.fetchCredentials(id: java.util.UUID) +POST /application/credentials/validate @uk.gov.hmrc.controllers.ApplicationController.validateCredentials + +POST /application/:id/collaborator @uk.gov.hmrc.controllers.ApplicationController.addCollaborator(id: java.util.UUID) +DELETE /application/:id/collaborator/:email @uk.gov.hmrc.controllers.ApplicationController.deleteCollaborator(id: java.util.UUID, email: String, admin: String, adminsToEmail: String) + +GET /application/:id/subscription @uk.gov.hmrc.controllers.ApplicationController.fetchAllSubscriptions(id: java.util.UUID) +GET /application/:id/subscription/:context/:version @uk.gov.hmrc.controllers.ApplicationController.isSubscribed(id: java.util.UUID, context: String, version: String) +POST /application/:id/subscription @uk.gov.hmrc.controllers.ApplicationController.createSubscriptionForApplication(id: java.util.UUID) +DELETE /application/:id/subscription @uk.gov.hmrc.controllers.ApplicationController.removeSubscriptionForApplication(id: java.util.UUID, context, version) + + +POST /application/:id/client-secret @uk.gov.hmrc.controllers.ApplicationController.addClientSecret(id: java.util.UUID) +POST /application/:id/revoke-client-secrets @uk.gov.hmrc.controllers.ApplicationController.deleteClientSecrets(id: java.util.UUID) + +POST /application/:id/request-uplift @uk.gov.hmrc.controllers.ApplicationController.requestUplift(id: java.util.UUID) +POST /application/:id/approve-uplift @uk.gov.hmrc.controllers.GatekeeperController.approveUplift(id: java.util.UUID) +POST /application/:id/reject-uplift @uk.gov.hmrc.controllers.GatekeeperController.rejectUplift(id: java.util.UUID) +POST /application/:id/resend-verification @uk.gov.hmrc.controllers.GatekeeperController.resendVerification(id: java.util.UUID) +POST /application/:id/delete @uk.gov.hmrc.controllers.GatekeeperController.deleteApplication(id: java.util.UUID) + +POST /application/:id/rate-limit-tier @uk.gov.hmrc.controllers.ApplicationController.updateRateLimitTier(id: java.util.UUID) + +POST /verify-uplift/:code @uk.gov.hmrc.controllers.ApplicationController.verifyUplift(code: String) + +GET /gatekeeper/applications @uk.gov.hmrc.controllers.GatekeeperController.fetchAppsForGatekeeper +GET /gatekeeper/application/:id @uk.gov.hmrc.controllers.GatekeeperController.fetchAppById(id: java.util.UUID) + +POST /application/:id/check-information @uk.gov.hmrc.controllers.ApplicationController.updateCheck(id: java.util.UUID) + +POST /admin/application/restore-wso2-data @uk.gov.hmrc.controllers.WSO2RestoreController.restoreWSO2Data() diff --git a/conf/application-json-logger.xml b/conf/application-json-logger.xml new file mode 100644 index 000000000..e40586ed1 --- /dev/null +++ b/conf/application-json-logger.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/conf/application.conf b/conf/application.conf new file mode 100644 index 000000000..6d59aaaa3 --- /dev/null +++ b/conf/application.conf @@ -0,0 +1,300 @@ +# Copyright 2018 HM Revenue & Customs +# +# 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. + +appName = third-party-application + +# Define any modules used here +play.modules.enabled += "com.kenshoo.play.metrics.PlayModule" +play.modules.enabled += "play.modules.reactivemongo.ReactiveMongoHmrcModule" +play.http.requestHandler = "play.api.http.GlobalSettingsHttpRequestHandler" + +# Session Timeout +# ~~~~ +# The default session timeout for the app is 15 minutes (900seconds). +# Updating this is the responsibility of the app - it must issue a new cookie with each request or the session will +# timeout 15 minutes after login (regardless of user activity). +# session.maxAge=900 + +# Secret key +# ~~~~~ +# The secret key is used to secure cryptographics functions. +# If you deploy your application to several instances be sure to use the same key! +application.secret = "3kXCHYnjCIvNeJa6KHrpNy6z8tLGqTtMMls0V5ypVihNd3irhDk47ctKmGXluPlz" + +# Session configuration +# ~~~~~ +application.session.httpOnly = false + +application.session.secure = false + +# The application languages +# ~~~~~ +application.langs = "en" + +# Global object class +# ~~~~~ +# Define the Global object class for this application. +# Default to Global in the root package. +application.global = uk.gov.hmrc.config.ApplicationGlobal + +# Router +# ~~~~~ +# Define the Router object to use for this application. +# This router will be looked up first when the application is starting up, +# so make sure this is the entry point. +# Furthermore, it's assumed your route file is named properly. +# So for an application router like `my.application.Router`, +# you may need to define a router file `conf/my.application.routes`. +# Default to Routes in the root package (and conf/routes) +# !!!WARNING!!! DO NOT CHANGE THIS ROUTER +application.router = prod.Routes + +# Cache Control +fetchApplicationTtlInSeconds = 300 +fetchSubscriptionTtlInSeconds = 300 + +# Feature Flags +# ~~~~~ +# Flag here control the way the application works. +# Once a flag is no longer needed it should be remove from the application. + +# Controller +# ~~~~~ +# By default all controllers will have authorisation, logging and +# auditing (transaction monitoring) enabled. +# The below controllers are the default exceptions to this rule. + +controllers { + com.kenshoo.play.metrics.MetricsController = { + needsAuth = false + needsLogging = false + needsAuditing = false + }, + controllers.ApplicationController = { + needsAuth = false + needsLogging = true + needsAuditing = false + } +} + + +# Evolutions +# ~~~~~ +# You can disable evolutions if needed +# evolutionplugin=disabled + +# Metrics plugin settings - graphite reporting is configured on a per env basis +metrics { + name = ${appName} + rateUnit = SECONDS + durationUnit = SECONDS + showSamples = true + jvm = true + enabled = true +} + +# Microservice specific config +clientSecretLimit = 5 +upliftVerificationValidity = 90d + +Dev { + skipWso2 = false + devHubBaseUrl = "http://localhost:9685" + trustedApplications = [] + + mongodb { + uri = "mongodb://localhost:27017/third-party-application" + } + + upliftVerificationExpiryJob { + initialDelay = 60s + interval = 6h + enabled = true + } + + refreshSubscriptionsJob { + initialDelay = 120s + interval = 60m + enabled = true + } + + auditing { + enabled = true + traceRequests = false + consumer { + baseUri { + host = localhost + port = 8100 + } + } + } + + microservice { + metrics { + graphite { + host = graphite + port = 2003 + prefix = play.${appName}. + enabled = false + } + } + + services { + timeout = 5 seconds + + delay-response = 2 seconds + + protocol = http + + api-definition { + host = localhost + port = 9604 + } + + api-subscription-fields { + host = localhost + port = 9650 + } + + wso2-store { + protocol = http + host = localhost + port = 9763 + username = admin + } + + email { + host = localhost + port = 8300 + } + + third-party-developer { + host = localhost + port = 9615 + } + + auth { + host = localhost + port = 8500 + } + + totp { + host = localhost + port = 9988 + } + + service-locator { + host = localhost + port = 9602 + } + + third-party-delegated-authority { + protocol = https + host = localhost + port = 9606 + } + } + } +} + +Test { + skipWso2 = true + devHubBaseUrl = "http://localhost:9685" + trustedApplications = [162017dc-607b-4405-8208-a28308672f76, 162017dc-607b-4405-8208-a28308672f77] + + mongodb { + uri = "mongodb://localhost:27017/third-party-application-test" + } + + upliftVerificationExpiryJob { + initialDelay = 60s + interval = 6h + enabled = true + } + + auditing { + enabled = false + traceRequests = false + } + + microservice { + metrics { + graphite { + host = graphite + port = 2003 + prefix = play.${appName}. + enabled = false + } + } + + services { + timeout = 5 seconds + + delay-response = 2 seconds + + protocol = http + + api-definition { + host = localhost + port = 22221 + } + + api-subscription-fields { + host = localhost + port = 22227 + } + + wso2-store { + protocol = http + host = localhost + port = 22222 + username = admin + } + + email { + host = localhost + port = 22223 + } + + third-party-developer { + host = localhost + port = 22224 + } + + auth { + host = localhost + port = 22225 + } + + totp { + host = localhost + port = 22226 + } + + service-locator { + enabled = false + host = localhost + port = 9602 + } + + third-party-delegated-authority { + host = localhost + port = 22228 + } + } + } +} + +Prod { +} diff --git a/conf/definition.routes b/conf/definition.routes new file mode 100644 index 000000000..dc7b2aed6 --- /dev/null +++ b/conf/definition.routes @@ -0,0 +1,2 @@ +GET /api/definition @uk.gov.hmrc.controllers.DocumentationController.definition() +GET /api/conf/:version/*file @uk.gov.hmrc.controllers.DocumentationController.raml(version, file) diff --git a/conf/logback.xml b/conf/logback.xml new file mode 100644 index 000000000..2e453966a --- /dev/null +++ b/conf/logback.xml @@ -0,0 +1,58 @@ + + + + + logs/third-party-application.log + + %date{ISO8601} level=[%level] logger=[%logger] thread=[%thread] message=[%message] %replace(exception=[%xException]){'^exception=\[\]$',''}%n + + + + + + %date{ISO8601} level=[%level] logger=[%logger] thread=[%thread] rid=[%X{X-Request-ID}] user=[%X{Authorization}] message=[%message] %replace(exception=[%xException]){'^exception=\[\]$',''}%n + + + + + + %date{ISO8601} level=[%level] logger=[%logger] thread=[%thread] rid=[not-available] user=[not-available] message=[%message] %replace(exception=[%xException]){'^exception=\[\]$',''}%n + + + + + logs/access.log + + %message%n + + + + + logs/connectors.log + + %message%n + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/conf/prod.routes b/conf/prod.routes new file mode 100644 index 000000000..7f1f92103 --- /dev/null +++ b/conf/prod.routes @@ -0,0 +1,5 @@ +# Add all the application routes to the app.routes file +-> / app.Routes +-> / definition.Routes +-> / health.Routes +GET /admin/metrics @com.kenshoo.play.metrics.MetricsController.metrics \ No newline at end of file diff --git a/conf/testOnlyDoNotUseInAppConf.routes b/conf/testOnlyDoNotUseInAppConf.routes new file mode 100644 index 000000000..b98c783ec --- /dev/null +++ b/conf/testOnlyDoNotUseInAppConf.routes @@ -0,0 +1,13 @@ +# IF THE MICRO-SERVICE DOES NOT NEED ANY TEST-ONLY END-POINTS (ALWAYS PREFERRED) DELETE THIS FILE. + +# !!!WARNING!!! This file MUST NOT be referenced in the "application.conf" file to avoid risk of rolling test routes in the production environment. +# If you need test routes when running tests in CI make sure that the profile for this micro-service (used by service-manager) defines this router as parameter. +# To do so add the following line to the micro-service profile: "-Dapplication.router=testOnlyDoNotUseInAppConf.Routes" +# To start the micro-service locally using the test routes run the following command: "sbt run -Dapplication.router=testOnlyDoNotUseInAppConf.Routes" + +# Any test-only end-point should be defined here. +# !!!WARNING!!! Every route defined in this file MUST be prefixed with "/test-only/". This is because NGINX is blocking every uri containing the string "test-only" in production. +# Failing to follow this rule may result in test routes deployed in production. + +# Add all the application routes to the prod.routes file +-> / prod.Routes diff --git a/dependencyReport.py b/dependencyReport.py new file mode 100644 index 000000000..b5845e2f6 --- /dev/null +++ b/dependencyReport.py @@ -0,0 +1,69 @@ +#!/usr/bin/python + +import os, urllib, json +import requests +import re +import subprocess +from sys import argv + +def fetchDependencies(repositoryName): + r = urllib.urlopen(os.environ['CATALOGUE_DEPENDENCIES_URL']) + j = json.load(r) + for i in range(len(j)): + if j[i]['repositoryName'] == repositoryName: + return j[i] + + return {} + +def findOutOfDateDependencies(dependencies): + outOfDateDependencies = [] + for i in range(len(dependencies)): + dependency = dependencies[i] + latestVersion = (dependency['latestVersion']['major'], + dependency['latestVersion']['minor'], + dependency['latestVersion']['patch']) + currentVersion = (dependency['currentVersion']['major'], + dependency['currentVersion']['minor'], + dependency['currentVersion']['patch']) + + if latestVersion > currentVersion: + outOfDateDependencies.append(dependency) + + return outOfDateDependencies + +def reportOnDependencies(dependencyType, dependencies): + print dependencyType + " to upgrade:" + outOfDateDependencies = findOutOfDateDependencies(dependencies) + if len(outOfDateDependencies) > 0: + for i in range(len(outOfDateDependencies)): + dependency = outOfDateDependencies[i] + print ' \033[38;5;15m\033[48;5;1m{} {}.{}.{} -> {}.{}.{}\033[39;49m'.format( + dependency['name'], + dependency['currentVersion']['major'], + dependency['currentVersion']['minor'], + dependency['currentVersion']['patch'], + dependency['latestVersion']['major'], + dependency['latestVersion']['minor'], + dependency['latestVersion']['patch']) + else: + print '\033[38;5;2m No upgrades required\033[39;49m' + + +def generateReport(repositoryName): + print 'Generating dependency report for {}...'.format(repositoryName) + dependencies = fetchDependencies(repositoryName) + + reportOnDependencies('Libraries', dependencies['libraryDependencies']) + reportOnDependencies('SBT plugins', dependencies['sbtPluginsDependencies']) + reportOnDependencies('Other dependencies', dependencies['otherDependencies']) + +def getRepoName(): + output = subprocess.check_output(['git', 'remote', '-v']) + p = re.compile('.*github.com\:hmrc/(.*)\.git.*') + return p.match(output).group(1) + +if __name__ == "__main__": + if 'CATALOGUE_DEPENDENCIES_URL' in os.environ: + generateReport(getRepoName()) + else: + print 'CATALOGUE_DEPENDENCIES_URL environment variable not set - cannot generate dependency report' diff --git a/export-versions-for-it-tests b/export-versions-for-it-tests new file mode 100644 index 000000000..26d3c032e --- /dev/null +++ b/export-versions-for-it-tests @@ -0,0 +1 @@ +export DATASTREAM_VERSION=3.11.0 \ No newline at end of file diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 000000000..133a8f197 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=0.13.17 diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 000000000..e24744f3c --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,15 @@ +resolvers += Resolver.url("hmrc-sbt-plugin-releases", url("https://dl.bintray.com/hmrc/sbt-plugin-releases"))(Resolver.ivyStylePatterns) + +resolvers += "HMRC Releases" at "https://dl.bintray.com/hmrc/releases" + +resolvers += Resolver.url("scoverage-bintray", url("https://dl.bintray.com/sksamuel/sbt-plugins/"))(Resolver.ivyStylePatterns) + +resolvers += "Typesafe Releases" at "http://repo.typesafe.com/typesafe/releases/" + +addSbtPlugin("uk.gov.hmrc" % "sbt-auto-build" % "1.13.0") +addSbtPlugin("uk.gov.hmrc" % "sbt-git-versioning" % "1.15.0") +addSbtPlugin("uk.gov.hmrc" % "sbt-artifactory" % "0.13.0") +addSbtPlugin("uk.gov.hmrc" % "sbt-distributables" % "1.1.0") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.5.12") +addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.5.1") +addSbtPlugin("org.scalastyle" %% "scalastyle-sbt-plugin" % "1.0.0") diff --git a/repository.yaml b/repository.yaml new file mode 100644 index 000000000..b4ced17eb --- /dev/null +++ b/repository.yaml @@ -0,0 +1 @@ +digital-service: API Platform \ No newline at end of file diff --git a/run_all_tests.sh b/run_all_tests.sh new file mode 100755 index 000000000..b2a1e6df7 --- /dev/null +++ b/run_all_tests.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +mongo third-party-application-test --eval "db.dropDatabase()" +sbt clean compile coverage test it:test coverageReport +python dependencyReport.py third-party-application diff --git a/run_local.sh b/run_local.sh new file mode 100755 index 000000000..e3b4778c0 --- /dev/null +++ b/run_local.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +sbt "~run -Drun.mode=Dev -Dhttp.port=9607 $*" diff --git a/scalastyle-config.xml b/scalastyle-config.xml new file mode 100644 index 000000000..5636469b0 --- /dev/null +++ b/scalastyle-config.xml @@ -0,0 +1,99 @@ + + Scalastyle standard configuration + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/common/uk/gov/hmrc/common/LogSuppressing.scala b/test/common/uk/gov/hmrc/common/LogSuppressing.scala new file mode 100644 index 000000000..9b40c2b25 --- /dev/null +++ b/test/common/uk/gov/hmrc/common/LogSuppressing.scala @@ -0,0 +1,77 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 common.uk.gov.hmrc.common + +import ch.qos.logback.classic.{Level, Logger} +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.filter.Filter +import ch.qos.logback.core.spi.FilterReply +import play.api.LoggerLike + +import scala.collection.mutable + +import scala.collection.JavaConversions._ + +class SuppressedLogFilter(val messagesContaining: String) extends Filter[ILoggingEvent] { + private val suppressedEntries = new mutable.MutableList[ILoggingEvent]() + + override def decide(event: ILoggingEvent): FilterReply = { + if (event.getMessage.contains(messagesContaining)) { + suppressedEntries += event + FilterReply.DENY + } else { + FilterReply.NEUTRAL + } + } + + def hasError(msg: String) = { + suppressedEntries.exists(entry => entry.getLevel == Level.ERROR && entry.getMessage.contains(msg)) + } + + def hasWarn(msg: String) = { + suppressedEntries.exists(entry => entry.getLevel == Level.WARN && entry.getMessage.contains(msg)) + } + + def hasInfo(msg: String) = { + suppressedEntries.exists(entry => entry.getLevel == Level.INFO && entry.getMessage.contains(msg)) + } +} + +trait LogSuppressing { + def withSuppressedLoggingFrom(logger: Logger, messagesContaining: String)(body: (=> SuppressedLogFilter) => Unit) { + + val appenders = logger.iteratorForAppenders().toList + val appendersWithFilters = appenders.map(appender => appender->appender.getCopyOfAttachedFiltersList) + + val filter = new SuppressedLogFilter(messagesContaining) + appenders.foreach(_.addFilter(filter)) + + try body(filter) + finally { + appendersWithFilters.foreach { case(appender, filters) => + appender.clearAllFilters + filters.foreach(appender.addFilter(_)) + } + } + } + + def withSuppressedLoggingFrom(logger: LoggerLike, messagesContaining: String)(body: (=> SuppressedLogFilter) => Unit) { + withSuppressedLoggingFrom(logger.logger.asInstanceOf[Logger], messagesContaining)(body) + } +} + + diff --git a/test/common/uk/gov/hmrc/testutils/ApplicationStateUtil.scala b/test/common/uk/gov/hmrc/testutils/ApplicationStateUtil.scala new file mode 100644 index 000000000..79c125cd1 --- /dev/null +++ b/test/common/uk/gov/hmrc/testutils/ApplicationStateUtil.scala @@ -0,0 +1,42 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 common.uk.gov.hmrc.testutils + +import uk.gov.hmrc.models.{State, ApplicationState} + +trait ApplicationStateUtil { + + val generatedVerificationCode: String = "verificationCode" + + def testingState() = ApplicationState(name = State.TESTING) + + def productionState(requestedBy: String) = ApplicationState( + name = State.PRODUCTION, + requestedByEmailAddress = Some(requestedBy), + verificationCode = Some(generatedVerificationCode)) + + def pendingRequesterVerificationState(requestedBy: String) = ApplicationState( + name = State.PENDING_REQUESTER_VERIFICATION, + requestedByEmailAddress = Some(requestedBy), + verificationCode = Some(generatedVerificationCode)) + + def pendingGatekeeperApprovalState(requestedBy: String) = ApplicationState( + name = State.PENDING_GATEKEEPER_APPROVAL, + requestedByEmailAddress = Some(requestedBy), + verificationCode = None) + +} diff --git a/test/it/uk/gov/hmrc/PlatformIntegrationSpec.scala b/test/it/uk/gov/hmrc/PlatformIntegrationSpec.scala new file mode 100644 index 000000000..d3826ef2b --- /dev/null +++ b/test/it/uk/gov/hmrc/PlatformIntegrationSpec.scala @@ -0,0 +1,122 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 it.uk.gov.hmrc + +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock +import com.github.tomakehurst.wiremock.client.WireMock._ +import com.github.tomakehurst.wiremock.core.WireMockConfiguration._ +import com.typesafe.config.ConfigFactory +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.mockito.MockitoSugar +import org.scalatest.{BeforeAndAfterEach, TestData} +import org.scalatestplus.play.guice.GuiceOneAppPerTest +import play.api.http.{LazyHttpErrorHandler, Status} +import play.api.inject.guice.GuiceApplicationBuilder +import play.api.test.FakeRequest +import play.api.{Application, Mode} +import uk.gov.hmrc.config.AppContext +import uk.gov.hmrc.controllers.DocumentationController +import uk.gov.hmrc.play.microservice.filters.MicroserviceFilterSupport +import uk.gov.hmrc.play.test.UnitSpec + +/** + * Testcase to verify the capability of integration with the API platform. + * + * 1, To integrate with API platform the service needs to register itself to the service locator by calling the /registration endpoint and providing + * - application name + * - application url + * + * 2a, To expose API's to Third Party Developers, the service needs to make the API definition available under api/definition GET endpoint + * 2b, The endpoints need to be defined in an application.raml file for all versions For all of the endpoints defined documentation will be provided and be + * available under api/documentation/[version]/[endpoint name] GET endpoint + * Example: api/documentation/1.0/Fetch-Some-Data + * + */ + + +trait PlatformIntegrationSpec extends UnitSpec with MockitoSugar with ScalaFutures with BeforeAndAfterEach with GuiceOneAppPerTest { + + val publishApiDefinition: Boolean + val stubHost = "localhost" + val stubPort = sys.env.getOrElse("WIREMOCK_SERVICE_LOCATOR_PORT", "11111").toInt + val wireMockServer = new WireMockServer(wireMockConfig().port(stubPort)) + + override def newAppForTest(testData: TestData): Application = GuiceApplicationBuilder() + .configure("run.mode" -> "Stub") + .configure(Map( + "appName" -> "application-name", + "appUrl" -> "http://microservice-name.example.local", + "publishApiDefinition" -> publishApiDefinition, + "api.context" -> "test-api-context", + "Test.microservice.services.service-locator.host" -> stubHost, + "Test.microservice.services.service-locator.port" -> stubPort, + "Test.microservice.services.service-locator.enabled" -> true + )).in(Mode.Test).build() + + override def beforeEach() { + wireMockServer.start() + WireMock.configureFor(stubHost, stubPort) + stubFor(post(urlMatching("/registration")).willReturn(aResponse().withStatus(Status.NO_CONTENT))) + } + + override protected def afterEach(): Unit = { + wireMockServer.stop() + wireMockServer.resetMappings() + } + + trait Setup extends MicroserviceFilterSupport { + val documentationController = new DocumentationController(LazyHttpErrorHandler, new AppContext(ConfigFactory.load())) {} + val request = FakeRequest() + } + +} + +class PublishApiDefinitionEnabledSpec extends PlatformIntegrationSpec { + val publishApiDefinition = true + + "microservice" should { + "return the JSON definition" in new Setup { + val result = await(documentationController.definition()(request)) + status(result) shouldBe 200 + bodyOf(result) should include(""""context": "test-api-context"""") + } + + "return the RAML" in new Setup { + val result = await(documentationController.raml("1.0", "application.raml")(request)) + status(result) shouldBe 200 + bodyOf(result) should include("/test-api-context") + } + } +} + +class PublishApiDefinitionDisabledSpec extends PlatformIntegrationSpec { + val publishApiDefinition = false + + "microservice" should { + + "return a 404 from the definition endpoint" in new Setup { + val result = await(documentationController.definition()(request)) + status(result) shouldBe 404 + } + + "return a 404 from the RAML endpoint" in new Setup { + val result = await(documentationController.raml("1.0", "application.raml")(request)) + status(result) shouldBe 404 + } + } +} \ No newline at end of file diff --git a/test/it/uk/gov/hmrc/component/BaseFeatureSpec.scala b/test/it/uk/gov/hmrc/component/BaseFeatureSpec.scala new file mode 100644 index 000000000..09764bd05 --- /dev/null +++ b/test/it/uk/gov/hmrc/component/BaseFeatureSpec.scala @@ -0,0 +1,65 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.component + +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock +import com.github.tomakehurst.wiremock.core.WireMockConfiguration +import uk.gov.hmrc.component.stubs._ +import org.scalatest._ +import org.scalatestplus.play.OneServerPerSuite + +import scala.concurrent.duration._ +import scala.language.postfixOps + +abstract class BaseFeatureSpec extends FeatureSpec with GivenWhenThen with Matchers + with BeforeAndAfterEach with BeforeAndAfterAll with OneServerPerSuite { + + override lazy val port = 19111 + val serviceUrl = s"http://localhost:$port" + val timeout = 10 seconds + + val wso2Store = WSO2StoreStub + var thirdPartyDeveloper = ThirdPartyDeveloperStub + val apiDefinition = ApiDefinitionStub + val apiSubscriptionFields = ApiSubscriptionFieldsStub + val thirdPartyDelegatedAuthorityConnector = ThirdPartyDelegatedAuthorityStub + val authConnector = AuthStub + val totpConnector = TOTPStub + val mocks = Seq(wso2Store, thirdPartyDeveloper, apiDefinition, apiSubscriptionFields, authConnector, totpConnector, thirdPartyDelegatedAuthorityConnector) + + override protected def beforeAll(): Unit = { + mocks.foreach(m => if (!m.stub.server.isRunning) m.stub.server.start()) + } + + override protected def afterEach(): Unit = { + mocks.foreach(_.stub.mock.resetMappings()) + } + + override protected def afterAll(): Unit = { + mocks.foreach(_.stub.server.stop()) + } +} + +case class MockHost(port: Int) { + val server = new WireMockServer(WireMockConfiguration.wireMockConfig().port(port)) + val mock = new WireMock("localhost", port) +} + +trait Stub { + val stub: MockHost +} \ No newline at end of file diff --git a/test/it/uk/gov/hmrc/component/ThirdPartyApplicationComponentSpec.scala b/test/it/uk/gov/hmrc/component/ThirdPartyApplicationComponentSpec.scala new file mode 100644 index 000000000..6533668dd --- /dev/null +++ b/test/it/uk/gov/hmrc/component/ThirdPartyApplicationComponentSpec.scala @@ -0,0 +1,571 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 it.uk.gov.hmrc.component + +import java.util.UUID + +import com.github.tomakehurst.wiremock.client.WireMock._ +import com.github.tomakehurst.wiremock.stubbing.Scenario +import org.joda.time.DateTimeUtils +import play.api.http.Status._ +import play.api.inject.bind +import play.api.inject.guice.GuiceApplicationBuilder +import play.api.libs.json.Json +import scalaj.http.{Http, HttpResponse} +import uk.gov.hmrc.component.BaseFeatureSpec +import uk.gov.hmrc.component.stubs.WSO2StoreStub.{WSO2Subscription, WSO2SubscriptionResponse} +import uk.gov.hmrc.controllers.AddCollaboratorResponse +import uk.gov.hmrc.models.JsonFormatters._ +import uk.gov.hmrc.models._ +import uk.gov.hmrc.repository.{ApplicationRepository, SubscriptionRepository} +import uk.gov.hmrc.util.CredentialGenerator + +import scala.concurrent.Await.result +import scala.concurrent.ExecutionContext.Implicits.global +import scala.util.Random + +class DummyCredentialGenerator extends CredentialGenerator { + override def generate() = "a" * 10 +} + +class ThirdPartyApplicationComponentSpec extends BaseFeatureSpec { + + implicit override lazy val app = + GuiceApplicationBuilder() + .configure(Map("Test.skipWso2" -> false, "appName" -> "third-party-application")) + .overrides(bind[CredentialGenerator].to[DummyCredentialGenerator]) + .build() + + val applicationName1 = "My 1st Application" + val applicationName2 = "My 2nd Application" + val emailAddress = "user@example.com" + val gatekeeperUserId = "gate.keeper" + val username = "a" * 10 + val password = "a" * 10 + val wso2ApplicationName = "a" * 10 + val cookie = Random.alphanumeric.take(10).mkString + val serviceName = "service" + val apiName = "apiName" + val context = "myapi" + val version = "1.0" + val anApiDefinition = APIDefinition(serviceName, apiName, context, Seq(APIVersion(version, APIStatus.STABLE, None)), None) + val standardAccess = Standard( + redirectUris = Seq("http://example.com/redirect"), + termsAndConditionsUrl = Some("http://example.com/terms"), + privacyPolicyUrl = Some("http://example.com/privacy"), + overrides = Set.empty + ) + val privilegedAccess = Privileged(totpIds = None, scopes = Set("ogdScope")) + + lazy val subscriptionRepository = app.injector.instanceOf[SubscriptionRepository] + lazy val applicationRepository = app.injector.instanceOf[ApplicationRepository] + + override protected def afterEach(): Unit = { + DateTimeUtils.setCurrentMillisSystem() + result(subscriptionRepository.removeAll(), timeout) + result(applicationRepository.removeAll(), timeout) + super.afterEach() + } + + override protected def beforeEach(): Unit = { + super.beforeEach() + result(applicationRepository.removeAll(), timeout) + wso2Store.willAddUserSuccessfully() + wso2Store.willLoginAndReturnCookieFor(username, password, cookie) + wso2Store.willLogout(cookie) + wso2Store.willAddSubscription(wso2ApplicationName, context, version, RateLimitTier.BRONZE) + wso2Store.willRemoveSubscription(wso2ApplicationName, context, version) + + DateTimeUtils.setCurrentMillisFixed(DateTimeUtils.currentTimeMillis()) + } + + feature("Fetch all applications") { + + scenario("Fetch all applications") { + + Given("A third party application") + val application1: ApplicationResponse = createApplication(wso2ApplicationName) + + And("The application is subscribed to an API in WSO2") + wso2Store.willLoginAndReturnCookieFor("DUMMY", "DUMMY", "admin-cookie") + wso2Store.willReturnAllSubscriptions(wso2ApplicationName -> Seq(APIIdentifier(context, version))) + wso2Store.willLogout("admin-cookie") + + When("We fetch all applications") + val fetchResponse = Http(s"$serviceUrl/application").asString + fetchResponse.code shouldBe OK + val result = Json.parse(fetchResponse.body).as[Seq[ApplicationResponse]] + + Then("The application is returned in the result") + result.exists(r => r.id == application1.id) shouldBe true + } + } + + feature("Fetch an application") { + + scenario("Fetch application from an application ID") { + + Given("A third party application") + val application: ApplicationResponse = createApplication() + + And("The applications are subscribed to an API in WSO2") + wso2Store.willLoginAndReturnCookieFor("DUMMY", "DUMMY", "admin-cookie") + wso2Store.willReturnAllSubscriptions(wso2ApplicationName -> Seq(APIIdentifier(context, version))) + wso2Store.willLogout("admin-cookie") + + When("We fetch the application by its ID") + val fetchResponse = Http(s"$serviceUrl/application/${application.id}").asString + fetchResponse.code shouldBe OK + val result = Json.parse(fetchResponse.body).as[ApplicationResponse] + + Then("The application is returned") + result shouldBe application + } + + scenario("Fetch application from a collaborator email address") { + + Given("A collaborator has access to two third party applications") + val application1: ApplicationResponse = createApplication(applicationName1) + val application2: ApplicationResponse = createApplication(applicationName2) + + And("The applications are subscribed to an API in WSO2") + wso2Store.willLoginAndReturnCookieFor("DUMMY", "DUMMY", "admin-cookie") + wso2Store.willReturnAllSubscriptions(wso2ApplicationName -> Seq(APIIdentifier(context, version))) + wso2Store.willLogout("admin-cookie") + + When("We fetch the application by the collaborator email address") + val fetchResponse = Http(s"$serviceUrl/application?emailAddress=$emailAddress").asString + fetchResponse.code shouldBe OK + val result = Json.parse(fetchResponse.body).as[Seq[ApplicationResponse]] + + Then("The applications are returned") + result should contain theSameElementsAs Seq(application1, application2) + } + + scenario("Fetch application credentials") { + + val appName = "appName" + + Given("A third party application") + val application: ApplicationResponse = createApplication(appName) + val createdApp = result(applicationRepository.fetch(application.id), timeout) + .getOrElse(fail()) + + When("We fetch the application credentials") + val response = Http(s"$serviceUrl/application/${application.id}/credentials").asString + response.code shouldBe OK + + Then("The credentials are returned") + Json.parse(response.body) shouldBe Json.toJson(ApplicationTokensResponse( + EnvironmentTokenResponse(s"$appName-PRODUCTION-key", "PRODUCTION-token", createdApp.tokens.production.clientSecrets), + EnvironmentTokenResponse(s"$appName-SANDBOX-key", "SANDBOX-token", createdApp.tokens.sandbox.clientSecrets))) + } + + scenario("Fetch WSO2 credentials of an application") { + + val appName = "appName" + + Given("A third party application") + createApplication(appName) + + When("We fetch the WSO2 credentials of the application") + val response = Http(s"$serviceUrl/application/wso2-credentials?clientId=$appName-SANDBOX-key").asString + response.code shouldBe OK + val result = Json.parse(response.body).as[Wso2Credentials] + + Then("The credentials are returned") + result shouldBe Wso2Credentials(s"$appName-SANDBOX-key", "SANDBOX-token", "SANDBOX-secret") + } + } + + feature("Privileged Applications") { + + val privilegedApplicationsScenario = "Create Privileged application" + scenario(privilegedApplicationsScenario) { + + val appName = "privileged-app-name" + + Given("The gatekeeper is logged in") + authConnector.willValidateLoggedInUserHasGatekeeperRole() + + And("WSO2 returns successfully") + wso2Store.willAddApplication(wso2ApplicationName) + wso2Store.willGenerateApplicationKey(appName, wso2ApplicationName, Environment.SANDBOX) + wso2Store.willGenerateApplicationKey(appName, wso2ApplicationName, Environment.PRODUCTION) + + And("TOTP returns successfully") + totpConnector.willReturnTOTP(privilegedApplicationsScenario) + + When("We create a privileged application") + val createdResponse = postData("/application", applicationRequest(appName, privilegedAccess)) + createdResponse.code shouldBe CREATED + + Then("The application is returned with the TOTP Ids and the TOTP Secrets") + val totpIds = (Json.parse(createdResponse.body) \ "access" \ "totpIds").as[TotpIds] + val totpSecrets = (Json.parse(createdResponse.body) \ "totp").as[TotpSecrets] + + totpIds match { + case TotpIds("prod-id", "sandbox-id") => totpSecrets shouldBe TotpSecrets("prod-secret", "sandbox-secret") + case TotpIds("sandbox-id", "prod-id") => totpSecrets shouldBe TotpSecrets("sandbox-secret", "prod-secret") + case TotpIds("prod-id", "prod-id") => totpSecrets shouldBe TotpSecrets("prod-secret", "prod-secret") + case _ => throw new IllegalStateException(s"Unexpected result - totpIds: $totpIds, totpSecrets: $totpSecrets") + } + } + } + + feature("Add/Remove collaborators to an application") { + + scenario("Add collaborator for an application") { + + Given("A third party application") + val application = createApplication() + + When("We request to add the developer as a collaborator of the application") + val response = postData(s"/application/${application.id}/collaborator", + """{ + | "adminEmail":"admin@example.com", + | "collaborator": { + | "emailAddress": "test@example.com", + | "role":"ADMINISTRATOR" + | }, + | "isRegistered": true, + | "adminsToEmail": [] + | }""".stripMargin) + response.code shouldBe OK + val result = Json.parse(response.body).as[AddCollaboratorResponse] + + Then("The collaborator is added") + result shouldBe AddCollaboratorResponse(registeredUser = true) + val fetchedApplication = fetchApplication(application.id) + fetchedApplication.collaborators should contain(Collaborator("test@example.com", Role.ADMINISTRATOR)) + } + + scenario("Remove collaborator to an application") { + + Given("A third party application") + val application = createApplication() + + When("We request to remove a collaborator to the application") + val response = Http(s"$serviceUrl/application/${application.id}/collaborator/user@example.com?admin=admin@example.com&adminsToEmail=") + .method("DELETE").asString + response.code shouldBe NO_CONTENT + + Then("The collaborator is removed") + val fetchedApplication = fetchApplication(application.id) + fetchedApplication.collaborators should not contain Collaborator(emailAddress, Role.DEVELOPER) + } + } + + feature("Update an application") { + + scenario("Update an application") { + + Given("A third party application") + val originalOverrides: Set[OverrideFlag] = Set(PersistLogin(), GrantWithoutConsent(Set("scope")), + SuppressIvForAgents(Set("scope")), SuppressIvForOrganisations(Set("scope"))) + val application = createApplication(access = standardAccess.copy(overrides = originalOverrides)) + When("I request to update the application") + val newApplicationName = "My Renamed Application" + val updatedRedirectUris = Seq("http://example.com/redirect2", "http://example.com/redirect3") + val updatedTermsAndConditionsUrl = Some("http://example.com/terms2") + val updatedPrivacyPolicyUrl = Some("http://example.com/privacy2") + val updatedAccess = Standard( + redirectUris = updatedRedirectUris, + termsAndConditionsUrl = updatedTermsAndConditionsUrl, + privacyPolicyUrl = updatedPrivacyPolicyUrl, + overrides = Set.empty) + val updatedResponse = postData(s"/application/${application.id}", applicationRequest(name = newApplicationName, access = updatedAccess)) + updatedResponse.code shouldBe OK + + Then("The application is updated but preserving the original access override flags") + val fetchedApplication = fetchApplication(application.id) + fetchedApplication.name shouldBe newApplicationName + fetchedApplication.redirectUris shouldBe updatedRedirectUris + fetchedApplication.termsAndConditionsUrl shouldBe updatedTermsAndConditionsUrl + fetchedApplication.privacyPolicyUrl shouldBe updatedPrivacyPolicyUrl + val fetchedAccess = fetchedApplication.access.asInstanceOf[Standard] + fetchedAccess.redirectUris shouldBe updatedRedirectUris + fetchedAccess.termsAndConditionsUrl shouldBe updatedTermsAndConditionsUrl + fetchedAccess.privacyPolicyUrl shouldBe updatedPrivacyPolicyUrl + fetchedAccess.overrides shouldBe originalOverrides + } + + scenario("Add a client secret") { + + Given("A third party application") + val application = createApplication() + + When("I request to add a production client secret") + val fetchResponse = postData(s"/application/${application.id}/client-secret", + """{"name":"secret-1", "environment": "PRODUCTION"}""") + fetchResponse.code shouldBe OK + + Then("The client secret is added to the production environment of the application") + val fetchResponseJson = Json.parse(fetchResponse.body).as[ApplicationTokensResponse] + fetchResponseJson.production.clientSecrets should have size 2 + fetchResponseJson.sandbox.clientSecrets should have size 1 + } + + scenario("Delete an application") { + wso2Store.willRemoveApplication(wso2ApplicationName) + wso2Store.willReturnApplicationSubscriptions(wso2ApplicationName, Seq(APIIdentifier(context, version))) + apiSubscriptionFields.willDeleteTheSubscriptionFields() + thirdPartyDelegatedAuthorityConnector.willRevokeApplicationAuthorities() + + Given("The gatekeeper is logged in") + authConnector.willValidateLoggedInUserHasGatekeeperRole() + + And("A third party application") + val application = createApplication() + + When("I request to delete the application") + val deleteResponse = postData(path = s"/application/${application.id}/delete", + data = s"""{"gatekeeperUserId": "$gatekeeperUserId", "requestedByEmailAddress": "$emailAddress"}""") + deleteResponse.code shouldBe NO_CONTENT + + Then("The application is deleted") + val fetchResponse = Http(s"$serviceUrl/application/${application.id}").asString + fetchResponse.code shouldBe NOT_FOUND + } + + scenario("Change rate limit tier for an application") { + + val scenario0 = "withoutSubscriptions" + val scenario1 = "withAllSubscriptions" + + val subscriptionListUrl = "/store/site/blocks/subscription/subscription-list/ajax/subscription-list.jag" + val uriParams = s"action=getSubscriptionByApplication&app=$wso2ApplicationName" + + val withoutSubcriptionsResponse = WSO2SubscriptionResponse(error = false, apis = Seq()) + val withAllSubcriptionsResponse = WSO2SubscriptionResponse(error = false, apis = Seq(WSO2Subscription(s"$context--$version", version))) + + def willReturnApplicationSubscriptions(): Unit = { + + val scenarioName = "change_rate-limit-tier" + + wso2Store.stub.server.stubFor( + post(urlEqualTo(subscriptionListUrl)) + .withRequestBody(equalTo(uriParams)) + .inScenario(scenarioName) + .whenScenarioStateIs(Scenario.STARTED) + .willSetStateTo(scenario1) + .willReturn( + aResponse() + .withStatus(OK) + .withBody(Json.toJson(withoutSubcriptionsResponse).toString) + ) + ) + + wso2Store.stub.server.stubFor( + post(urlEqualTo(subscriptionListUrl)) + .withRequestBody(equalTo(uriParams)) + .inScenario(scenarioName) + .whenScenarioStateIs(scenario0) + .willSetStateTo(scenario1) + .willReturn( + aResponse() + .withStatus(OK) + .withBody(Json.toJson(withoutSubcriptionsResponse).toString) + ) + ) + + wso2Store.stub.server.stubFor( + post(urlEqualTo(subscriptionListUrl)) + .withRequestBody(equalTo(uriParams)) + .inScenario(scenarioName) + .whenScenarioStateIs(scenario1) + .willSetStateTo(scenario0) + .willReturn( + aResponse() + .withStatus(OK) + .withBody(Json.toJson(withAllSubcriptionsResponse).toString) + ) + ) + + } + + Given("The gatekeeper is logged in") + authConnector.willValidateLoggedInUserHasGatekeeperRole() + + And("A third party application with BRONZE rate limit tier exists") + val application = createApplication() + + And("An API is available for the application") + apiDefinition.willReturnApisForApplication(application.id, Seq(anApiDefinition)) + + And("The application is subscribed to the API") + willReturnApplicationSubscriptions() + + When("I change the rate limit tier of the application and all its subscriptions") + wso2Store.willUpdateApplication(wso2ApplicationName, RateLimitTier.SILVER) + wso2Store.willFetchApplication(wso2ApplicationName, RateLimitTier.SILVER) + wso2Store.willAddSubscription(wso2ApplicationName, context, version, RateLimitTier.SILVER) + + Then("The response is successful") + val response = postData(path = s"/application/${application.id}/rate-limit-tier", data = """{ "rateLimitTier" : "SILVER" }""") + response.code shouldBe NO_CONTENT + } + } + + feature("Subscription") { + + scenario("Fetch API Subscriptions") { + + Given("A third party application") + val application = createApplication() + + And("The API is available for the application") + apiDefinition.willReturnApisForApplication(application.id, Seq(anApiDefinition)) + + And("The application is subscribed to an API in WSO2") + wso2Store.willReturnApplicationSubscriptions(wso2ApplicationName, Seq(APIIdentifier(context, version))) + + When("I fetch the API subscriptions of the application") + val response = Http(s"$serviceUrl/application/${application.id}/subscription").asString + + Then("The API subscription is returned") + val result = Json.parse(response.body).as[Seq[APISubscription]] + result shouldBe Seq(APISubscription(apiName, serviceName, context, Seq(VersionSubscription(anApiDefinition.versions.head, subscribed = true)), None)) + } + + scenario("Fetch All API Subscriptions") { + + Given("A third party application") + val application = createApplication("App with subscription") + + And("An API") + apiDefinition.willReturnApisForApplication(application.id, Seq(anApiDefinition)) + + And("The application is not subscribe to the API") + wso2Store.willReturnApplicationSubscriptions(wso2ApplicationName, Seq()) + + And("I subscribe the application to an API") + val subscribeResponse = postData(s"/application/${application.id}/subscription", + s"""{ "context" : "$context", "version" : "$version" }""") + And("The subscription is created") + subscribeResponse.code shouldBe NO_CONTENT + + When("I fetch all API subscriptions") + val response = Http(s"$serviceUrl/application/subscriptions").asString + + Then("The result includes the new subscription") + val result = Json.parse(response.body).as[Seq[SubscriptionData]] + result should have size 1 + + val subscribedApps = result.head.applications + subscribedApps should have size 1 + subscribedApps.head shouldBe application.id + } + + scenario("Subscribe to an api") { + + Given("A third party application") + val application = createApplication() + + And("An API") + apiDefinition.willReturnApisForApplication(application.id, Seq(anApiDefinition)) + + And("The application is not subscribe to the API") + wso2Store.willReturnApplicationSubscriptions(wso2ApplicationName, Seq()) + + When("I request to subscribe the application to the API") + val subscribeResponse = postData(s"/application/${application.id}/subscription", + s"""{ "context" : "$context", "version" : "$version" }""") + + Then("A 204 is returned") + subscribeResponse.code shouldBe NO_CONTENT + } + + scenario("Unsubscribe to an api") { + + Given("A third party application") + val application = createApplication() + + And("The application is subscribed to an API in WSO2") + wso2Store.willReturnApplicationSubscriptions(wso2ApplicationName, Seq(APIIdentifier(context, version))) + + When("I request to unsubscribe the application to an API") + val unsubscribedResponse = Http(s"$serviceUrl/application/${application.id}/subscription?context=$context&version=$version") + .method("DELETE").asString + + Then("A 204 is returned") + unsubscribedResponse.code shouldBe NO_CONTENT + } + } + + feature("Uplift") { + + scenario("Request uplift for an application") { + + Given("A third party application") + val application = createApplication() + + When("I request to uplift an application to production") + val result = postData(s"/application/${application.id}/request-uplift", + s"""{"requestedByEmailAddress":"admin@example.com", "applicationName": "Prod Application Name"}""") + + Then("The application is updated to PENDING_GATEKEEPER_APPROVAL") + result.code shouldBe NO_CONTENT + val fetchedApplication = fetchApplication(application.id) + fetchedApplication.state.name shouldBe State.PENDING_GATEKEEPER_APPROVAL + fetchedApplication.name shouldBe "Prod Application Name" + } + } + + private def fetchApplication(id: UUID): ApplicationResponse = { + val fetchedResponse = Http(s"$serviceUrl/application/${id.toString}").asString + fetchedResponse.code shouldBe OK + Json.parse(fetchedResponse.body).as[ApplicationResponse] + } + + private def createApplication(appName: String = applicationName1, access: Access = standardAccess): ApplicationResponse = { + wso2Store.willAddApplication(wso2ApplicationName) + wso2Store.willGenerateApplicationKey(appName, wso2ApplicationName, Environment.SANDBOX) + wso2Store.willGenerateApplicationKey(appName, wso2ApplicationName, Environment.PRODUCTION) + + val createdResponse = postData("/application", applicationRequest(appName, access)) + createdResponse.code shouldBe CREATED + Json.parse(createdResponse.body).as[ApplicationResponse] + } + + private def postData(path: String, data: String, method: String = "POST"): HttpResponse[String] = { + Http(s"$serviceUrl$path").postData(data).method(method) + .header("Content-Type", "application/json") + .timeout(connTimeoutMs = 5000, readTimeoutMs = 10000) + .asString + } + + private def applicationRequest(name: String, access: Access = standardAccess) = { + s"""{ + |"name" : "$name", + |"environment" : "PRODUCTION", + |"description" : "Some Description", + |"access" : ${Json.toJson(access)}, + |"collaborators": [ + | { + | "emailAddress": "admin@example.com", + | "role": "ADMINISTRATOR" + | }, + | { + | "emailAddress": "$emailAddress", + | "role": "DEVELOPER" + | } + |] + |}""".stripMargin.replaceAll("\n", "") + } + +} \ No newline at end of file diff --git a/test/it/uk/gov/hmrc/component/stubs/ApiDefinitionStub.scala b/test/it/uk/gov/hmrc/component/stubs/ApiDefinitionStub.scala new file mode 100644 index 000000000..967d2f9f3 --- /dev/null +++ b/test/it/uk/gov/hmrc/component/stubs/ApiDefinitionStub.scala @@ -0,0 +1,41 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.component.stubs + +import java.util.UUID + +import com.github.tomakehurst.wiremock.client.WireMock._ +import uk.gov.hmrc.component.{MockHost, Stub} +import play.api.http.Status.OK +import play.api.libs.json.Json +import uk.gov.hmrc.models.JsonFormatters._ +import uk.gov.hmrc.models.APIDefinition + +object ApiDefinitionStub extends Stub { + + override val stub = MockHost(22221) + + def willReturnApisForApplication(applicationId: UUID, apiDefinitions: Seq[APIDefinition]) = { + stub.mock.register(get(urlEqualTo(s"/api-definition?applicationId=$applicationId")) + .willReturn( + aResponse() + .withStatus(OK) + .withBody(Json.toJson(apiDefinitions).toString()) + ) + ) + } +} diff --git a/test/it/uk/gov/hmrc/component/stubs/ApiSubscriptionFieldsStub.scala b/test/it/uk/gov/hmrc/component/stubs/ApiSubscriptionFieldsStub.scala new file mode 100644 index 000000000..f6059c23a --- /dev/null +++ b/test/it/uk/gov/hmrc/component/stubs/ApiSubscriptionFieldsStub.scala @@ -0,0 +1,31 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.component.stubs + +import com.github.tomakehurst.wiremock.client.WireMock._ +import uk.gov.hmrc.component.{MockHost, Stub} +import play.api.http.Status.NO_CONTENT + +object ApiSubscriptionFieldsStub extends Stub { + + override val stub = MockHost(22227) + + def willDeleteTheSubscriptionFields() = { + stub.mock.register(get(urlPathMatching(s"/field/application/*")) + .willReturn(aResponse().withStatus(NO_CONTENT))) + } +} diff --git a/test/it/uk/gov/hmrc/component/stubs/AuthStub.scala b/test/it/uk/gov/hmrc/component/stubs/AuthStub.scala new file mode 100644 index 000000000..351a875d3 --- /dev/null +++ b/test/it/uk/gov/hmrc/component/stubs/AuthStub.scala @@ -0,0 +1,33 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.component.stubs + +import com.github.tomakehurst.wiremock.client.WireMock._ +import uk.gov.hmrc.component.{MockHost, Stub} +import play.api.http.Status.OK + +object AuthStub extends Stub { + + override val stub = MockHost(22225) + + def willValidateLoggedInUserHasGatekeeperRole() = { + stub.mock.register(get(urlPathEqualTo("/auth/authenticate/user/authorise")) + .withQueryParam("scope", equalTo("api")) + .withQueryParam("role", equalTo("gatekeeper")) + .willReturn(aResponse().withStatus(OK))) + } +} diff --git a/test/it/uk/gov/hmrc/component/stubs/TOTPStub.scala b/test/it/uk/gov/hmrc/component/stubs/TOTPStub.scala new file mode 100644 index 000000000..79f091a76 --- /dev/null +++ b/test/it/uk/gov/hmrc/component/stubs/TOTPStub.scala @@ -0,0 +1,77 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.component.stubs + +import com.github.tomakehurst.wiremock.client.WireMock._ +import com.github.tomakehurst.wiremock.stubbing.Scenario +import uk.gov.hmrc.component.{MockHost, Stub} +import play.api.http.Status.CREATED +import play.api.libs.json.Json + +object TOTPStub extends Stub { + + override val stub = MockHost(22226) + + private val productionState = "productionState" + private val sandboxState = "sandboxState" + + private val totpUrl = "/time-based-one-time-password/secret" + + private val productionTotpJson = Json.obj("secret" -> "prod-secret", "id" -> "prod-id").toString() + private val sandboxTotpJson = Json.obj("secret" -> "sandbox-secret", "id" -> "sandbox-id").toString() + + def willReturnTOTP(scenarioName: String) = { + + stub.server.stubFor( + post(urlEqualTo(totpUrl)) + .inScenario(scenarioName) + .whenScenarioStateIs(Scenario.STARTED) + .willSetStateTo(sandboxState) + .willReturn( + aResponse() + .withStatus(CREATED) + .withBody(productionTotpJson) + ) + ) + + stub.server.stubFor( + post(urlEqualTo(totpUrl)) + .inScenario(scenarioName) + .whenScenarioStateIs(productionState) + .willSetStateTo(sandboxState) + .willReturn( + aResponse() + .withStatus(CREATED) + .withBody(productionTotpJson) + ) + ) + + stub.server.stubFor( + post(urlEqualTo(totpUrl)) + .inScenario(scenarioName) + .whenScenarioStateIs(sandboxState) + .willSetStateTo(productionState) + .willReturn( + aResponse() + .withStatus(CREATED) + .withBody(sandboxTotpJson) + ) + ) + + } + +} diff --git a/test/it/uk/gov/hmrc/component/stubs/ThirdPartyDelegatedAuthorityStub.scala b/test/it/uk/gov/hmrc/component/stubs/ThirdPartyDelegatedAuthorityStub.scala new file mode 100644 index 000000000..8ce709f31 --- /dev/null +++ b/test/it/uk/gov/hmrc/component/stubs/ThirdPartyDelegatedAuthorityStub.scala @@ -0,0 +1,30 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.component.stubs + +import com.github.tomakehurst.wiremock.client.WireMock._ +import uk.gov.hmrc.component.{MockHost, Stub} +import play.api.http.Status.NO_CONTENT + +object ThirdPartyDelegatedAuthorityStub extends Stub { + override val stub = MockHost(22228) + + def willRevokeApplicationAuthorities() = { + stub.mock.register(delete(urlPathMatching("/authority/*")) + .willReturn(aResponse().withStatus(NO_CONTENT))) + } +} diff --git a/test/it/uk/gov/hmrc/component/stubs/ThirdPartyDeveloperStub.scala b/test/it/uk/gov/hmrc/component/stubs/ThirdPartyDeveloperStub.scala new file mode 100644 index 000000000..e88fbcdd2 --- /dev/null +++ b/test/it/uk/gov/hmrc/component/stubs/ThirdPartyDeveloperStub.scala @@ -0,0 +1,41 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.component.stubs + +import java.net.URLEncoder + +import com.github.tomakehurst.wiremock.client.WireMock._ +import uk.gov.hmrc.component.{Stub, MockHost} +import play.api.http.Status.OK +import play.api.libs.json.Json +import uk.gov.hmrc.models.UserResponse + +object ThirdPartyDeveloperStub extends Stub { + + override val stub = MockHost(22224) + + def willReturnTheDeveloper(user: UserResponse) = { + val email = URLEncoder.encode(user.email, "UTF-8") + stub.mock.register(get(urlEqualTo(s"/developer?email=$email")) + .willReturn( + aResponse() + .withStatus(OK) + .withBody(Json.toJson(user).toString()) + ) + ) + } +} diff --git a/test/it/uk/gov/hmrc/component/stubs/WSO2StoreStub.scala b/test/it/uk/gov/hmrc/component/stubs/WSO2StoreStub.scala new file mode 100644 index 000000000..609284021 --- /dev/null +++ b/test/it/uk/gov/hmrc/component/stubs/WSO2StoreStub.scala @@ -0,0 +1,158 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.component.stubs + +import com.github.tomakehurst.wiremock.client.WireMock._ +import uk.gov.hmrc.component.{MockHost, Stub} +import play.api.libs.json._ +import play.api.http.ContentTypes.FORM +import play.api.http.HeaderNames.{CONTENT_TYPE, COOKIE, SET_COOKIE} +import play.api.http.Status.OK +import uk.gov.hmrc.models.Environment.Environment +import uk.gov.hmrc.models.RateLimitTier._ +import uk.gov.hmrc.models.{APIIdentifier, Environment} + +object WSO2StoreStub extends Stub { + + override val stub = MockHost(22222) + implicit val format1 = Json.format[WSO2Subscription] + implicit val format2 = Json.format[WSO2SubscriptionResponse] + + def clientId(appName: String, env: Environment) = s"$appName-$env-key" + + def willAddUserSuccessfully() = { + stub.mock.register(post(urlEqualTo("/store/site/blocks/user/sign-up/ajax/user-add.jag")) + .willReturn(aResponse().withStatus(OK) + .withBody( """{"error": false}"""))) + } + + def willLoginAndReturnCookieFor(username: String, password: String, cookie: String) = { + stub.mock.register(post(urlEqualTo("/store/site/blocks/user/login/ajax/login.jag")) + .withHeader(CONTENT_TYPE, equalTo(FORM)) + .withRequestBody(equalTo(s"action=login&username=$username&password=$password")) + .willReturn(aResponse().withStatus(OK) + .withHeader(SET_COOKIE, cookie) + .withBody( """{"error": false}"""))) + } + + def willLogout(cookie: String) = { + stub.mock.register(get(urlEqualTo("/store/site/blocks/user/login/ajax/login.jag?action=logout")) + .withHeader(COOKIE, equalTo(cookie)) + .willReturn(aResponse().withStatus(OK) + .withBody( """{"error": false}"""))) + } + + def willAddApplication(wso2ApplicationName: String) = { + stub.mock.register(post(urlEqualTo("/store/site/blocks/application/application-add/ajax/application-add.jag")) + .withHeader(CONTENT_TYPE, equalTo(FORM)) + .withRequestBody(equalTo(s"action=addApplication&application=$wso2ApplicationName&tier=BRONZE_APPLICATION&description=&callbackUrl=")) + .willReturn(aResponse().withStatus(OK) + .withBody( """{"error": false}"""))) + } + + def willFetchApplication(wso2ApplicationName: String, rateLimitTier: RateLimitTier) = { + val url = s"/store/site/blocks/application/application-list/ajax/application-list.jag" + val uriParams = s"?action=getApplicationByName&applicationName=$wso2ApplicationName" + + stub.mock.register(get(urlEqualTo(url + uriParams)) + .withHeader(CONTENT_TYPE, equalTo(FORM)) + .willReturn(aResponse().withStatus(OK) + .withBody( s"""{ "error" : false, "application" : { "tier" : "${rateLimitTier.toString}_APPLICATION" } }"""))) + } + + def willGenerateApplicationKey(appName: String, wso2ApplicationName: String, environment: Environment.Value) = { + stub.mock.register(post(urlEqualTo("/store/site/blocks/subscription/subscription-add/ajax/subscription-add.jag")) + .withHeader(CONTENT_TYPE, equalTo(FORM)) + .withRequestBody(equalTo(s"action=generateApplicationKey&application=$wso2ApplicationName&keytype=$environment&callbackUrl=&authorizedDomains=ALL&validityTime=-1")) + .willReturn(aResponse().withStatus(OK) + .withBody( s"""{"error":false,"data":{"key":{"consumerSecret":"$environment-secret","consumerKey":"$appName-$environment-key","accessToken":"$environment-token"}}}"""))) + } + + def willAddSubscription(wso2ApplicationName: String, context: String, version: String, rateLimitTier: RateLimitTier) = { + val uriParams = s"action=addAPISubscription&name=$context--$version&version=$version&provider=admin&tier=${rateLimitTier.toString}_SUBSCRIPTION&applicationName=$wso2ApplicationName" + + stub.mock.register(post(urlEqualTo("/store/site/blocks/subscription/subscription-add/ajax/subscription-add.jag")) + .withHeader(CONTENT_TYPE, equalTo(FORM)) + .withRequestBody(equalTo(uriParams)) + .willReturn(aResponse().withStatus(OK) + .withBody( """{"error": false}"""))) + } + + def willRemoveSubscription(wso2ApplicationName: String, context: String, version: String) = { + stub.mock.register(post(urlEqualTo("/store/site/blocks/subscription/subscription-remove/ajax/subscription-remove.jag")) + .withHeader(CONTENT_TYPE, equalTo(FORM)) + .withRequestBody(equalTo(s"action=removeSubscription&name=$context--$version&version=$version&provider=admin&applicationName=$wso2ApplicationName")) + .willReturn(aResponse().withStatus(OK) + .withBody( """{"error": false}"""))) + } + + def willRemoveApplication(wso2ApplicationName: String) = { + stub.mock.register(post(urlEqualTo(s"/store/site/blocks/application/application-remove/ajax/application-remove.jag")) + .withRequestBody(equalTo(s"action=removeApplication&application=$wso2ApplicationName")) + .willReturn(aResponse().withStatus(OK) + .withBody( """{"error": false}"""))) + } + + def willUpdateApplication(wso2ApplicationName: String, newRateLimitTier: RateLimitTier) = { + val uriParams = s"action=updateApplication&applicationOld=$wso2ApplicationName&applicationNew=$wso2ApplicationName" + + s"&callbackUrlNew=&descriptionNew=&tier=${newRateLimitTier.toString}_APPLICATION" + + stub.mock.register(post(urlEqualTo(s"/store/site/blocks/application/application-update/ajax/application-update.jag")) + .withRequestBody(equalTo(uriParams)) + .willReturn(aResponse().withStatus(OK) + .withBody( """{"error": false}"""))) + } + + def willReturnApplicationSubscriptions(wso2ApplicationName: String, apis: Seq[APIIdentifier]) = { + val wso2Subscriptions: Seq[WSO2Subscription] = apis map { api => WSO2Subscription(s"${api.context}--${api.version}", api.version)} + val wso2Response = Json.toJson(WSO2SubscriptionResponse(error = false, wso2Subscriptions)).toString + + stub.mock.register(post(urlEqualTo("/store/site/blocks/subscription/subscription-list/ajax/subscription-list.jag")) + .withRequestBody(equalTo(s"action=getSubscriptionByApplication&app=$wso2ApplicationName")) + .willReturn(aResponse().withStatus(OK) + .withBody(wso2Response))) + } + + def willReturnAllSubscriptions(appAndSubscriptions: (String, Seq[APIIdentifier])*) = { + val wso2Response: JsValue = JsObject(Seq( + "error" -> JsBoolean(false), + "subscriptions" -> JsObject(Seq( + "applications" -> JsArray(appAndSubscriptions.map { case(name, apis) => + JsObject(Seq( + "name" -> JsString(name), + "subscriptions" -> JsArray(apis.map { + api => JsObject(Seq( + "name" -> JsString(s"${api.context}--${api.version}"), + "context" -> JsString(s"/${api.context}/${api.version}"), + "version" -> JsString(api.version) + )) + }) + )) + }) + )) + )) + + stub.mock.register(post(urlEqualTo("/store/site/blocks/subscription/subscription-list/ajax/subscription-list.jag")) + .withRequestBody(equalTo(s"action=getAllSubscriptions")) + .willReturn(aResponse().withStatus(OK) + .withBody(wso2Response.toString()))) + } + + case class WSO2Subscription(apiName: String, apiVersion: String, apiProvider: String = "admin", description: String = null, subscribedTier: String = "Unlimited", status: String = "PUBLISHED") + case class WSO2SubscriptionResponse(error: Boolean, apis: Seq[WSO2Subscription]) + +} diff --git a/test/it/uk/gov/hmrc/repository/ApplicationRepositorySpec.scala b/test/it/uk/gov/hmrc/repository/ApplicationRepositorySpec.scala new file mode 100644 index 000000000..4bd7f04a2 --- /dev/null +++ b/test/it/uk/gov/hmrc/repository/ApplicationRepositorySpec.scala @@ -0,0 +1,495 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.repository + +import java.util.UUID + +import org.joda.time.DateTime +import org.scalatest.concurrent.Eventually +import org.scalatest.mockito.MockitoSugar +import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach} +import play.modules.reactivemongo.ReactiveMongoComponent +import reactivemongo.api.indexes.Index +import reactivemongo.api.indexes.IndexType.Ascending +import uk.gov.hmrc.IndexVerification +import uk.gov.hmrc.models._ +import uk.gov.hmrc.mongo.{MongoConnector, MongoSpecSupport} +import uk.gov.hmrc.play.test.UnitSpec +import uk.gov.hmrc.time.{DateTimeUtils => HmrcTime} +import common.uk.gov.hmrc.testutils.ApplicationStateUtil + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.util.Random.{alphanumeric, nextString} + +class ApplicationRepositorySpec extends UnitSpec with MongoSpecSupport + with BeforeAndAfterEach with BeforeAndAfterAll with ApplicationStateUtil with IndexVerification + with MockitoSugar with Eventually { + + private val reactiveMongoComponent = new ReactiveMongoComponent { override def mongoConnector: MongoConnector = mongoConnectorForTest } + + private val applicationRepository = new ApplicationRepository(reactiveMongoComponent) + private val subscriptionRepository = new SubscriptionRepository(reactiveMongoComponent) + + private def generateClientId = alphanumeric.take(10).mkString + + override def beforeEach() { + Seq(applicationRepository, subscriptionRepository).foreach { db => + await(db.drop) + await(db.ensureIndexes) + } + } + + override protected def afterAll() { + Seq(applicationRepository, subscriptionRepository).foreach { db => + await(db.drop) + } + } + + "save" should { + + "create an application and retrieve it from database" in { + + val application = anApplicationData(UUID.randomUUID()) + + await(applicationRepository.save(application)) + + val retrieved = await(applicationRepository.fetch(application.id)).get + + retrieved shouldBe application + } + + "update an application" in { + + val application = anApplicationData(UUID.randomUUID()) + + await(applicationRepository.save(application)) + + val retrieved = await(applicationRepository.fetch(application.id)).get + + retrieved shouldBe application + + val updated = retrieved.copy(name = "new name") + + await(applicationRepository.save(updated)) + + val newRetrieved = await(applicationRepository.fetch(application.id)).get + + newRetrieved shouldBe updated + + } + + } + + "fetchByClientId" should { + + "retrieve the application for a given client id when it is matched for sandbox client id" in { + + val application1 = anApplicationData(UUID.randomUUID(), "aaa", "111", productionState("requestorEmail@example.com")) + val application2 = anApplicationData(UUID.randomUUID(), "zzz", "999", productionState("requestorEmail@example.com")) + + await(applicationRepository.save(application1)) + await(applicationRepository.save(application2)) + + val retrieved = await(applicationRepository.fetchByClientId(application1.tokens.sandbox.clientId)) + + retrieved shouldBe Some(application1) + + } + + "retrieve the application for a given client id when it is matched for production client id" in { + + val application1 = anApplicationData(UUID.randomUUID(), "aaa", "111", productionState("requestorEmail@example.com")) + val application2 = anApplicationData(UUID.randomUUID(), "zzz", "999", productionState("requestorEmail@example.com")) + + await(applicationRepository.save(application1)) + await(applicationRepository.save(application2)) + + val retrieved = await(applicationRepository.fetchByClientId(application2.tokens.production.clientId)) + + retrieved shouldBe Some(application2) + + } + + } + + "fetchByServerToken" should { + + "retrieve the application when it is matched for sandbox access token" in { + + val application1 = anApplicationData(UUID.randomUUID(), "aaa", "111", productionState("requestorEmail@example.com")) + val application2 = anApplicationData(UUID.randomUUID(), "zzz", "999", productionState("requestorEmail@example.com")) + + await(applicationRepository.save(application1)) + await(applicationRepository.save(application2)) + + val retrieved = await(applicationRepository.fetchByServerToken(application1.tokens.sandbox.accessToken)) + + retrieved shouldBe Some(application1) + } + + "retrieve the application when it is matched for production access token" in { + + val application1 = anApplicationData(UUID.randomUUID(), "aaa", "111", productionState("requestorEmail@example.com")) + val application2 = anApplicationData(UUID.randomUUID(), "zzz", "999", productionState("requestorEmail@example.com")) + + await(applicationRepository.save(application1)) + await(applicationRepository.save(application2)) + + val retrieved = await(applicationRepository.fetchByServerToken(application2.tokens.production.accessToken)) + + retrieved shouldBe Some(application2) + } + } + + "fetchAllForEmailAddress" should { + + "retrieve all the applications for a given collaborator email address" in { + val application1 = anApplicationData(id = UUID.randomUUID(), prodClientId = generateClientId, sandboxClientId = generateClientId) + val application2 = anApplicationData(id = UUID.randomUUID(), prodClientId = generateClientId, sandboxClientId = generateClientId) + + await(applicationRepository.save(application1)) + await(applicationRepository.save(application2)) + + val retrieved = await(applicationRepository.fetchAllForEmailAddress("user@example.com")) + + retrieved shouldBe Seq(application1, application2) + } + + } + + "fetchStandardNonTestingApps" should { + "retrieve all the standard applications not in TESTING state" in { + val application1 = anApplicationData(id = UUID.randomUUID(), prodClientId = generateClientId, sandboxClientId = generateClientId) + val application2 = anApplicationData(id = UUID.randomUUID(), prodClientId = generateClientId, sandboxClientId = generateClientId, + state = pendingRequesterVerificationState("user1")) + val application3 = anApplicationData(id = UUID.randomUUID(), prodClientId = generateClientId, sandboxClientId = generateClientId, + state = productionState("user2")) + val application4 = anApplicationData(id = UUID.randomUUID(), prodClientId = generateClientId, sandboxClientId = generateClientId, + state = pendingRequesterVerificationState("user2")) + + await(applicationRepository.save(application1)) + await(applicationRepository.save(application2)) + await(applicationRepository.save(application3)) + await(applicationRepository.save(application4)) + + val retrieved = await(applicationRepository.fetchStandardNonTestingApps()) + + retrieved.toSet shouldBe Set(application2, application3, application4) + } + + "return empty list when no apps are found" in { + await(applicationRepository.fetchStandardNonTestingApps()) shouldBe Nil + } + + "not return Privileged applications" in { + val application1 = anApplicationData(UUID.randomUUID(), state = productionState("gatekeeper"), access = Privileged()) + await(applicationRepository.save(application1)) + await(applicationRepository.fetchStandardNonTestingApps()) shouldBe Nil + } + + "not return ROPC applications" in { + val application1 = anApplicationData(UUID.randomUUID(), state = productionState("gatekeeper"), access = Ropc()) + await(applicationRepository.save(application1)) + await(applicationRepository.fetchStandardNonTestingApps()) shouldBe Nil + } + + "return empty list when all apps in TESTING state" in { + val application1 = anApplicationData(UUID.randomUUID()) + await(applicationRepository.save(application1)) + await(applicationRepository.fetchStandardNonTestingApps()) shouldBe Nil + } + } + + "fetchNonTestingApplicationByName" should { + + "retrieve the application with the matching name" in { + val applicationName = "appName" + val applicationNormalisedName = "appname" + + val upliftedApplication = anApplicationData(id = UUID.randomUUID(), prodClientId = generateClientId, sandboxClientId = generateClientId) + .copy(normalisedName = applicationNormalisedName, state = pendingGatekeeperApprovalState("email@example.com")) + val testingApplication = anApplicationData(id = UUID.randomUUID(), prodClientId = generateClientId, sandboxClientId = generateClientId) + .copy(normalisedName = applicationNormalisedName, state = testingState()) + + await(applicationRepository.save(upliftedApplication)) + await(applicationRepository.save(testingApplication)) + + val retrieved = await(applicationRepository.fetchNonTestingApplicationByName(applicationName)) + + retrieved shouldBe Some(upliftedApplication) + } + + "retrieve None when no uplifted application exist for that name" in { + val applicationName = "appName" + val applicationNormalizedName = "appname" + + val testingApplication = anApplicationData(UUID.randomUUID()).copy(normalisedName = applicationNormalizedName, state = testingState()) + await(applicationRepository.save(testingApplication)) + + val retrieved = await(applicationRepository.fetchNonTestingApplicationByName(applicationName)) + + retrieved shouldBe None + } + } + + "fetchAllByStatusDetails" should { + + val dayOfExpiry = HmrcTime.now + + val expiryOnTheDayBefore = dayOfExpiry.minusDays(1) + + val expiryOnTheDayAfter = dayOfExpiry.plusDays(1) + + + def verifyApplications(responseApplications: Seq[ApplicationData], expectedState: State.State, expectedNumber: Int): Unit = { + responseApplications.foreach(app => app.state.name shouldBe expectedState) + withClue(s"The expected number of applications with state $expectedState is $expectedNumber") { + responseApplications.size shouldBe expectedNumber + } + } + + + "retrieve the only application with PENDING_REQUESTER_VERIFICATION state that have been updated before the expiryDay" in { + val applications = Seq( + createAppWithStatusUpdatedOn(State.TESTING, expiryOnTheDayBefore), + createAppWithStatusUpdatedOn(State.PENDING_GATEKEEPER_APPROVAL, expiryOnTheDayBefore), + createAppWithStatusUpdatedOn(State.PENDING_REQUESTER_VERIFICATION, expiryOnTheDayBefore), + createAppWithStatusUpdatedOn(State.PRODUCTION, expiryOnTheDayBefore) + ) + applications.foreach(application => await(applicationRepository.save(application))) + + verifyApplications(await(applicationRepository.fetchAllByStatusDetails(State.PENDING_REQUESTER_VERIFICATION, dayOfExpiry)), State.PENDING_REQUESTER_VERIFICATION, 1) + } + + "retrieve the application with PENDING_REQUESTER_VERIFICATION state that have been updated before the dayOfExpiry" in { + val application = createAppWithStatusUpdatedOn(State.PENDING_REQUESTER_VERIFICATION, expiryOnTheDayBefore) + await(applicationRepository.save(application)) + + verifyApplications(await(applicationRepository.fetchAllByStatusDetails(State.PENDING_REQUESTER_VERIFICATION, dayOfExpiry)), State.PENDING_REQUESTER_VERIFICATION, 1) + } + + "retrieve the application with PENDING_REQUESTER_VERIFICATION state that have been updated on the dayOfExpiry" in { + val application = createAppWithStatusUpdatedOn(State.PENDING_REQUESTER_VERIFICATION, dayOfExpiry) + await(applicationRepository.save(application)) + + verifyApplications(await(applicationRepository.fetchAllByStatusDetails(State.PENDING_REQUESTER_VERIFICATION, dayOfExpiry)), State.PENDING_REQUESTER_VERIFICATION, 1) + } + + "retrieve no application with PENDING_REQUESTER_VERIFICATION state that have been updated after the dayOfExpiry" in { + val application = createAppWithStatusUpdatedOn(State.PENDING_REQUESTER_VERIFICATION, expiryOnTheDayAfter) + await(applicationRepository.save(application)) + + verifyApplications(await(applicationRepository.fetchAllByStatusDetails(State.PENDING_REQUESTER_VERIFICATION, dayOfExpiry)), State.PENDING_REQUESTER_VERIFICATION, 0) + } + + } + + "fetchVerifiableBy" should { + + "retrieve the application with verificationCode when in pendingRequesterVerification state" in { + val application = anApplicationData(UUID.randomUUID(), "aaa", "111", state = pendingRequesterVerificationState("requestorEmail@example.com")) + await(applicationRepository.save(application)) + val retrieved = await(applicationRepository.fetchVerifiableUpliftBy(generatedVerificationCode)) + retrieved shouldBe Some(application) + } + + "retrieve the application with verificationCode when in production state" in { + val application = anApplicationData(UUID.randomUUID(), "aaa", "111", state = productionState("requestorEmail@example.com")) + await(applicationRepository.save(application)) + val retrieved = await(applicationRepository.fetchVerifiableUpliftBy(generatedVerificationCode)) + retrieved shouldBe Some(application) + } + + "not retrieve the application with an unknown verificationCode" in { + val application = anApplicationData(UUID.randomUUID(), "aaa", "111", state = pendingRequesterVerificationState("requestorEmail@example.com")) + await(applicationRepository.save(application)) + val retrieved = await(applicationRepository.fetchVerifiableUpliftBy("aDifferentVerificationCode")) + retrieved shouldBe None + } + + } + + "delete" should { + + "delete an application from the database" in { + + val application = anApplicationData(UUID.randomUUID()) + + await(applicationRepository.save(application)) + + val retrieved = await(applicationRepository.fetch(application.id)).get + + retrieved shouldBe application + + await(applicationRepository.delete(application.id)) + + val result = await(applicationRepository.fetch(application.id)) + + result shouldBe None + } + + } + + "fetchAllWithNoSubscriptions" should { + "fetch only those applications with no subscriptions" in { + + val application1 = anApplicationData(id = UUID.randomUUID(), prodClientId = generateClientId, sandboxClientId = generateClientId) + val application2 = anApplicationData(id = UUID.randomUUID(), prodClientId = generateClientId, sandboxClientId = generateClientId) + await(applicationRepository.save(application1)) + await(applicationRepository.save(application2)) + await(subscriptionRepository.insert(aSubscriptionData("context", "version", application1.id))) + + val result = await(applicationRepository.fetchAllWithNoSubscriptions()) + + result shouldBe Seq(application2) + } + } + + "fetchAll" should { + "fetch all existing applications" in { + + val application1 = anApplicationData(id = UUID.randomUUID(), prodClientId = generateClientId, sandboxClientId = generateClientId) + val application2 = anApplicationData(id = UUID.randomUUID(), prodClientId = generateClientId, sandboxClientId = generateClientId) + await(applicationRepository.save(application1)) + await(applicationRepository.save(application2)) + + await(applicationRepository.fetchAll()) shouldBe Seq(application1, application2) + } + } + + "fetchAllForContext" should { + "fetch only those applications when the context matches" in { + + val application1 = anApplicationData(id = UUID.randomUUID(), prodClientId = generateClientId, sandboxClientId = generateClientId) + val application2 = anApplicationData(id = UUID.randomUUID(), prodClientId = generateClientId, sandboxClientId = generateClientId) + val application3 = anApplicationData(id = UUID.randomUUID(), prodClientId = generateClientId, sandboxClientId = generateClientId) + await(applicationRepository.save(application1)) + await(applicationRepository.save(application2)) + await(applicationRepository.save(application3)) + await(subscriptionRepository.insert(aSubscriptionData("context", "version-1", application1.id))) + await(subscriptionRepository.insert(aSubscriptionData("context", "version-2", application2.id))) + await(subscriptionRepository.insert(aSubscriptionData("other", "version-2", application3.id))) + + val result = await(applicationRepository.fetchAllForContext("context")) + + result shouldBe Seq(application1, application2) + } + } + + "fetchAllForApiIdentifier" should { + "fetch only those applications when the context and version matches" in { + val application1 = anApplicationData(id = UUID.randomUUID(), prodClientId = generateClientId, sandboxClientId = generateClientId) + val application2 = anApplicationData(id = UUID.randomUUID(), prodClientId = generateClientId, sandboxClientId = generateClientId) + val application3 = anApplicationData(id = UUID.randomUUID(), prodClientId = generateClientId, sandboxClientId = generateClientId) + await(applicationRepository.save(application1)) + await(applicationRepository.save(application2)) + await(applicationRepository.save(application3)) + await(subscriptionRepository.insert(aSubscriptionData("context", "version-1", application1.id))) + await(subscriptionRepository.insert(aSubscriptionData("context", "version-2", application2.id))) + await(subscriptionRepository.insert(aSubscriptionData("other", "version-2", application2.id, application3.id))) + + val result = await(applicationRepository.fetchAllForApiIdentifier(APIIdentifier("context", "version-2"))) + + result shouldBe Seq(application2) + } + + "fetch multiple applications with the same matching context and versions" in { + val application1 = anApplicationData(id = UUID.randomUUID(), prodClientId = generateClientId, sandboxClientId = generateClientId) + val application2 = anApplicationData(id = UUID.randomUUID(), prodClientId = generateClientId, sandboxClientId = generateClientId) + val application3 = anApplicationData(id = UUID.randomUUID(), prodClientId = generateClientId, sandboxClientId = generateClientId) + await(applicationRepository.save(application1)) + await(applicationRepository.save(application2)) + await(applicationRepository.save(application3)) + await(subscriptionRepository.insert(aSubscriptionData("context", "version-1", application1.id))) + await(subscriptionRepository.insert(aSubscriptionData("context", "version-2", application2.id, application3.id))) + + val result = await(applicationRepository.fetchAllForApiIdentifier(APIIdentifier("context", "version-2"))) + + result shouldBe Seq(application2, application3) + } + + "fetch no applications when the context and version do not match" in { + val application1 = anApplicationData(id = UUID.randomUUID(), prodClientId = generateClientId, sandboxClientId = generateClientId) + val application2 = anApplicationData(id = UUID.randomUUID(), prodClientId = generateClientId, sandboxClientId = generateClientId) + await(applicationRepository.save(application1)) + await(applicationRepository.save(application2)) + await(subscriptionRepository.insert(aSubscriptionData("context", "version-1", application1.id))) + await(subscriptionRepository.insert(aSubscriptionData("context", "version-2", application2.id))) + await(subscriptionRepository.insert(aSubscriptionData("other", "version-2", application1.id, application2.id))) + + val result = await(applicationRepository.fetchAllForApiIdentifier(APIIdentifier("other", "version-1"))) + + result shouldBe Seq() + } + } + + "The 'application' collection" should { + "have all the current indexes" in { + + val indexVersion = Some(1) + val expectedIndexes = Set( + Index(key = Seq("_id" -> Ascending), name = Some("_id_"), unique = false, background = false, version = indexVersion), + Index(key = Seq("state.verificationCode" -> Ascending), name = Some("verificationCodeIndex"), background = true, version = indexVersion), + Index(key = Seq("state.name" -> Ascending, "state.updatedOn" -> Ascending), name = Some("stateName_stateUpdatedOn_Index"), background = true, version = indexVersion), + Index(key = Seq("id" -> Ascending), name = Some("applicationIdIndex"), unique = true, background = true, version = indexVersion), + Index(key = Seq("normalisedName" -> Ascending), name = Some("applicationNormalisedNameIndex"), background = true, version = indexVersion), + Index(key = Seq("tokens.production.clientId" -> Ascending), name = Some("productionTokenClientIdIndex"), unique = true, background = true, version = indexVersion), + Index(key = Seq("tokens.sandbox.clientId" -> Ascending), name = Some("sandboxTokenClientIdIndex"), unique = true, background = true, version = indexVersion), + Index(key = Seq("access.overrides" -> Ascending), name = Some("accessOverridesIndex"), background = true, version = indexVersion), + Index(key = Seq("access.accessType" -> Ascending), name = Some("accessTypeIndex"), background = true, version = indexVersion), + Index(key = Seq("collaborators.emailAddress" -> Ascending), name = Some("collaboratorsEmailAddressIndex"), background = true, version = indexVersion) + ) + + verifyIndexes(applicationRepository, expectedIndexes) + } + } + + def createAppWithStatusUpdatedOn(state: State.State, updatedOn: DateTime) = anApplicationData( + id = UUID.randomUUID(), + prodClientId = generateClientId, + sandboxClientId = generateClientId, + state = ApplicationState(state, Some("requestorEmail@example.com"), Some("aVerificationCode"), updatedOn) + ) + + def aSubscriptionData(context: String, version: String, applicationIds: UUID*) = { + SubscriptionData(APIIdentifier(context, version), Set(applicationIds: _*)) + } + + def anApplicationData(id: UUID, + prodClientId: String = "aaa", + sandboxClientId: String = "111", + state: ApplicationState = testingState(), + access: Access = Standard(Seq.empty, None, None), + user: String = "user@example.com"): ApplicationData = { + + ApplicationData( + id, + s"myApp-$id", + s"myapp-$id", + Set(Collaborator(user, Role.ADMINISTRATOR)), + Some("description"), + "username", + "password", + "myapplication", + ApplicationTokens( + EnvironmentToken(prodClientId, nextString(5), nextString(5)), + EnvironmentToken(sandboxClientId, nextString(5), nextString(5))), + state, + access) + } + +} diff --git a/test/it/uk/gov/hmrc/repository/StateHistoryRepositorySpec.scala b/test/it/uk/gov/hmrc/repository/StateHistoryRepositorySpec.scala new file mode 100644 index 000000000..459440e60 --- /dev/null +++ b/test/it/uk/gov/hmrc/repository/StateHistoryRepositorySpec.scala @@ -0,0 +1,154 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.repository + +import java.util.UUID + +import org.scalatest.concurrent.Eventually +import org.scalatest.mockito.MockitoSugar +import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach} +import play.modules.reactivemongo.ReactiveMongoComponent +import reactivemongo.api.indexes.Index +import reactivemongo.api.indexes.IndexType.Ascending +import uk.gov.hmrc.IndexVerification +import uk.gov.hmrc.models.{Actor, ActorType, State, StateHistory} +import uk.gov.hmrc.mongo.{MongoConnector, MongoSpecSupport} +import uk.gov.hmrc.play.test.UnitSpec +import uk.gov.hmrc.time.DateTimeUtils + +import scala.concurrent.ExecutionContext.Implicits.global + +class StateHistoryRepositorySpec extends UnitSpec with MongoSpecSupport with IndexVerification + with BeforeAndAfterEach with BeforeAndAfterAll with MockitoSugar with Eventually { + + private val reactiveMongoComponent = new ReactiveMongoComponent { override def mongoConnector: MongoConnector = mongoConnectorForTest } + + private val repository = new StateHistoryRepository(reactiveMongoComponent) + + override def beforeEach() { + await(repository.drop) + await(repository.ensureIndexes) + } + + override protected def afterEach() { + await(repository.drop) + } + + val actor = Actor("admin@example.com", ActorType.COLLABORATOR) + + "insert" should { + + "Save a state history" in { + + val stateHistory = StateHistory(UUID.randomUUID(), State.TESTING, actor) + + val result = await(repository.insert(stateHistory)) + + result shouldBe stateHistory + val savedStateHistories = await(repository.findAll()) + savedStateHistories shouldBe Seq(stateHistory) + } + } + + "fetchByApplicationId" should { + + "Return the state history of the application" in { + + val applicationId = UUID.randomUUID() + val stateHistory = StateHistory(applicationId, State.TESTING, actor) + val anotherAppStateHistory = StateHistory(UUID.randomUUID(), State.TESTING, actor) + await(repository.insert(stateHistory)) + await(repository.insert(anotherAppStateHistory)) + + val result = await(repository.fetchByApplicationId(applicationId)) + + result shouldBe Seq(stateHistory) + } + } + + "fetchByState" should { + + "Return the state history of the application" in { + + val applicationId = UUID.randomUUID() + val pendingHistory1 = StateHistory(applicationId, State.PENDING_GATEKEEPER_APPROVAL, actor, changedAt = DateTimeUtils.now.minusDays(5)) + val approvedHistory = StateHistory(applicationId, State.PENDING_REQUESTER_VERIFICATION, actor) + val pendingHistory2 = StateHistory(applicationId, State.PENDING_GATEKEEPER_APPROVAL, actor) + val pendingHistory3 = StateHistory(UUID.randomUUID(), State.PENDING_GATEKEEPER_APPROVAL, actor) + + await(repository.insert(pendingHistory1)) + await(repository.insert(approvedHistory)) + await(repository.insert(pendingHistory2)) + await(repository.insert(pendingHistory3)) + + val result = await(repository.fetchByState(State.PENDING_GATEKEEPER_APPROVAL)) + + result shouldBe Seq(pendingHistory1, pendingHistory2, pendingHistory3) + } + } + + "fetchLatestByApplicationIdAndState" should { + + "Return the state history of the application" in { + + val applicationId = UUID.randomUUID() + val pendingHistory1 = StateHistory(applicationId, State.PENDING_GATEKEEPER_APPROVAL, actor, changedAt = DateTimeUtils.now.minusDays(5)) + val approvedHistory = StateHistory(applicationId, State.PENDING_REQUESTER_VERIFICATION, actor) + val pendingHistory2 = StateHistory(applicationId, State.PENDING_GATEKEEPER_APPROVAL, actor) + val pendingHistory3 = StateHistory(UUID.randomUUID(), State.PENDING_GATEKEEPER_APPROVAL, actor) + + await(repository.insert(pendingHistory1)) + await(repository.insert(approvedHistory)) + await(repository.insert(pendingHistory2)) + await(repository.insert(pendingHistory3)) + + val result = await(repository.fetchLatestByStateForApplication(applicationId, State.PENDING_GATEKEEPER_APPROVAL)) + + result shouldBe Some(pendingHistory2) + } + } + + "deleteByApplicationId" should { + + "Delete the state histories of the application" in { + + val applicationId = UUID.randomUUID() + val stateHistory = StateHistory(applicationId, State.TESTING, actor) + val anotherAppStateHistory = StateHistory(UUID.randomUUID(), State.TESTING, actor) + await(repository.insert(stateHistory)) + await(repository.insert(anotherAppStateHistory)) + + await(repository.deleteByApplicationId(applicationId)) + + await(repository.findAll()) shouldBe Seq(anotherAppStateHistory) + } + } + + "The 'stateHistory' collection" should { + "have all the indexes" in { + val indexVersion = Some(1) + val expectedIndexes = Set( + Index(key = Seq("state" -> Ascending), name = Some("state"), unique = false, background = true, version = indexVersion), + Index(key = Seq("applicationId" -> Ascending), name = Some("applicationId"), unique = false, background = true, version = indexVersion), + Index(key = Seq("applicationId" -> Ascending, "state" -> Ascending), name = Some("applicationId_state"), unique = false, background = true, version = indexVersion), + Index(key = Seq("_id" -> Ascending), name = Some("_id_"), unique = false, background = false, version = indexVersion)) + + verifyIndexes(repository, expectedIndexes) + } + } + +} diff --git a/test/it/uk/gov/hmrc/repository/SubscriptionRepositorySpec.scala b/test/it/uk/gov/hmrc/repository/SubscriptionRepositorySpec.scala new file mode 100644 index 000000000..0d61d2a7e --- /dev/null +++ b/test/it/uk/gov/hmrc/repository/SubscriptionRepositorySpec.scala @@ -0,0 +1,182 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc.repository + +import java.util.UUID + +import org.scalatest.concurrent.Eventually +import org.scalatest.mockito.MockitoSugar +import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach} +import play.modules.reactivemongo.ReactiveMongoComponent +import reactivemongo.api.indexes.Index +import reactivemongo.api.indexes.IndexType.Ascending +import uk.gov.hmrc.IndexVerification +import uk.gov.hmrc.models._ +import uk.gov.hmrc.mongo.{MongoConnector, MongoSpecSupport} +import uk.gov.hmrc.play.test.UnitSpec +import common.uk.gov.hmrc.testutils.ApplicationStateUtil + +import scala.concurrent.ExecutionContext.Implicits.global + +class SubscriptionRepositorySpec extends UnitSpec with MockitoSugar with MongoSpecSupport with IndexVerification + with BeforeAndAfterEach with BeforeAndAfterAll with ApplicationStateUtil with Eventually { + + private val reactiveMongoComponent = new ReactiveMongoComponent { override def mongoConnector: MongoConnector = mongoConnectorForTest } + + private val repository = new SubscriptionRepository(reactiveMongoComponent) + + override def beforeEach() { + await(repository.drop) + await(repository.ensureIndexes) + } + + override protected def afterAll() { + await(repository.drop) + } + + "add" should { + + "create an entry" in { + val applicationId = UUID.randomUUID() + val apiIdentifier = APIIdentifier("some-context", "1.0.0") + + val result = await(repository.add(applicationId, apiIdentifier)) + + result shouldBe HasSucceeded + } + + "create multiple subscriptions" in { + val application1 = UUID.randomUUID() + val application2 = UUID.randomUUID() + val apiIdentifier = APIIdentifier("some-context", "1.0.0") + await(repository.add(application1, apiIdentifier)) + + val result = await(repository.add(application2, apiIdentifier)) + + result shouldBe HasSucceeded + } + } + + "remove" should { + "delete the subscription" in { + val application1 = UUID.randomUUID() + val application2 = UUID.randomUUID() + val apiIdentifier = APIIdentifier("some-context", "1.0.0") + await(repository.add(application1, apiIdentifier)) + await(repository.add(application2, apiIdentifier)) + + val result = await(repository.remove(application1, apiIdentifier)) + + result shouldBe HasSucceeded + await(repository.isSubscribed(application1, apiIdentifier)) shouldBe false + await(repository.isSubscribed(application2, apiIdentifier)) shouldBe true + } + + "not fail when deleting a non-existing subscription" in { + val application1 = UUID.randomUUID() + val application2 = UUID.randomUUID() + val apiIdentifier = APIIdentifier("some-context", "1.0.0") + await(repository.add(application1, apiIdentifier)) + + val result = await(repository.remove(application2, apiIdentifier)) + + result shouldBe HasSucceeded + await(repository.isSubscribed(application1, apiIdentifier)) shouldBe true + } + } + + "find all" should { + "retrieve all versions subscriptions" in { + val application1 = UUID.randomUUID() + val application2 = UUID.randomUUID() + val apiIdentifierA = APIIdentifier("some-context-a", "1.0.0") + val apiIdentifierB = APIIdentifier("some-context-b", "1.0.2") + await(repository.add(application1, apiIdentifierA)) + await(repository.add(application2, apiIdentifierA)) + await(repository.add(application2, apiIdentifierB)) + val retrieved = await(repository.findAll()) + retrieved shouldBe Seq( + subscriptionData("some-context-a", "1.0.0", application1, application2), + subscriptionData("some-context-b", "1.0.2", application2)) + } + } + + "isSubscribed" should { + + "return true when the application is subscribed" in { + val applicationId = UUID.randomUUID() + val apiIdentifier = APIIdentifier("some-context", "1.0.0") + await(repository.add(applicationId, apiIdentifier)) + + val isSubscribed = await(repository.isSubscribed(applicationId, apiIdentifier)) + + isSubscribed shouldBe true + } + + "return false when the application is not subscribed" in { + val applicationId = UUID.randomUUID() + val apiIdentifier = APIIdentifier("some-context", "1.0.0") + + val isSubscribed = await(repository.isSubscribed(applicationId, apiIdentifier)) + + isSubscribed shouldBe false + } + } + + "getSubscriptions" should { + val application1 = UUID.randomUUID() + val application2 = UUID.randomUUID() + val api1 = APIIdentifier("some-context", "1.0") + val api2 = APIIdentifier("some-context", "2.0") + val api3 = APIIdentifier("some-context", "3.0") + + "return the subscribed APIs" in { + await(repository.add(application1, api1)) + await(repository.add(application1, api2)) + await(repository.add(application2, api3)) + + val result = await(repository.getSubscriptions(application1)) + + result shouldBe Seq(api1, api2) + } + + "return empty when the application is not subscribed to any API" in { + val result = await(repository.getSubscriptions(application1)) + + result shouldBe Seq.empty + } + } + + "The 'subscription' collection" should { + "have all the indexes" in { + val indexVersion = Some(1) + val expectedIndexes = Set( + Index(key = Seq("applications" -> Ascending), name = Some("applications"), unique = false, background = true, version = indexVersion), + Index(key = Seq("apiIdentifier.context" -> Ascending), name = Some("context"), unique = false, background = true, version = indexVersion), + Index(key = Seq("apiIdentifier.context" -> Ascending, "apiIdentifier.version" -> Ascending), name = Some("context_version"), unique = true, background = true, version = indexVersion), + Index(key = Seq("_id" -> Ascending), name = Some("_id_"), unique = false, background = false, version = indexVersion)) + + verifyIndexes(repository, expectedIndexes) + } + } + + def subscriptionData(apiContext: String, version: String, applicationIds: UUID*) = { + SubscriptionData( + APIIdentifier(apiContext, version), + Set(applicationIds: _*)) + } +} \ No newline at end of file diff --git a/test/it/uk/gov/hmrc/repository/package.scala b/test/it/uk/gov/hmrc/repository/package.scala new file mode 100644 index 000000000..028df5a22 --- /dev/null +++ b/test/it/uk/gov/hmrc/repository/package.scala @@ -0,0 +1,37 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 uk.gov.hmrc + +import org.scalatest.concurrent.Eventually +import reactivemongo.api.indexes.Index +import uk.gov.hmrc.mongo.ReactiveRepository +import uk.gov.hmrc.play.test.UnitSpec + +import scala.concurrent.ExecutionContext +import scala.concurrent.duration._ + +trait IndexVerification extends UnitSpec with Eventually { + + def verifyIndexes[A, ID](repository: ReactiveRepository[A, ID], indexes: Set[Index])(implicit ec: ExecutionContext) = { + eventually(timeout(10.seconds), interval(1000.milliseconds)) { + val actualIndexes = await(repository.collection.indexesManager.list()).toSet + println(actualIndexes) + actualIndexes shouldBe indexes + } + } +} + diff --git a/test/resources/test-apps.json b/test/resources/test-apps.json new file mode 100644 index 000000000..b912101b2 --- /dev/null +++ b/test/resources/test-apps.json @@ -0,0 +1,38 @@ +[ + { + "app": { + "name": "Application 1", + "description": "First test application", + "collaborators": [ + { + "emailAddress": "app1.admin@example.com", + "role": "ADMINISTRATOR" + } + ] + }, + "subscriptions": [ + { + "context": "hello", + "version": "1.0" + } + ] + }, + { + "app": { + "name": "Application 2", + "description": "Second test application", + "collaborators": [ + { + "emailAddress": "app2.admin@example.com", + "role": "ADMINISTRATOR" + } + ] + }, + "subscriptions": [ + { + "context": "hello", + "version": "1.0" + } + ] + } +] \ No newline at end of file diff --git a/test/unit/connector/APIDefinitionConnectorSpec.scala b/test/unit/connector/APIDefinitionConnectorSpec.scala new file mode 100644 index 000000000..637df46d0 --- /dev/null +++ b/test/unit/connector/APIDefinitionConnectorSpec.scala @@ -0,0 +1,214 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 unit.connector + +import java.util.UUID + +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock +import com.github.tomakehurst.wiremock.client.WireMock._ +import com.github.tomakehurst.wiremock.core.WireMockConfiguration._ +import common.uk.gov.hmrc.common.LogSuppressing +import org.scalatest.BeforeAndAfterEach +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.mockito.MockitoSugar +import play.api.http.Status +import uk.gov.hmrc.connector.APIDefinitionConnector +import uk.gov.hmrc.http.HeaderCarrier +import uk.gov.hmrc.models.JsonFormatters._ +import uk.gov.hmrc.models.{APIDefinition, APIStatus, APIVersion} +import uk.gov.hmrc.play.test.{UnitSpec, WithFakeApplication} +import uk.gov.hmrc.util.http.HttpHeaders._ + +import scala.concurrent.ExecutionContext.Implicits.global + +class APIDefinitionConnectorSpec extends UnitSpec with WithFakeApplication with MockitoSugar with ScalaFutures + with BeforeAndAfterEach with LogSuppressing { + + implicit val hc = HeaderCarrier().withExtraHeaders(X_REQUEST_ID_HEADER -> "requestId") + val stubPort = sys.env.getOrElse("WIREMOCK", "21212").toInt + val stubHost = "localhost" + val wireMockUrl = s"http://$stubHost:$stubPort" + val wireMockServer = new WireMockServer(wireMockConfig().port(stubPort)) + + val apiDefinitionWithStableStatus = APIDefinition("api-service", "api-name", "api-context", + Seq(APIVersion("1.0", APIStatus.STABLE, None)), Some(false)) + + val apiDefinitionWithBetaStatus = APIDefinition("api-service", "api-name", "api-context", + Seq(APIVersion("1.0", APIStatus.BETA, None)), Some(false)) + + val apiDefinitionWithIsTestSupportFlag = APIDefinition("api-service", "api-name", "api-context", + Seq(APIVersion("1.0", APIStatus.STABLE, None)), Some(false), Some(true)) + + val apiDefinitionWithStableStatusJson = + """[{ + | "serviceName": "api-service", + | "name": "api-name", + | "context": "api-context", + | "versions": [ + | { + | "version": "1.0", + | "status": "STABLE" + | } + | ], + | "requiresTrust": false + |}]""".stripMargin + + val apiDefinitionWithPublishedStatusJson = + """[{ + | "serviceName": "api-service", + | "name": "api-name", + | "context": "api-context", + | "versions": [ + | { + | "version": "1.0", + | "status": "PUBLISHED" + | } + | ], + | "requiresTrust": false + |}]""".stripMargin + + val apiDefinitionWithPrototypedStatusJson = + """[{ + | "serviceName": "api-service", + | "name": "api-name", + | "context": "api-context", + | "versions": [ + | { + | "version": "1.0", + | "status": "PROTOTYPED" + | } + | ], + | "requiresTrust": false + |}]""".stripMargin + + val apiDefinitionWithIsTestSupportFlagJson = + """[{ + | "serviceName": "api-service", + | "name": "api-name", + | "context": "api-context", + | "versions": [ + | { + | "version": "1.0", + | "status": "PUBLISHED" + | } + | ], + | "requiresTrust": false, + | "isTestSupport": true + |}]""".stripMargin + + trait Setup { + val underTest = new APIDefinitionConnector { + override lazy val serviceUrl: String = wireMockUrl + override lazy val applicationName: String = "third-party-application" + } + } + + override def beforeEach() { + wireMockServer.start() + WireMock.configureFor(stubHost, stubPort) + } + + override def afterEach() { + wireMockServer.resetMappings() + wireMockServer.stop() + } + + "fetchAPIs" should { + + val applicationId: UUID = UUID.randomUUID() + val applicationName = "third-party-application" + + "return the APIs available for an application" in new Setup { + + stubFor(get(urlPathMatching("/api-definition")) + .withHeader(USER_AGENT, equalTo(applicationName)) + .withHeader(X_REQUEST_ID_HEADER, equalTo("requestId")) + .withQueryParam("applicationId", equalTo(applicationId.toString)).willReturn( + aResponse() + .withStatus(Status.OK) + .withHeader("Content-Type", "application/json") + .withBody(apiDefinitionWithStableStatusJson))) + + val result = await(underTest.fetchAllAPIs(applicationId)) + + result shouldBe Seq(apiDefinitionWithStableStatus) + } + + "map a status of PROTOTYPED in the JSON to BETA in the model" in new Setup { + + stubFor(get(urlPathMatching("/api-definition")) + .withHeader(USER_AGENT, equalTo(applicationName)) + .withHeader(X_REQUEST_ID_HEADER, equalTo("requestId")) + .withQueryParam("applicationId", equalTo(applicationId.toString)).willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(apiDefinitionWithPrototypedStatusJson))) + + val result = await(underTest.fetchAllAPIs(applicationId)) + + result shouldBe Seq(apiDefinitionWithBetaStatus) + } + + "map a status of PUBLISHED in the JSON to STABLE in the model" in new Setup { + + stubFor(get(urlPathMatching("/api-definition")) + .withHeader(USER_AGENT, equalTo(applicationName)) + .withHeader(X_REQUEST_ID_HEADER, equalTo("requestId")) + .withQueryParam("applicationId", equalTo(applicationId.toString)).willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(apiDefinitionWithPublishedStatusJson))) + + val result = await(underTest.fetchAllAPIs(applicationId)) + + result shouldBe Seq(apiDefinitionWithStableStatus) + } + + "map a isTestSupport flag when set in the JSON to correct value in model" in new Setup { + + stubFor(get(urlPathMatching("/api-definition")) + .withHeader(USER_AGENT, equalTo(applicationName)) + .withHeader(X_REQUEST_ID_HEADER, equalTo("requestId")) + .withQueryParam("applicationId", equalTo(applicationId.toString)).willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(apiDefinitionWithIsTestSupportFlagJson))) + + val result = await(underTest.fetchAllAPIs(applicationId)) + + result shouldBe Seq(apiDefinitionWithIsTestSupportFlag) + } + + "fail when api-definition returns a 500" in new Setup { + + stubFor(get(urlPathMatching("/api-definition")) + .withHeader(USER_AGENT, equalTo(applicationName)) + .withHeader(X_REQUEST_ID_HEADER, equalTo("requestId")) + .withQueryParam("applicationId", equalTo(applicationId.toString)) + .willReturn(aResponse().withStatus(500))) + + intercept[RuntimeException] { + await(underTest.fetchAllAPIs(applicationId)) + } + } + + } +} \ No newline at end of file diff --git a/test/unit/connector/ApiSubscriptionFieldsConnectorSpec.scala b/test/unit/connector/ApiSubscriptionFieldsConnectorSpec.scala new file mode 100644 index 000000000..88839309b --- /dev/null +++ b/test/unit/connector/ApiSubscriptionFieldsConnectorSpec.scala @@ -0,0 +1,89 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 unit.connector + +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock +import com.github.tomakehurst.wiremock.client.WireMock._ +import com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig +import common.uk.gov.hmrc.common.LogSuppressing +import org.scalatest.BeforeAndAfterEach +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.mockito.MockitoSugar +import play.api.http.Status._ +import uk.gov.hmrc.connector.ApiSubscriptionFieldsConnector +import uk.gov.hmrc.http.{HeaderCarrier, Upstream5xxResponse} +import uk.gov.hmrc.play.test.{UnitSpec, WithFakeApplication} +import uk.gov.hmrc.util.http.HttpHeaders.X_REQUEST_ID_HEADER + +class ApiSubscriptionFieldsConnectorSpec extends UnitSpec with WithFakeApplication with MockitoSugar with ScalaFutures + with BeforeAndAfterEach with LogSuppressing { + + implicit val hc = HeaderCarrier().withExtraHeaders(X_REQUEST_ID_HEADER -> "requestId") + val stubPort = sys.env.getOrElse("WIREMOCK", "21212").toInt + val stubHost = "localhost" + val wireMockUrl = s"http://$stubHost:$stubPort" + val wireMockServer = new WireMockServer(wireMockConfig().port(stubPort)) + + trait Setup { + val clientId = "client-id" + val underTest = new ApiSubscriptionFieldsConnector { + override lazy val serviceUrl = wireMockUrl + } + } + + override def beforeEach() { + wireMockServer.start() + WireMock.configureFor(stubHost, stubPort) + } + + override def afterEach() { + wireMockServer.resetMappings() + wireMockServer.stop() + } + + "ApiSubscriptionFieldsConnector" should { + "succeed when the remote call returns No Content" in new Setup { + val url = s"/field/application/$clientId" + stubFor(delete(urlEqualTo(url)).willReturn(aResponse().withStatus(NO_CONTENT))) + + await(underTest.deleteSubscriptions(clientId)) + + verify(1, deleteRequestedFor(urlEqualTo(url))) + } + + "succeed when the remote call returns Not Found" in new Setup { + val url = s"/field/application/$clientId" + stubFor(delete(urlEqualTo(url)).willReturn(aResponse().withStatus(NOT_FOUND))) + + await(underTest.deleteSubscriptions(clientId)) + + verify(1, deleteRequestedFor(urlEqualTo(url))) + } + + "fail when the remote call returns Internal Server Error" in new Setup { + val url = s"/field/application/$clientId" + stubFor(delete(urlEqualTo(url)).willReturn(aResponse().withStatus(INTERNAL_SERVER_ERROR))) + + intercept[Upstream5xxResponse] { + await(underTest.deleteSubscriptions(clientId)) + } + + verify(1, deleteRequestedFor(urlEqualTo(url))) + } + } +} diff --git a/test/unit/connector/AuthConnectorSpec.scala b/test/unit/connector/AuthConnectorSpec.scala new file mode 100644 index 000000000..bef7ac2f1 --- /dev/null +++ b/test/unit/connector/AuthConnectorSpec.scala @@ -0,0 +1,85 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 connector + +import com.github.tomakehurst.wiremock.client.WireMock._ +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.{BeforeAndAfterEach, Matchers} +import uk.gov.hmrc.config.WSHttp +import uk.gov.hmrc.connector.AuthConnector +import uk.gov.hmrc.play.test.{UnitSpec, WithFakeApplication} +import uk.gov.hmrc.util.http.HttpHeaders.{USER_AGENT, X_REQUEST_ID_HEADER} +import uk.gov.hmrc.http.HeaderCarrier + + +class AuthConnectorSpec extends UnitSpec with Matchers with ScalaFutures with WiremockSugar with BeforeAndAfterEach with WithFakeApplication { + + trait Setup { + implicit val hc = HeaderCarrier().withExtraHeaders(X_REQUEST_ID_HEADER -> "requestId") + + val connector = new AuthConnector { + override val http = WSHttp + override val authUrl: String = s"$wireMockUrl/auth/authenticate/user" + } + } + + "authorised" should { + + val applicationName = "third-party-application" + + "return true if only scope is sent and the response is 200" in new Setup { + stubFor(get(urlEqualTo("/auth/authenticate/user/authorise?scope=api")) + .withHeader(USER_AGENT, equalTo(applicationName)) + .withHeader(X_REQUEST_ID_HEADER, equalTo("requestId")) + .willReturn(aResponse().withStatus(200))) + + val result = await(connector.authorized("api", None)) + verify(1, getRequestedFor(urlPathEqualTo("/auth/authenticate/user/authorise")) + .withQueryParam("scope", equalTo("api"))) + + result shouldBe true + } + + "return true if scope and role are sent and the response is 200" in new Setup { + stubFor(get(urlEqualTo("/auth/authenticate/user/authorise?scope=api&role=gatekeeper")) + .withHeader(USER_AGENT, equalTo(applicationName)) + .withHeader(X_REQUEST_ID_HEADER, equalTo("requestId")) + .willReturn(aResponse().withStatus(200))) + + val result = await(connector.authorized("api", Some("gatekeeper"))) + verify(1, getRequestedFor(urlPathEqualTo("/auth/authenticate/user/authorise")) + .withQueryParam("scope", equalTo("api")) + .withQueryParam("role", equalTo("gatekeeper"))) + + result shouldBe true + } + + "return false if scope and role are sent but the response is 401" in new Setup { + stubFor(get(urlEqualTo("/auth/authenticate/user/authorise?scope=api&role=gatekeeper")) + .withHeader(USER_AGENT, equalTo(applicationName)) + .withHeader(X_REQUEST_ID_HEADER, equalTo("requestId")) + .willReturn(aResponse().withStatus(401))) + + val result = await(connector.authorized("api", Some("gatekeeper"))) + verify(1, getRequestedFor(urlPathEqualTo("/auth/authenticate/user/authorise")) + .withQueryParam("scope", equalTo("api")) + .withQueryParam("role", equalTo("gatekeeper"))) + + result shouldBe false + } + } +} diff --git a/test/unit/connector/BaseConnectorSpec.scala b/test/unit/connector/BaseConnectorSpec.scala new file mode 100644 index 000000000..b5eb18913 --- /dev/null +++ b/test/unit/connector/BaseConnectorSpec.scala @@ -0,0 +1,41 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 unit.connector + +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock +import com.github.tomakehurst.wiremock.core.WireMockConfiguration._ +import org.scalatest.BeforeAndAfterEach +import uk.gov.hmrc.play.test.UnitSpec + +trait BaseConnectorSpec extends UnitSpec with BeforeAndAfterEach { + val stubPort = sys.env.getOrElse("WIREMOCK", "21212").toInt + val stubHost = "localhost" + val wireMockUrl = s"http://$stubHost:$stubPort" + val wireMockServer = new WireMockServer(wireMockConfig().port(stubPort)) + + + override def beforeEach() { + wireMockServer.start() + WireMock.configureFor(stubHost, stubPort) + } + + override def afterEach() { + wireMockServer.resetMappings() + wireMockServer.stop() + } +} diff --git a/test/unit/connector/EmailConnectorSpec.scala b/test/unit/connector/EmailConnectorSpec.scala new file mode 100644 index 000000000..2783faefa --- /dev/null +++ b/test/unit/connector/EmailConnectorSpec.scala @@ -0,0 +1,234 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 unit.connector + +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock +import com.github.tomakehurst.wiremock.client.WireMock._ +import com.github.tomakehurst.wiremock.core.WireMockConfiguration._ +import org.mockito.Mockito.when +import org.scalatest.BeforeAndAfterEach +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.mockito.MockitoSugar +import play.api.http.Status +import uk.gov.hmrc.config.AppContext +import uk.gov.hmrc.connector.EmailConnector +import uk.gov.hmrc.http.HeaderCarrier +import uk.gov.hmrc.play.test.{UnitSpec, WithFakeApplication} + +class EmailConnectorSpec extends UnitSpec with WithFakeApplication with MockitoSugar with ScalaFutures with BeforeAndAfterEach { + implicit val hc = HeaderCarrier() + val stubPort = sys.env.getOrElse("WIREMOCK", "21212").toInt + val stubHost = "localhost" + val wireMockUrl = s"http://$stubHost:$stubPort" + val wireMockServer = new WireMockServer(wireMockConfig().port(stubPort)) + val emailServicePath = "/hmrc/email" + val hubTestTitle = "Unit Test Hub Title" + + trait Setup { + val appContext = mock[AppContext] + when(appContext.devHubBaseUrl).thenReturn("http://localhost:9685") + when(appContext.devHubTitle).thenReturn(hubTestTitle) + + val connector = new EmailConnector(appContext) { + override lazy val serviceUrl = wireMockUrl + } + stubFor(post(urlEqualTo(emailServicePath)).willReturn(aResponse().withStatus(Status.OK))) + } + + override def beforeEach() { + wireMockServer.start() + WireMock.configureFor(stubHost, stubPort) + } + + override def afterEach() { + wireMockServer.resetMappings() + wireMockServer.stop() + } + + "emailConnector" should { + val collaboratorEmail = "email@example.com" + val adminEmail1 = "admin1@example.com" + val adminEmail2 = "admin2@example.com" + val gatekeeperEmail = "gatekeeper@example.com" + val role = "admin" + val application = "Test Application" + + "send added collaborator confirmation email" in new Setup { + await(connector.sendAddedCollaboratorConfirmation(role, application, Set(collaboratorEmail))) + + verify(1, postRequestedFor(urlEqualTo(emailServicePath)) + .withRequestBody(equalToJson( + s"""{ + | "to": ["$collaboratorEmail"], + | "templateId": "apiAddedDeveloperAsCollaboratorConfirmation", + | "parameters": { + | "role": "$role", + | "applicationName": "$application", + | "developerHubLink": "${connector.devHubBaseUrl}/developer/registration", + | "developerHubTitle": "$hubTestTitle" + | }, + | "force": false, + | "auditData": {} + |}""".stripMargin))) + } + + "send added collaborator notification email" in new Setup { + await(connector.sendAddedCollaboratorNotification(collaboratorEmail, role, application, Set(adminEmail1, adminEmail2))) + + verify(1, postRequestedFor(urlEqualTo(emailServicePath)) + .withRequestBody(equalToJson( + s"""{ + | "to": ["$adminEmail1","$adminEmail2"], + | "templateId": "apiAddedDeveloperAsCollaboratorNotification", + | "parameters": { + | "email": "$collaboratorEmail", + | "role": "$role", + | "applicationName": "$application", + | "developerHubTitle":"$hubTestTitle" + | }, + | "force": false, + | "auditData": {} + |}""".stripMargin)) + ) + } + + "send removed collaborator confirmation email" in new Setup { + await(connector.sendRemovedCollaboratorConfirmation(application, Set(collaboratorEmail))) + + verify(1, postRequestedFor(urlEqualTo(emailServicePath)) + .withRequestBody(equalToJson( + s"""{ + | "to": ["$collaboratorEmail"], + | "templateId": "apiRemovedCollaboratorConfirmation", + | "parameters": { + | "applicationName": "$application", + | "developerHubTitle": "$hubTestTitle" + | }, + | "force": false, + | "auditData": {} + |}""".stripMargin))) + } + + "send removed collaborator notification email" in new Setup { + await(connector.sendRemovedCollaboratorNotification(collaboratorEmail, application, Set(adminEmail1, adminEmail2))) + + verify(1, postRequestedFor(urlEqualTo(emailServicePath)) + .withRequestBody(equalToJson( + s"""{ + | "to": ["$adminEmail1","$adminEmail2"], + | "templateId":"apiRemovedCollaboratorNotification", + | "parameters": { + | "email": "$collaboratorEmail", + | "applicationName": "$application", + | "developerHubTitle": "$hubTestTitle" + | }, + | "force": false, + | "auditData": {} + |}""".stripMargin)) + ) + } + + "send application approved gatekeeper confirmation email" in new Setup { + await(connector.sendApplicationApprovedGatekeeperConfirmation(adminEmail1, application, Set(gatekeeperEmail))) + + verify(1, postRequestedFor(urlEqualTo(emailServicePath)) + .withRequestBody(equalToJson( + s"""{ + | "to": ["$gatekeeperEmail"], + | "templateId": "apiApplicationApprovedGatekeeperConfirmation", + | "parameters": { + | "email": "$adminEmail1", + | "applicationName": "$application" + | }, + | "force": false, + | "auditData": {} + |}""".stripMargin))) + } + + "send application approved admin confirmation email" in new Setup { + val code: String = "generatedCode" + await(connector.sendApplicationApprovedAdminConfirmation(application, code, Set(adminEmail1))) + + verify(1, postRequestedFor(urlEqualTo(emailServicePath)) + .withRequestBody(equalToJson( + s"""{ + | "to": ["$adminEmail1"], + | "templateId": "apiApplicationApprovedAdminConfirmation", + | "parameters": { + | "applicationName":"$application", + | "developerHubLink":"${connector.devHubBaseUrl}/developer/application-verification?code=$code" + | }, + | "force": false, + | "auditData": {} + |}""".stripMargin))) + } + + "send application approved notification email" in new Setup { + await(connector.sendApplicationApprovedNotification(application, Set(adminEmail1, adminEmail2))) + + verify(1, postRequestedFor(urlEqualTo(emailServicePath)) + .withRequestBody(equalToJson( + s"""{ + | "to": ["$adminEmail1","$adminEmail2"], + | "templateId": "apiApplicationApprovedNotification", + | "parameters": { + | "applicationName": "$application" + | }, + | "force": false, + | "auditData": {} + |}""".stripMargin))) + } + + "send application rejected notification email" in new Setup { + val reason = "Test Error" + await(connector.sendApplicationRejectedNotification(application, Set(adminEmail1, adminEmail2), reason)) + + verify(1, postRequestedFor(urlEqualTo(emailServicePath)) + .withRequestBody(equalToJson( + s"""{ + | "to": ["$adminEmail1","$adminEmail2"], + | "templateId": "apiApplicationRejectedNotification", + | "parameters": { + | "applicationName": "$application", + | "guidelinesUrl": "${connector.devHubBaseUrl}/api-documentation/docs/using-the-hub/name-guidelines", + | "supportUrl": "${connector.devHubBaseUrl}/developer/support", + | "reason": "$reason" + | }, + | "force": false, + | "auditData": {} + |}""".stripMargin))) + } + + "send application deleted notification email" in new Setup { + await(connector.sendApplicationDeletedNotification(application, adminEmail1, Set(adminEmail1, adminEmail2))) + + verify(1, postRequestedFor(urlEqualTo(emailServicePath)) + .withRequestBody(equalToJson( + s"""{ + | "to": ["$adminEmail1","$adminEmail2"], + | "templateId": "apiApplicationDeletedNotification", + | "parameters": { + | "applicationName": "$application", + | "requestor": "$adminEmail1" + | }, + | "force": false, + | "auditData": {} + |}""".stripMargin))) + } + } +} \ No newline at end of file diff --git a/test/unit/connector/TOTPConnectorSpec.scala b/test/unit/connector/TOTPConnectorSpec.scala new file mode 100644 index 000000000..b77318fb7 --- /dev/null +++ b/test/unit/connector/TOTPConnectorSpec.scala @@ -0,0 +1,101 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 unit.connector + +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock +import com.github.tomakehurst.wiremock.client.WireMock._ +import com.github.tomakehurst.wiremock.core.WireMockConfiguration._ +import org.scalatest.BeforeAndAfterEach +import play.api.http.ContentTypes.JSON +import play.api.http.HeaderNames.CONTENT_TYPE +import play.api.http.Status.{CREATED, INTERNAL_SERVER_ERROR} +import play.api.libs.json.Json +import uk.gov.hmrc.connector.TOTPConnector +import uk.gov.hmrc.http.HeaderCarrier +import uk.gov.hmrc.models.TOTP +import uk.gov.hmrc.play.test.{UnitSpec, WithFakeApplication} +import uk.gov.hmrc.util.http.HttpHeaders.{USER_AGENT, X_REQUEST_ID_HEADER} +import scala.concurrent.ExecutionContext.Implicits.global + +class TOTPConnectorSpec extends UnitSpec with BeforeAndAfterEach with WithFakeApplication { + + val stubPort = sys.env.getOrElse("WIREMOCK", "21213").toInt + val stubHost = "localhost" + val wireMockUrl = s"http://$stubHost:$stubPort" + val wireMockServer = new WireMockServer(wireMockConfig().port(stubPort)) + + trait Setup { + implicit val hc: HeaderCarrier = HeaderCarrier().withExtraHeaders(X_REQUEST_ID_HEADER -> "requestId") + val underTest = new TOTPConnector { + override val serviceUrl: String = wireMockUrl + override lazy val applicationName: String = "third-party-application" + } + } + + override def beforeEach() { + wireMockServer.start() + WireMock.configureFor(stubHost, stubPort) + } + + override def afterEach() { + wireMockServer.resetMappings() + wireMockServer.stop() + } + + "generateTotp" should { + val totpId = "clientId" + val totpSecret = "aTotp" + + val applicationName = "third-party-application" + + "return the TOTP when it is successfully created" in new Setup { + + stubFor(post(urlPathMatching("/time-based-one-time-password/secret")) + .withHeader(USER_AGENT, equalTo(applicationName)) + .withHeader(X_REQUEST_ID_HEADER, equalTo("requestId")) + .willReturn( + aResponse() + .withStatus(CREATED) + .withHeader(CONTENT_TYPE, JSON) + .withBody( + Json.obj("secret" -> totpSecret, "id" -> totpId).toString() + ) + ) + ) + + val result = await(underTest.generateTotp()) + + result shouldBe TOTP(totpSecret, totpId) + } + + "fail when the TOTP creation fails" in new Setup { + + stubFor(post(urlPathMatching("/time-based-one-time-password/secret")) + .withHeader(USER_AGENT, equalTo(applicationName)) + .withHeader(X_REQUEST_ID_HEADER, equalTo("requestId")) + .willReturn( + aResponse() + .withStatus(INTERNAL_SERVER_ERROR) + .withHeader(CONTENT_TYPE, JSON))) + + intercept[RuntimeException] { + await(underTest.generateTotp()) + } + } + } +} diff --git a/test/unit/connector/WSO2APIStoreConnectorSpec.scala b/test/unit/connector/WSO2APIStoreConnectorSpec.scala new file mode 100644 index 000000000..8924bcc17 --- /dev/null +++ b/test/unit/connector/WSO2APIStoreConnectorSpec.scala @@ -0,0 +1,523 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 unit.connector + +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock +import com.github.tomakehurst.wiremock.client.WireMock._ +import com.github.tomakehurst.wiremock.core.WireMockConfiguration._ +import org.scalatest.BeforeAndAfterEach +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.mockito.MockitoSugar +import play.api.http.Status +import uk.gov.hmrc.connector.WSO2APIStoreConnector +import uk.gov.hmrc.http.HeaderCarrier +import uk.gov.hmrc.models._ +import uk.gov.hmrc.play.test.{UnitSpec, WithFakeApplication} +import uk.gov.hmrc.util.http.HttpHeaders._ + +import scala.concurrent.Await +import scala.concurrent.duration._ + +class WSO2APIStoreConnectorSpec extends UnitSpec with WithFakeApplication with MockitoSugar + with ScalaFutures with BeforeAndAfterEach { + + implicit val hc: HeaderCarrier = HeaderCarrier().withExtraHeaders(X_REQUEST_ID_HEADER -> "requestId") + val stubPort = sys.env.getOrElse("WIREMOCK", "22222").toInt + val stubHost = "localhost" + val wireMockUrl = s"http://$stubHost:$stubPort" + val wireMockServer = new WireMockServer(wireMockConfig().port(stubPort)) + + trait Setup { + val serviceName = "third-party-application" + val underTest = new WSO2APIStoreConnector { + override val adminUsername = "admin" + override val serviceUrl = s"$wireMockUrl/store/site/blocks" + override lazy val applicationName: String = "third-party-application" + } + + } + + override def beforeEach() { + wireMockServer.start() + WireMock.configureFor(stubHost, stubPort) + } + + override def afterEach() { + wireMockServer.stop() + } + + "login" should { + + "log the user into WSO2 API Store and return the cookies" in new Setup { + + stubFor(post(urlEqualTo("/store/site/blocks/user/login/ajax/login.jag")) + .withHeader(CONTENT_TYPE, equalTo("application/x-www-form-urlencoded")) + .withHeader(USER_AGENT, equalTo(serviceName)) + .withHeader(X_REQUEST_ID_HEADER, equalTo("requestId")) + .withRequestBody(equalTo("action=login&username=admin&password=admin")) + .willReturn( + aResponse() + .withStatus(Status.OK) + .withHeader(CONTENT_TYPE, "application/json") + .withBody( """{"error":false}""") + .withHeader("Set-Cookie", "JSESSIONID=12345") + .withHeader("Set-Cookie", "api-store=loadbalancercookie"))) + + val result = Await.result(underTest.login("admin", "admin"), 1.second) + + result shouldBe "JSESSIONID=12345;api-store=loadbalancercookie" + + } + } + + "getSubscriptions" should { + + "get API subscriptions for a given application" in new Setup { + + val cookie = "login-cookie-123" + val applicationName = "myapp" + + stubFor(post(urlEqualTo("/store/site/blocks/subscription/subscription-list/ajax/subscription-list.jag")) + .withHeader(CONTENT_TYPE, equalTo("application/x-www-form-urlencoded")) + .withHeader(COOKIE, equalTo(cookie)) + .withHeader(USER_AGENT, equalTo(serviceName)) + .withHeader(X_REQUEST_ID_HEADER, equalTo("requestId")) + .withRequestBody(equalTo(s"action=getSubscriptionByApplication&app=$applicationName")) + .willReturn( + aResponse() + .withStatus(Status.OK) + .withHeader(CONTENT_TYPE, "application/json") + .withBody( + """{ + | "error":false, + | "apis":[ + | {"apiName":"some--context--1.0","apiVersion":"1.0"}, + | {"apiName":"some--context--1.0","apiVersion":"1.1"} + | ] + |}""" + .stripMargin))) + + val result = Await.result(underTest.getSubscriptions(cookie, applicationName), 1.second) + + result.seq.head.name shouldBe "some--context--1.0" + result.seq.head.version shouldBe "1.0" + result.seq(1).name shouldBe "some--context--1.0" + result.seq(1).version shouldBe "1.1" + + } + } + + "getAllSubscriptions" should { + + "get all API subscriptions" in new Setup { + + val cookie = "login-cookie-123" + + stubFor(post(urlEqualTo("/store/site/blocks/subscription/subscription-list/ajax/subscription-list.jag")) + .withHeader(CONTENT_TYPE, equalTo("application/x-www-form-urlencoded")) + .withHeader(COOKIE, equalTo(cookie)) + .withHeader(USER_AGENT, equalTo(serviceName)) + .withHeader(X_REQUEST_ID_HEADER, equalTo("requestId")) + .withRequestBody(equalTo(s"action=getAllSubscriptions")) + .willReturn( + aResponse() + .withStatus(Status.OK) + .withHeader(CONTENT_TYPE, "application/json") + .withBody( + """{ + | "error": false, + | "subscriptions": { + | "applications": [ + | { + | "id": 1, + | "name": "DefaultApplication", + | "callbackUrl": null, + | "prodKey": null, + | "prodKeyScope": null, + | "prodKeyScopeValue": null, + | "prodConsumerKey": null, + | "prodConsumerSecret": null, + | "prodRegenarateOption": true, + | "prodAuthorizedDomains": null, + | "prodValidityTime": 3600, + | "prodJsonString": null, + | "sandboxKey": null, + | "sandKeyScope": null, + | "sandKeyScopeValue": null, + | "sandboxConsumerKey": null, + | "sandboxConsumerSecret": null, + | "sandRegenarateOption": true, + | "sandboxAuthorizedDomains": null, + | "sandboxJsonString": null, + | "sandValidityTime": 3600, + | "subscriptions": [ + | { + | "name": "pizzashack--1.0.0", + | "provider": "admin", + | "version": "1.0.0", + | "status": "PUBLISHED", + | "tier": "Unlimited", + | "subStatus": "UNBLOCKED", + | "thumburl": "/registry/resource/_system/governance/apimgt/applicationdata/icons/admin/PizzaShackAPI/1.0.0/icon", + | "context": "/pizzashack/1.0.0", + | "businessOwner": "Jane Roe", + | "prodKey": null, + | "prodConsumerKey": null, + | "prodConsumerSecret": null, + | "prodAuthorizedDomains": null, + | "prodValidityTime": 3600000, + | "sandboxKey": null, + | "sandboxConsumerKey": null, + | "sandboxConsumerSecret": null, + | "sandAuthorizedDomains": null, + | "sandValidityTime": 3600000, + | "hasMultipleEndpoints": "false" + | }, + | { + | "name": "another-sample--v1.0", + | "provider": "admin", + | "version": "v1.0", + | "status": "PUBLISHED", + | "tier": "Unlimited", + | "subStatus": "UNBLOCKED", + | "thumburl": null, + | "context": "/another-sample/v1.0", + | "businessOwner": null, + | "prodKey": null, + | "prodConsumerKey": null, + | "prodConsumerSecret": null, + | "prodAuthorizedDomains": null, + | "prodValidityTime": 3600000, + | "sandboxKey": null, + | "sandboxConsumerKey": null, + | "sandboxConsumerSecret": null, + | "sandAuthorizedDomains": null, + | "sandValidityTime": 3600000, + | "hasMultipleEndpoints": "false" + | } + | ], + | "scopes": [] + | }, + | { + | "id": 2, + | "name": "AnotherApplication", + | "callbackUrl": null, + | "prodKey": null, + | "prodKeyScope": null, + | "prodKeyScopeValue": null, + | "prodConsumerKey": null, + | "prodConsumerSecret": null, + | "prodRegenarateOption": true, + | "prodAuthorizedDomains": null, + | "prodValidityTime": 3600, + | "prodJsonString": null, + | "sandboxKey": null, + | "sandKeyScope": null, + | "sandKeyScopeValue": null, + | "sandboxConsumerKey": null, + | "sandboxConsumerSecret": null, + | "sandRegenarateOption": true, + | "sandboxAuthorizedDomains": null, + | "sandboxJsonString": null, + | "sandValidityTime": 3600, + | "subscriptions": [ + | { + | "name": "another-sample--v1.0", + | "provider": "admin", + | "version": "v1.0", + | "status": "PUBLISHED", + | "tier": "Unlimited", + | "subStatus": "UNBLOCKED", + | "thumburl": null, + | "context": "/another-sample/v1.0", + | "businessOwner": null, + | "prodKey": null, + | "prodConsumerKey": null, + | "prodConsumerSecret": null, + | "prodAuthorizedDomains": null, + | "prodValidityTime": 3600000, + | "sandboxKey": null, + | "sandboxConsumerKey": null, + | "sandboxConsumerSecret": null, + | "sandAuthorizedDomains": null, + | "sandValidityTime": 3600000, + | "hasMultipleEndpoints": "false" + | } + | ], + | "scopes": [] + | } + | ], + | "totalLength": 1 + | } + |} + |""".stripMargin))) + + val result = Await.result(underTest.getAllSubscriptions(cookie), 1.second) + + result shouldBe Map( + "DefaultApplication" -> Seq(WSO2API("pizzashack--1.0.0", "1.0.0"), WSO2API("another-sample--v1.0", "v1.0")), + "AnotherApplication" -> Seq(WSO2API("another-sample--v1.0", "v1.0"))) + } + } + + "logout" should { + + "logout of WSO2 API Store for the given cookie" in new Setup { + + val cookie = "login-cookie-123" + + stubFor(get(urlEqualTo("/store/site/blocks/user/login/ajax/login.jag?action=logout")) + .withHeader(CONTENT_TYPE, equalTo("application/x-www-form-urlencoded")) + .withHeader(COOKIE, equalTo(cookie)) + .withHeader(USER_AGENT, equalTo(serviceName)) + .withHeader(X_REQUEST_ID_HEADER, equalTo("requestId")) + .willReturn( + aResponse() + .withStatus(Status.OK) + .withHeader(CONTENT_TYPE, "application/json") + .withBody( s"""{"error":false}"""))) + + val result = Await.result(underTest.logout(cookie), 1.second) + + result shouldBe HasSucceeded + + } + + } + + "addSubscription" should { + + "add an API subscription from WSO2 for the given application" in new Setup { + val cookie = "login-cookie-123" + val applicationName = "myapp" + val api = WSO2API("my--api--1.0", "1.0") + val adminUsername = "admin" + + stubFor(post(urlEqualTo("/store/site/blocks/subscription/subscription-add/ajax/subscription-add.jag")) + .withHeader(CONTENT_TYPE, equalTo("application/x-www-form-urlencoded")) + .withHeader(COOKIE, equalTo(cookie)) + .withHeader(USER_AGENT, equalTo(serviceName)) + .withHeader(X_REQUEST_ID_HEADER, equalTo("requestId")) + .withRequestBody(equalTo( + s"action=addAPISubscription&name=${api.name}" + + s"&version=${api.version}" + + s"&provider=$adminUsername" + + s"&tier=BRONZE_SUBSCRIPTION" + + s"&applicationName=$applicationName")) + .willReturn( + aResponse() + .withStatus(Status.OK) + .withHeader(CONTENT_TYPE, "application/json") + .withBody( s"""{"error":false}"""))) + + val result = Await.result(underTest.addSubscription(cookie, applicationName, api, None, 0), 1.second) + + result shouldBe HasSucceeded + } + + } + + + "removeSubscription" should { + + "remove an API subscription from WSO2 for the given application" in new Setup { + + val cookie = "login-cookie-123" + val applicationName = "myapp" + val api = WSO2API("my--api--1.0", "1.0") + val adminUsername = "admin" + + stubFor(post(urlEqualTo("/store/site/blocks/subscription/subscription-remove/ajax/subscription-remove.jag")) + .withHeader(CONTENT_TYPE, equalTo("application/x-www-form-urlencoded")) + .withHeader(COOKIE, equalTo(cookie)) + .withHeader(USER_AGENT, equalTo(serviceName)) + .withHeader(X_REQUEST_ID_HEADER, equalTo("requestId")) + .withRequestBody(equalTo(s"action=removeSubscription&name=${api.name}&version=${api.version}&provider=$adminUsername&applicationName=$applicationName")) + .willReturn( + aResponse() + .withStatus(Status.OK) + .withHeader(CONTENT_TYPE, "application/json") + .withBody( s"""{"error":false}"""))) + + val result = Await.result(underTest.removeSubscription(cookie, applicationName, api, 0), 1.second) + result shouldBe HasSucceeded + } + + } + + "generateApplicationKey" should { + + "generate an application key in WSO2 for a given application name and key type" in new Setup { + + val cookie = "login-cookie-123" + val applicationName = "myapp" + val environment = Environment.PRODUCTION + + stubFor(post(urlEqualTo("/store/site/blocks/subscription/subscription-add/ajax/subscription-add.jag")) + .withHeader(CONTENT_TYPE, equalTo("application/x-www-form-urlencoded")) + .withHeader(COOKIE, equalTo(cookie)) + .withHeader(USER_AGENT, equalTo(serviceName)) + .withHeader(X_REQUEST_ID_HEADER, equalTo("requestId")) + .withRequestBody(equalTo( + s"action=generateApplicationKey&application=$applicationName&keytype=$environment&callbackUrl=&authorizedDomains=ALL&validityTime=-1")) + .willReturn(aResponse().withStatus(Status.OK) + .withHeader(CONTENT_TYPE, "application/json") + .withBody( s"""{"error":false,"data":{"key":{"consumerSecret":"secret","consumerKey":"key","accessToken":"token"}}}"""))) + + val result = Await.result(underTest.generateApplicationKey(cookie, applicationName, environment), 1.second) + + result shouldBe (_: EnvironmentToken) + + } + + } + + "deleteApplication" should { + + "delete an application in WSO2 for the given application name" in new Setup { + + val cookie = "login-cookie-123" + val applicationName = "myapp" + + stubFor(post(urlEqualTo("/store/site/blocks/application/application-remove/ajax/application-remove.jag")) + .withHeader(CONTENT_TYPE, equalTo("application/x-www-form-urlencoded")) + .withHeader(COOKIE, equalTo(cookie)) + .withHeader(USER_AGENT, equalTo(serviceName)) + .withHeader(X_REQUEST_ID_HEADER, equalTo("requestId")) + .withRequestBody(equalTo(s"action=removeApplication&application=$applicationName")) + .willReturn( + aResponse() + .withStatus(Status.OK) + .withHeader(CONTENT_TYPE, "application/json") + .withBody( s"""{"error":false}"""))) + + val result = Await.result(underTest.deleteApplication(cookie, applicationName), 1.second) + + result shouldBe HasSucceeded + + } + + } + + "createApplication" should { + + "create an application in WSO2 for the given application name" in new Setup { + + val cookie = "login-cookie-123" + val applicationName = "myapp" + + stubFor(post(urlEqualTo("/store/site/blocks/application/application-add/ajax/application-add.jag")) + .withHeader(CONTENT_TYPE, equalTo("application/x-www-form-urlencoded")) + .withHeader(COOKIE, equalTo(cookie)) + .withHeader(USER_AGENT, equalTo(serviceName)) + .withHeader(X_REQUEST_ID_HEADER, equalTo("requestId")) + .withRequestBody(equalTo(s"action=addApplication&application=$applicationName&tier=BRONZE_APPLICATION&description=&callbackUrl=")) + .willReturn( + aResponse() + .withStatus(Status.OK) + .withHeader(CONTENT_TYPE, "application/json") + .withBody( s"""{"error":false}"""))) + + val result = Await.result(underTest.createApplication(cookie, applicationName), 1.second) + + result shouldBe HasSucceeded + + } + + } + + "updateApplication" should { + + val cookie = "login-cookie-123" + val wso2ApplicationName = "myapp" + + def stubForApplicationUpdate(userAgent: String, responseCode: Int, isError: Option[Boolean] = None): Unit = { + + val body = isError match { + case Some(b) => s"""{"error":${b.toString}}""" + case _ => s"""{}""" + } + + stubFor(post(urlEqualTo("/store/site/blocks/application/application-update/ajax/application-update.jag")) + .withHeader(USER_AGENT, equalTo(userAgent)) + .withHeader(X_REQUEST_ID_HEADER, equalTo("requestId")) + .withRequestBody(equalTo( + s"action=updateApplication" + + s"&applicationOld=$wso2ApplicationName" + + s"&applicationNew=$wso2ApplicationName" + + s"&callbackUrlNew=" + + s"&descriptionNew=" + + s"&tier=SILVER_APPLICATION")) + .willReturn( + aResponse() + .withStatus(responseCode) + .withHeader(CONTENT_TYPE, "application/json") + .withBody(body))) + } + + "update rate limiting tier is wso2" in new Setup { + stubForApplicationUpdate(serviceName, Status.OK, isError = Some(false)) + + await(underTest updateApplication(cookie, wso2ApplicationName, RateLimitTier.SILVER)) shouldBe HasSucceeded + } + + "thrown an exception if the response contains an error" in new Setup { + stubForApplicationUpdate(serviceName, Status.OK, isError = Some(true)) + + intercept[RuntimeException] { + await(underTest updateApplication(cookie, wso2ApplicationName, RateLimitTier.SILVER)) + } + } + + "thrown an exception if the response code is not 200 OK" in new Setup { + stubForApplicationUpdate(serviceName, Status.INTERNAL_SERVER_ERROR) + + intercept[RuntimeException] { + await(underTest updateApplication(cookie, wso2ApplicationName, RateLimitTier.SILVER)) + } + } + } + + "createUser" should { + + "create a user in WSO2 for the given username and password" in new Setup { + + val username = "myuser" + val password = "mypassword" + val applicationName = "myapp" + + stubFor(post(urlEqualTo("/store/site/blocks/user/sign-up/ajax/user-add.jag")) + .withHeader(CONTENT_TYPE, equalTo("application/x-www-form-urlencoded")) + .withHeader(USER_AGENT, equalTo(serviceName)) + .withHeader(X_REQUEST_ID_HEADER, equalTo("requestId")) + .withRequestBody(equalTo(s"action=addUser&username=$username&password=$password&allFieldsValues=firstname|lastname|email")) + .willReturn( + aResponse() + .withStatus(Status.OK) + .withHeader(CONTENT_TYPE, "application/json") + .withBody( s"""{"error":false}"""))) + + val result = Await.result(underTest.createUser(username, password), 1.second) + + result shouldBe HasSucceeded + + } + + } + +} \ No newline at end of file diff --git a/test/unit/connector/WiremockSugar.scala b/test/unit/connector/WiremockSugar.scala new file mode 100644 index 000000000..5fee0718a --- /dev/null +++ b/test/unit/connector/WiremockSugar.scala @@ -0,0 +1,41 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 connector + +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock +import com.github.tomakehurst.wiremock.core.WireMockConfiguration._ +import org.scalatest.{BeforeAndAfterEach, Suite} + +trait WiremockSugar extends BeforeAndAfterEach { + this: Suite => + val stubPort = sys.env.getOrElse("WIREMOCK", "22222").toInt + val stubHost = "localhost" + val wireMockUrl = s"http://$stubHost:$stubPort" + val wireMockServer = new WireMockServer(wireMockConfig().port(stubPort)) + + override def beforeEach() = { + wireMockServer.start() + WireMock.configureFor(stubHost, stubPort) + } + + override def afterEach() { + wireMockServer.stop() + wireMockServer.resetMappings() + } + +} diff --git a/test/unit/controllers/AccessControllerSpec.scala b/test/unit/controllers/AccessControllerSpec.scala new file mode 100644 index 000000000..7adeeb118 --- /dev/null +++ b/test/unit/controllers/AccessControllerSpec.scala @@ -0,0 +1,217 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 controllers + +import java.util.UUID + +import org.mockito.Matchers._ +import org.mockito.Mockito.when +import org.scalatest.mockito.MockitoSugar +import play.api.http.Status._ +import play.api.libs.json.Json +import play.api.mvc.Result +import play.api.test.FakeRequest +import uk.gov.hmrc.connector.AuthConnector +import uk.gov.hmrc.controllers.{OverridesRequest, _} +import uk.gov.hmrc.http.HeaderCarrier +import uk.gov.hmrc.models.JsonFormatters._ +import uk.gov.hmrc.models._ +import uk.gov.hmrc.play.test.{UnitSpec, WithFakeApplication} +import uk.gov.hmrc.services.{AccessService, ApplicationService} +import uk.gov.hmrc.time.DateTimeUtils + +import scala.concurrent.Future +import scala.concurrent.Future.{failed, successful} + +class AccessControllerSpec extends UnitSpec with MockitoSugar with WithFakeApplication { + + implicit lazy val materializer = fakeApplication.materializer + private val overrides = Set[OverrideFlag](PersistLogin(), GrantWithoutConsent(Set("scope1", "scope2"))) + private val scopes = Set("scope") + private val scopeRequest = ScopeRequest(scopes) + private val overridesRequest = OverridesRequest(overrides) + private val applicationId = UUID.randomUUID() + + private val mockApplicationService = mock[ApplicationService] + private val mockAuthConnector = mock[AuthConnector] + private val mockAccessService = mock[AccessService] + + implicit val fakeRequest = FakeRequest() + implicit val headerCarrier = HeaderCarrier() + + "Access controller read scopes function" should { + + def mockAccessServiceReadScopesToReturn(eventualScopeResponse: Future[ScopeResponse]) = + when(mockAccessService.readScopes(any[UUID])).thenReturn(eventualScopeResponse) + + "return http ok status when service read scopes succeeds" in new PrivilegedAndRopcFixture { + testWithPrivilegedAndRopc({ + mockAccessServiceReadScopesToReturn(successful(ScopeResponse(scopes))) + status(invokeAccessControllerReadScopesWith(applicationId)) shouldBe OK + }) + } + + "return resource as response body when service read scopes succeeds" in new PrivilegedAndRopcFixture { + testWithPrivilegedAndRopc({ + mockAccessServiceReadScopesToReturn(successful(ScopeResponse(scopes))) + jsonBodyOf(invokeAccessControllerReadScopesWith(applicationId)) shouldBe Json.toJson(ScopeResponse(scopes)) + }) + } + + "return http internal server error status when service read scopes fails with exception" in new PrivilegedAndRopcFixture { + testWithPrivilegedAndRopc({ + mockAccessServiceReadScopesToReturn(failed(new RuntimeException("testing testing 123"))) + status(invokeAccessControllerReadScopesWith(applicationId)) shouldBe INTERNAL_SERVER_ERROR + }) + } + + } + + "Access controller update scopes function" should { + + def mockAccessServiceUpdateScopesToReturn(eventualScopeResponse: Future[ScopeResponse]) = + when(mockAccessService.updateScopes(any[UUID], any[ScopeRequest])(any[HeaderCarrier])).thenReturn(eventualScopeResponse) + + "return http ok status when service update scopes succeeds" in new PrivilegedAndRopcFixture { + testWithPrivilegedAndRopc({ + mockAccessServiceUpdateScopesToReturn(successful(ScopeResponse(scopes))) + status(invokeAccessControllerUpdateScopesWith(applicationId, scopeRequest)) shouldBe OK + }) + } + + "return resource as response body when service update scopes succeeds" in new PrivilegedAndRopcFixture { + testWithPrivilegedAndRopc({ + mockAccessServiceUpdateScopesToReturn(successful(ScopeResponse(scopes))) + jsonBodyOf(invokeAccessControllerUpdateScopesWith(applicationId, scopeRequest)) shouldBe Json.toJson(ScopeResponse(scopes)) + }) + } + + "return http internal server error status when service update scopes fails" in new PrivilegedAndRopcFixture { + testWithPrivilegedAndRopc({ + mockAccessServiceUpdateScopesToReturn(failed(new RuntimeException("testing testing 123"))) + status(invokeAccessControllerUpdateScopesWith(applicationId, scopeRequest)) shouldBe INTERNAL_SERVER_ERROR + }) + } + } + + "Access controller overrides crud functions" should { + + "return http unauthorized status when application id refers to a non-standard application" in new PrivilegedAndRopcFixture { + status(invokeAccessControllerReadOverridesWith(applicationId)) shouldBe UNAUTHORIZED + status(invokeAccessControllerUpdateOverridesWith(applicationId, overridesRequest)) shouldBe UNAUTHORIZED + } + + } + + "Access controller read overrides function" should { + + def mockAccessServiceReadOverridesToReturn(eventualOverridesResponse: Future[OverridesResponse]) = + when(mockAccessService.readOverrides(any[UUID])).thenReturn(eventualOverridesResponse) + + "return http ok status when service read overrides succeeds" in new StandardFixture { + mockAccessServiceReadOverridesToReturn(successful(OverridesResponse(overrides))) + val result = invokeAccessControllerReadOverridesWith(applicationId) + status(result) shouldBe OK + } + + "return resource as response body when service read overrides succeeds" in new StandardFixture { + mockAccessServiceReadOverridesToReturn(successful(OverridesResponse(overrides))) + val result = invokeAccessControllerReadOverridesWith(applicationId) + jsonBodyOf(result) shouldBe Json.toJson(OverridesResponse(overrides)) + } + + "return http internal server error status when service read overrides fails with exception" in new StandardFixture { + mockAccessServiceReadOverridesToReturn(failed(new RuntimeException("testing testing 123"))) + val result = invokeAccessControllerReadOverridesWith(applicationId) + status(result) shouldBe INTERNAL_SERVER_ERROR + } + + } + + "Access controller update overrides function" should { + + def mockAccessServiceUpdateOverridesToReturn(eventualOverridesResponse: Future[OverridesResponse]) = + when(mockAccessService.updateOverrides(any[UUID], any[OverridesRequest])(any[HeaderCarrier])).thenReturn(eventualOverridesResponse) + + "return http ok status when service update overrides succeeds" in new StandardFixture { + mockAccessServiceUpdateOverridesToReturn(successful(OverridesResponse(overrides))) + val result = invokeAccessControllerUpdateOverridesWith(applicationId, overridesRequest) + status(result) shouldBe OK + } + + "return resource as response body when service update overrides succeeds" in new StandardFixture { + mockAccessServiceUpdateOverridesToReturn(successful(OverridesResponse(overrides))) + val result = invokeAccessControllerUpdateOverridesWith(applicationId, overridesRequest) + jsonBodyOf(result) shouldBe Json.toJson(OverridesResponse(overrides)) + } + + "return http internal server error status when service update overrides fails" in new StandardFixture { + mockAccessServiceUpdateOverridesToReturn(failed(new RuntimeException("testing testing 123"))) + val result = invokeAccessControllerUpdateOverridesWith(applicationId, overridesRequest) + status(result) shouldBe INTERNAL_SERVER_ERROR + } + } + + trait Fixture { + + private[controllers] val accessController = new AccessController(mockAccessService, mockAuthConnector, mockApplicationService) + + when(mockAuthConnector.authorized(any[AuthRole])(any[HeaderCarrier])).thenReturn(successful(true)) + + def invokeAccessControllerReadScopesWith(applicationId: UUID): Result = + await(accessController.readScopes(applicationId)(fakeRequest)) + + def invokeAccessControllerUpdateScopesWith(applicationId: UUID, scopeRequest: ScopeRequest): Result = + await(accessController.updateScopes(applicationId)(fakeRequest.withBody(Json.toJson(scopeRequest)))) + + def invokeAccessControllerReadOverridesWith(applicationId: UUID): Result = + await(accessController.readOverrides(applicationId)(fakeRequest)) + + def invokeAccessControllerUpdateOverridesWith(applicationId: UUID, overridesRequest: OverridesRequest): Result = + await(accessController.updateOverrides(applicationId)(fakeRequest.withBody(Json.toJson(overridesRequest)))) + + } + + trait StandardFixture extends Fixture { + when(mockApplicationService.fetch(applicationId)).thenReturn(successful(Some( + new ApplicationResponse( + applicationId, + "clientId", + "name", + "PRODUCTION", + Some("description"), + Set.empty, + DateTimeUtils.now, + access = Standard()))) + ) + } + + trait PrivilegedAndRopcFixture extends Fixture { + + def testWithPrivilegedAndRopc(testBlock: => Unit): Unit = { + val applicationResponse = ApplicationResponse(applicationId, "clientId", "name", "PRODUCTION", None, Set.empty, DateTimeUtils.now) + when(mockApplicationService.fetch(applicationId)).thenReturn(successful(Some( + applicationResponse.copy(clientId = "privilegedClientId", name = "privilegedName", access = Privileged(scopes = Set("scope:privilegedScopeKey"))) + ))).thenReturn(successful(Some( + applicationResponse.copy(clientId = "ropcClientId", name = "ropcName", access = Ropc(Set("scope:ropcScopeKey"))) + ))) + testBlock + testBlock + } + } + +} diff --git a/test/unit/controllers/ApplicationControllerSpec.scala b/test/unit/controllers/ApplicationControllerSpec.scala new file mode 100644 index 000000000..9a56e782e --- /dev/null +++ b/test/unit/controllers/ApplicationControllerSpec.scala @@ -0,0 +1,1408 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 unit.controllers + +import java.util.UUID + +import common.uk.gov.hmrc.testutils.ApplicationStateUtil +import org.apache.http.HttpStatus._ +import org.joda.time.DateTime +import org.mockito.BDDMockito.given +import org.mockito.Matchers.{any, eq => mockEq} +import org.mockito.Mockito._ +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.mockito.MockitoSugar +import play.api.libs.json.{JsValue, Json} +import play.api.mvc.Result +import play.api.test.FakeRequest +import play.mvc.Http.HeaderNames +import uk.gov.hmrc.config.AppContext +import uk.gov.hmrc.connector.AuthConnector +import uk.gov.hmrc.controllers.ErrorCode._ +import uk.gov.hmrc.controllers._ +import uk.gov.hmrc.http.{HeaderCarrier, NotFoundException} +import uk.gov.hmrc.models.AuthRole.APIGatekeeper +import uk.gov.hmrc.models.Environment._ +import uk.gov.hmrc.models.JsonFormatters._ +import uk.gov.hmrc.models.RateLimitTier.SILVER +import uk.gov.hmrc.models.Role._ +import uk.gov.hmrc.models._ +import uk.gov.hmrc.play.test.{UnitSpec, WithFakeApplication} +import uk.gov.hmrc.services.{ApplicationService, CredentialService, SubscriptionService} +import uk.gov.hmrc.time.DateTimeUtils +import uk.gov.hmrc.util.http.HttpHeaders._ + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.concurrent.Future.{apply => _, _} + +class ApplicationControllerSpec extends UnitSpec with ScalaFutures with MockitoSugar with WithFakeApplication with ApplicationStateUtil { + + implicit lazy val materializer = fakeApplication.materializer + + trait Setup { + implicit val hc = HeaderCarrier().withExtraHeaders(X_REQUEST_ID_HEADER -> "requestId") + implicit lazy val request = FakeRequest().withHeaders("X-name" -> "blob", "X-email-address" -> "test@example.com", "X-Server-Token" -> "abc123") + + val mockCredentialService = mock[CredentialService] + val mockApplicationService = mock[ApplicationService] + val mockAuthConnector = mock[AuthConnector] + val mockSubscriptionService = mock[SubscriptionService] + val mockAppContext = mock[AppContext] + + val applicationTtlInSecs = 1234 + val subscriptionTtlInSecs = 4321 + when(mockAppContext.fetchApplicationTtlInSecs).thenReturn(applicationTtlInSecs) + when(mockAppContext.fetchSubscriptionTtlInSecs).thenReturn(subscriptionTtlInSecs) + + val underTest = new ApplicationController(mockApplicationService, mockAuthConnector, mockCredentialService, mockSubscriptionService, mockAppContext) + } + + trait PrivilegedAndRopcSetup extends Setup { + + def testWithPrivilegedAndRopcGatekeeperLoggedIn(applicationId: UUID, testBlock: => Unit): Unit = + testWithPrivilegedAndRopc(applicationId, gatekeeperLoggedIn = true, testBlock) + + def testWithPrivilegedAndRopcGatekeeperNotLoggedIn(applicationId: UUID, testBlock: => Unit): Unit = + testWithPrivilegedAndRopc(applicationId, gatekeeperLoggedIn = false, testBlock) + + private def testWithPrivilegedAndRopc(applicationId: UUID, gatekeeperLoggedIn: Boolean, testBlock: => Unit): Unit = { + when(underTest.applicationService.fetch(applicationId)) + .thenReturn(Some(aNewApplicationResponse(privilegedAccess))) + .thenReturn(Some(aNewApplicationResponse(ropcAccess))) + when(underTest.authConnector.authorized(mockEq(APIGatekeeper))(any[HeaderCarrier])).thenReturn(gatekeeperLoggedIn) + testBlock + testBlock + } + } + + val authTokenHeader = "authorization" -> "authorizationToken" + + val tokens = ApplicationTokensResponse( + EnvironmentTokenResponse("aaa", "bbb", Seq(ClientSecret("ccc", "ccc"))), + EnvironmentTokenResponse("111", "222", Seq(ClientSecret("333", "333")))) + + val collaborators = Set( + Collaborator("admin@example.com", ADMINISTRATOR), + Collaborator("dev@example.com", DEVELOPER)) + + private val standardAccess = Standard(Seq("http://example.com/redirect"), Some("http://example.com/terms"), Some("http://example.com/privacy")) + private val privilegedAccess = Privileged(scopes = Set("scope1")) + private val ropcAccess = Ropc() + + "hc" should { + + "take the X-email-address and X-name fields from the incoming headers" in new Setup { + val req = request.withHeaders( + LOGGED_IN_USER_NAME_HEADER -> "John Smith", + LOGGED_IN_USER_EMAIL_HEADER -> "test@example.com", + X_REQUEST_ID_HEADER -> "requestId" + ) + + underTest.hc(req).headers should contain(LOGGED_IN_USER_NAME_HEADER -> "John Smith") + underTest.hc(req).headers should contain(LOGGED_IN_USER_EMAIL_HEADER -> "test@example.com") + underTest.hc(req).headers should contain(X_REQUEST_ID_HEADER -> "requestId") + } + + "contain each header if only one exists" in new Setup { + val nameHeader = LOGGED_IN_USER_NAME_HEADER -> "John Smith" + val emailHeader = LOGGED_IN_USER_EMAIL_HEADER -> "test@example.com" + + underTest.hc(request.withHeaders(nameHeader)).headers should contain(nameHeader) + underTest.hc(request.withHeaders(emailHeader)).headers should contain(emailHeader) + } + } + + "Create" should { + val standardApplicationRequest = aCreateApplicationRequest(standardAccess, Environment.PRODUCTION) + val privilegedApplicationRequest = aCreateApplicationRequest(privilegedAccess, Environment.PRODUCTION) + val ropcApplicationRequest = aCreateApplicationRequest(ropcAccess, Environment.PRODUCTION) + + val standardApplicationResponse = CreateApplicationResponse(aNewApplicationResponse()) + val totp = TotpSecrets("pTOTP", "sTOTP") + val privilegedApplicationResponse = CreateApplicationResponse(aNewApplicationResponse(privilegedAccess), Some(totp)) + val ropcApplicationResponse = CreateApplicationResponse(aNewApplicationResponse(ropcAccess)) + + "succeed with a 201 (Created) for a valid Standard application request when service responds successfully" in new Setup { + + when(underTest.applicationService.create(mockEq(standardApplicationRequest))(any[HeaderCarrier])).thenReturn(successful(standardApplicationResponse)) + + val result = await(underTest.create()(request.withBody(Json.toJson(standardApplicationRequest)))) + + status(result) shouldBe SC_CREATED + verify(underTest.applicationService).create(mockEq(standardApplicationRequest))(any[HeaderCarrier]) + } + + "succeed with a 201 (Created) for a valid Privileged application request when gatekeeper is logged in and service responds successfully" in new Setup { + + when(underTest.authConnector.authorized(mockEq(APIGatekeeper))(any[HeaderCarrier])).thenReturn(true) + when(underTest.applicationService.create(mockEq(privilegedApplicationRequest))(any[HeaderCarrier])).thenReturn(successful(privilegedApplicationResponse)) + + val result = await(underTest.create()(request.withBody(Json.toJson(privilegedApplicationRequest)))) + + (jsonBodyOf(result) \ "totp").as[TotpSecrets] shouldBe totp + status(result) shouldBe SC_CREATED + verify(underTest.applicationService).create(mockEq(privilegedApplicationRequest))(any[HeaderCarrier]) + } + + "succeed with a 201 (Created) for a valid ROPC application request when gatekeeper is logged in and service responds successfully" in new Setup { + when(underTest.authConnector.authorized(mockEq(APIGatekeeper))(any[HeaderCarrier])).thenReturn(true) + when(underTest.applicationService.create(mockEq(ropcApplicationRequest))(any[HeaderCarrier])).thenReturn(successful(ropcApplicationResponse)) + + val result = await(underTest.create()(request.withBody(Json.toJson(ropcApplicationRequest)))) + + status(result) shouldBe SC_CREATED + verify(underTest.applicationService).create(mockEq(ropcApplicationRequest))(any[HeaderCarrier]) + } + + "fail with a 401 (Unauthorized) for a valid Privileged application request when gatekeeper is not logged in" in new Setup { + + when(underTest.authConnector.authorized(mockEq(APIGatekeeper))(any[HeaderCarrier])).thenReturn(false) + + val result = await(underTest.create()(request.withBody(Json.toJson(privilegedApplicationRequest)))) + + verifyErrorResult(result, SC_UNAUTHORIZED, ErrorCode.UNAUTHORIZED) + verify(underTest.applicationService, never()).create(any[CreateApplicationRequest])(any[HeaderCarrier]) + } + + "fail with a 401 (Unauthorized) for a valid ROPC application request when gatekeeper is not logged in" in new Setup { + when(underTest.authConnector.authorized(mockEq(APIGatekeeper))(any[HeaderCarrier])).thenReturn(false) + + val result = await(underTest.create()(request.withBody(Json.toJson(ropcApplicationRequest)))) + + verifyErrorResult(result, SC_UNAUTHORIZED, ErrorCode.UNAUTHORIZED) + verify(underTest.applicationService, never()).create(any[CreateApplicationRequest])(any[HeaderCarrier]) + } + + "fail with a 409 (Conflict) for a privileged application when the name already exists for another production application" in new Setup { + + when(underTest.authConnector.authorized(mockEq(APIGatekeeper))(any[HeaderCarrier])).thenReturn(true) + when(underTest.applicationService.create(mockEq(privilegedApplicationRequest))(any[HeaderCarrier])) + .thenReturn(failed(ApplicationAlreadyExists("appName"))) + + val result = await(underTest.create()(request.withBody(Json.toJson(privilegedApplicationRequest)))) + + status(result) shouldBe SC_CONFLICT + jsonBodyOf(result) shouldBe JsErrorResponse(APPLICATION_ALREADY_EXISTS, "Application already exists with name: appName") + } + + "fail with a 422 (unprocessable entity) when unexpected json is provided" in new Setup { + + val body = """{ "json": "invalid" }""" + + val result = await(underTest.create()(request.withBody(Json.parse(body)))) + + status(result) shouldBe SC_UNPROCESSABLE_ENTITY + } + + "fail with a 422 (unprocessable entity) when duplicate email is provided" in new Setup { + + val body = + s"""{ + |"name" : "My Application", + |"environment": "PRODUCTION", + |"access" : { + | "accessType" : "STANDARD", + | "redirectUris" : [], + | "overrides" : [] + |}, + |"collaborators": [ + |{"emailAddress": "admin@example.com","role": "ADMINISTRATOR"}, + |{"emailAddress": "ADMIN@example.com","role": "ADMINISTRATOR"} + |] + |}""".stripMargin.replaceAll("\n", "") + + val result = await(underTest.create()(request.withBody(Json.parse(body)))) + + status(result) shouldBe SC_UNPROCESSABLE_ENTITY + (jsonBodyOf(result) \ "message").as[String] shouldBe "requirement failed: duplicate email in collaborator" + } + + "fail with a 422 (unprocessable entity) when request exceeds maximum number of redirect URIs" in new Setup { + + val createApplicationRequestJson: String = + s"""{ + "name" : "My Application", + "environment": "PRODUCTION", + "access": { + "accessType": "STANDARD", + "redirectUris": [ + "http://localhost:8080/redirect1", "http://localhost:8080/redirect2", + "http://localhost:8080/redirect3", "http://localhost:8080/redirect4", + "http://localhost:8080/redirect5", "http://localhost:8080/redirect6" + ], + "overrides" : [] + }, + "collaborators": [{"emailAddress": "admin@example.com","role": "ADMINISTRATOR"}] + }""" + + val result = await(underTest.create()(request.withBody(Json.parse(createApplicationRequestJson)))) + + status(result) shouldBe SC_UNPROCESSABLE_ENTITY + (jsonBodyOf(result) \ "message").as[String] shouldBe "requirement failed: maximum number of redirect URIs exceeded" + } + + "fail with a 422 (unprocessable entity) when incomplete json is provided" in new Setup { + + val body = """{ "name": "myapp" }""" + + val result = await(underTest.create()(request.withBody(Json.parse(body)))) + + status(result) shouldBe SC_UNPROCESSABLE_ENTITY + + } + + "fail with a 422 (unprocessable entity) and correct body when incorrect role is used" in new Setup { + val body = + s"""{ + |"name" : "My Application", + |"description" : "Description", + |"environment": "PRODUCTION", + |"redirectUris": ["http://example.com/redirect"], + |"termsAndConditionsUrl": "http://example.com/terms", + |"privacyPolicyUrl": "http://example.com/privacy", + |"collaborators": [ + |{ + |"emailAddress": "admin@example.com", + |"role": "ADMINISTRATOR" + |}, + |{ + |"emailAddress": "dev@example.com", + |"role": "developer" + |}] + |}""".stripMargin.replaceAll("\n", "") + + val result = await(underTest.create()(request.withBody(Json.parse(body)))) + + val expected = + s"""{ + |"code": "INVALID_REQUEST_PAYLOAD", + |"message": "Enumeration expected of type: 'Role$$', but it does not contain 'developer'" + |}""".stripMargin.replaceAll("\n", "") + + + status(result) shouldBe SC_UNPROCESSABLE_ENTITY + jsonBodyOf(result) shouldBe Json.toJson(Json.parse(expected)) + } + + "fail with a 500 (internal server error) when an exception is thrown" in new Setup { + + when(underTest.applicationService.create(mockEq(standardApplicationRequest))(any[HeaderCarrier])) + .thenReturn(failed(new RuntimeException("Expected test failure"))) + + val result = await(underTest.create()(request.withBody(Json.toJson(standardApplicationRequest)))) + + status(result) shouldBe SC_INTERNAL_SERVER_ERROR + } + + } + + "Update" should { + val standardApplicationRequest = anUpdateApplicationRequest(standardAccess) + val privilegedApplicationRequest = anUpdateApplicationRequest(privilegedAccess) + val id = UUID.randomUUID() + + "fail with a 401 (Unauthorized) when a valid Privileged application and gatekeeper is not logged in" in new Setup { + + when(underTest.applicationService.fetch(id)).thenReturn(None) + + val result = await(underTest.update(id)(request.withBody(Json.toJson(privilegedApplicationRequest)))) + + verifyErrorResult(result, SC_NOT_FOUND, ErrorCode.APPLICATION_NOT_FOUND) + } + + "fail with a 404 (not found) when id is provided but no application exists for that id" in new Setup { + + when(underTest.applicationService.fetch(id)).thenReturn(None) + + val result = await(underTest.update(id)(request.withBody(Json.toJson(standardApplicationRequest)))) + + verifyErrorResult(result, SC_NOT_FOUND, ErrorCode.APPLICATION_NOT_FOUND) + } + + "fail with a 422 (unprocessable entity) when request exceeds maximum number of redirect URIs" in new Setup { + when(underTest.applicationService.fetch(id)).thenReturn(Some(aNewApplicationResponse())) + + val updateApplicationRequestJson: String = + s"""{ + "id" : "My ID", + "name" : "My Application", + "collaborators": [{"emailAddress": "admin@example.com","role": "ADMINISTRATOR"}], + "access": { + "accessType": "STANDARD", + "redirectUris": [ + "http://localhost:8080/redirect1", "http://localhost:8080/redirect2", + "http://localhost:8080/redirect3", "http://localhost:8080/redirect4", + "http://localhost:8080/redirect5", "http://localhost:8080/redirect6" + ], + "overrides" : [] + } + }""" + val result = await(underTest.update(id)(request.withBody(Json.parse(updateApplicationRequestJson)))) + + status(result) shouldBe SC_UNPROCESSABLE_ENTITY + (jsonBodyOf(result) \ "message").as[String] shouldBe "requirement failed: maximum number of redirect URIs exceeded" + } + + } + + "update approval" should { + val termsOfUseAgreement = TermsOfUseAgreement("test@example.com", new DateTime(), "1.0") + val checkInformation = CheckInformation( + contactDetails = Some(ContactDetails("Tester", "test@example.com", "12345677890")), termsOfUseAgreements = Seq(termsOfUseAgreement)) + val id = UUID.randomUUID() + + "fail with a 404 (not found) when id is provided but no application exists for that id" in new Setup { + + when(underTest.applicationService.fetch(id)).thenReturn(None) + + val result = await(underTest.updateCheck(id)(request.withBody(Json.toJson(checkInformation)))) + + verifyErrorResult(result, SC_NOT_FOUND, ErrorCode.APPLICATION_NOT_FOUND) + } + + "sucessfully update approval information for applicaton" in new Setup { + when(underTest.authConnector.authorized(mockEq(APIGatekeeper))(any[HeaderCarrier])).thenReturn(true) + when(underTest.applicationService.fetch(id)).thenReturn(successful(Some(aNewApplicationResponse()))) + when(underTest.applicationService.updateCheck(mockEq(id), mockEq(checkInformation))).thenReturn(successful(aNewApplicationResponse())) + + val jsonBody = Json.toJson(checkInformation) + val result = await(underTest.updateCheck(id)(request.withBody(jsonBody))) + + status(result) shouldBe SC_OK + } + } + + "fetch application" should { + val applicationId = UUID.randomUUID() + + "succeed with a 200 (ok) if the application exists for the given id" in new Setup { + when(underTest.applicationService.fetch(applicationId)).thenReturn(successful(Some(aNewApplicationResponse()))) + + val result = await(underTest.fetch(applicationId)(request)) + + status(result) shouldBe SC_OK + } + + "fail with a 404 (not found) if no application exists for the given id" in new Setup { + when(underTest.applicationService.fetch(applicationId)).thenReturn(successful(None)) + + val result = await(underTest.fetch(applicationId)(request)) + + verifyErrorResult(result, SC_NOT_FOUND, ErrorCode.APPLICATION_NOT_FOUND) + } + + "fail with a 500 (internal server error) when an exception is thrown" in new Setup { + when(underTest.applicationService.fetch(applicationId)).thenReturn(failed(new RuntimeException("Expected test failure"))) + + val result = await(underTest.fetch(applicationId)(request)) + + status(result) shouldBe SC_INTERNAL_SERVER_ERROR + + } + } + + private def verifyErrorResult(result: Result, statusCode: Int, errorCode: ErrorCode): Unit = { + status(result) shouldBe statusCode + (jsonBodyOf(result) \ "code").as[String] shouldBe errorCode.toString + } + + "fetch credentials" should { + val applicationId = UUID.randomUUID() + + "succeed with a 200 (ok) if the application exists for the given id" in new Setup { + when(mockCredentialService.fetchCredentials(applicationId)).thenReturn(successful(Some(tokens))) + + val result = await(underTest.fetchCredentials(applicationId)(request)) + + status(result) shouldBe SC_OK + jsonBodyOf(result) shouldBe Json.toJson(tokens) + } + + "fail with a 404 (not found) if no application exists for the given id" in new Setup { + when(mockCredentialService.fetchCredentials(applicationId)).thenReturn(successful(None)) + + val result = await(underTest.fetchCredentials(applicationId)(request)) + + verifyErrorResult(result, SC_NOT_FOUND, ErrorCode.APPLICATION_NOT_FOUND) + } + + "fail with a 500 (internal server error) when an exception is thrown" in new Setup { + when(mockCredentialService.fetchCredentials(applicationId)).thenReturn(failed(new RuntimeException("Expected test failure"))) + + val result = await(underTest.fetchCredentials(applicationId)(request)) + + status(result) shouldBe SC_INTERNAL_SERVER_ERROR + } + + } + + "fetch WSO2 credentials" should { + val clientId = "productionClientId" + + "succeed with a 200 (ok) if the application exists for the given id" in new Setup { + val wso2Credentials = Wso2Credentials(clientId, "accessToken", "wso2Secret") + when(mockCredentialService.fetchWso2Credentials(clientId)).thenReturn(successful(Some(wso2Credentials))) + + val result = await(underTest.fetchWso2Credentials(clientId)(request)) + + status(result) shouldBe SC_OK + jsonBodyOf(result) shouldBe Json.toJson(wso2Credentials) + } + + "fail with a 404 (not found) if no application exists for the given client id" in new Setup { + when(mockCredentialService.fetchWso2Credentials(clientId)).thenReturn(successful(None)) + + val result = await(underTest.fetchWso2Credentials(clientId)(request)) + + verifyErrorResult(result, SC_NOT_FOUND, ErrorCode.APPLICATION_NOT_FOUND) + } + + "fail with a 500 (internal server error) when an exception is thrown" in new Setup { + when(mockCredentialService.fetchWso2Credentials(clientId)).thenReturn(failed(new RuntimeException("Expected test failure"))) + + val result = await(underTest.fetchWso2Credentials(clientId)(request)) + + status(result) shouldBe SC_INTERNAL_SERVER_ERROR + } + + } + + "add collaborators" should { + val applicationId = UUID.randomUUID() + val admin = "admin@example.com" + val email = "test@example.com" + val role = DEVELOPER + val isRegistered = false + val adminsToEmail = Set.empty[String] + val addCollaboratorRequest = AddCollaboratorRequest(admin, Collaborator(email, role), isRegistered, adminsToEmail) + val payload = s"""{"adminEmail":"$admin", "collaborator":{"emailAddress":"$email", "role":"$role"}, "isRegistered": $isRegistered, "adminsToEmail": []}""" + val addRequest: FakeRequest[_] => FakeRequest[JsValue] = request => request.withBody(Json.parse(payload)) + + "succeed with a 200 (ok) for a STANDARD application" in new Setup { + when(underTest.applicationService.fetch(applicationId)).thenReturn(Some(aNewApplicationResponse())) + val response = AddCollaboratorResponse(registeredUser = true) + when(underTest.applicationService.addCollaborator(mockEq(applicationId), mockEq(addCollaboratorRequest))(any[HeaderCarrier])).thenReturn(response) + + val result = await(underTest.addCollaborator(applicationId)(addRequest(request))) + + status(result) shouldBe SC_OK + jsonBodyOf(result) shouldBe Json.toJson(response) + } + + "succeed with a 200 (ok) for a PRIVILEGED or ROPC application and the gatekeeper is logged in" in new PrivilegedAndRopcSetup { + testWithPrivilegedAndRopcGatekeeperLoggedIn(applicationId, { + val response = AddCollaboratorResponse(registeredUser = true) + when(underTest.applicationService.addCollaborator(mockEq(applicationId), mockEq(addCollaboratorRequest))(any[HeaderCarrier])).thenReturn(response) + + val result = await(underTest.addCollaborator(applicationId)(addRequest(request))) + + status(result) shouldBe SC_OK + jsonBodyOf(result) shouldBe Json.toJson(response) + }) + } + + "fail with a 401 (unauthorized) for a PRIVILEGED or ROPC application and the gatekeeper is not logged in" in new PrivilegedAndRopcSetup { + testWithPrivilegedAndRopcGatekeeperNotLoggedIn(applicationId, { + val response = AddCollaboratorResponse(registeredUser = true) + when(underTest.applicationService.addCollaborator(mockEq(applicationId), mockEq(addCollaboratorRequest))(any[HeaderCarrier])).thenReturn(response) + + val result = await(underTest.addCollaborator(applicationId)(addRequest(request))) + + verifyErrorResult(result, SC_UNAUTHORIZED, ErrorCode.UNAUTHORIZED) + }) + } + + "fail with a 404 (not found) if no application exists for the given id" in new Setup { + when(underTest.applicationService.fetch(applicationId)).thenReturn(None) + + val result = await(underTest.addCollaborator(applicationId)(addRequest(request))) + + verifyErrorResult(result, SC_NOT_FOUND, ErrorCode.APPLICATION_NOT_FOUND) + } + + "fail with a 422 (unprocessable) if role is invalid" in new Setup { + when(underTest.applicationService.fetch(applicationId)).thenReturn(Some(aNewApplicationResponse())) + + val result = await(underTest.addCollaborator(applicationId)(request.withBody(Json.obj("emailAddress" -> s"$email", "role" -> "invalid")))) + + verifyErrorResult(result, SC_UNPROCESSABLE_ENTITY, ErrorCode.INVALID_REQUEST_PAYLOAD) + } + + "fail with a 409 (conflict) if email already registered with different role" in new Setup { + when(underTest.applicationService.fetch(applicationId)).thenReturn(Some(aNewApplicationResponse())) + when(underTest.applicationService.addCollaborator(mockEq(applicationId), mockEq(addCollaboratorRequest))(any[HeaderCarrier])) + .thenReturn(failed(new UserAlreadyExists)) + + val result = await(underTest.addCollaborator(applicationId)(addRequest(request))) + + verifyErrorResult(result, SC_CONFLICT, ErrorCode.USER_ALREADY_EXISTS) + } + + "fail with a 500 (internal server error) when an exception is thrown" in new Setup { + when(underTest.applicationService.fetch(applicationId)).thenReturn(Some(aNewApplicationResponse())) + when(underTest.applicationService.addCollaborator(mockEq(applicationId), mockEq(addCollaboratorRequest))(any[HeaderCarrier])) + .thenReturn(failed(new RuntimeException("Expected test failure"))) + + val result = await(underTest.addCollaborator(applicationId)(addRequest(request))) + + status(result) shouldBe SC_INTERNAL_SERVER_ERROR + } + + } + + "remove collaborator" should { + val applicationId = UUID.randomUUID() + val admin = "admin@example.com" + val collaborator = "dev@example.com" + val adminsToEmailSet = Set.empty[String] + val adminsToEmailString = "" + + "succeed with a 204 (No Content) for a STANDARD application" in new Setup { + when(underTest.applicationService.fetch(applicationId)).thenReturn(Some(aNewApplicationResponse())) + when(underTest.applicationService.deleteCollaborator( + mockEq(applicationId), mockEq(collaborator), mockEq(admin), mockEq(adminsToEmailSet))(any[HeaderCarrier])) + .thenReturn(successful(Set(Collaborator(admin, Role.ADMINISTRATOR)))) + + val result = await(underTest.deleteCollaborator(applicationId, collaborator, admin, adminsToEmailString)(request)) + + status(result) shouldBe SC_NO_CONTENT + } + + "succeed with a 204 (No Content) for a PRIVILEGED or ROPC application when the Gatekeeper is logged in" in new PrivilegedAndRopcSetup { + testWithPrivilegedAndRopcGatekeeperLoggedIn(applicationId, { + when(underTest.applicationService.deleteCollaborator( + mockEq(applicationId), mockEq(collaborator), mockEq(admin), mockEq(adminsToEmailSet))(any[HeaderCarrier])) + .thenReturn(successful(Set(Collaborator(admin, Role.ADMINISTRATOR)))) + + val result = await(underTest.deleteCollaborator(applicationId, collaborator, admin, adminsToEmailString)(request)) + + status(result) shouldBe SC_NO_CONTENT + }) + } + + "fail with a 401 (Unauthorized) for a PRIVILEGED or ROPC application when the Gatekeeper is not logged in" in new PrivilegedAndRopcSetup { + testWithPrivilegedAndRopcGatekeeperNotLoggedIn(applicationId, { + when(underTest.applicationService.deleteCollaborator( + mockEq(applicationId), mockEq(collaborator), mockEq(admin), mockEq(adminsToEmailSet))(any[HeaderCarrier])) + .thenReturn(successful(Set(Collaborator(admin, Role.ADMINISTRATOR)))) + + val result = await(underTest.deleteCollaborator(applicationId, collaborator, admin, adminsToEmailString)(request)) + + verifyErrorResult(result, SC_UNAUTHORIZED, ErrorCode.UNAUTHORIZED) + }) + } + + "fail with a 404 (not found) if no application exists for the given id" in new Setup { + when(underTest.applicationService.fetch(applicationId)).thenReturn(None) + + val result = await(underTest.deleteCollaborator(applicationId, collaborator, admin, adminsToEmailString)(request)) + + verifyErrorResult(result, SC_NOT_FOUND, ErrorCode.APPLICATION_NOT_FOUND) + } + + "fail with a 403 (forbidden) if deleting the only admin" in new Setup { + when(underTest.applicationService.fetch(applicationId)).thenReturn(Some(aNewApplicationResponse())) + when(underTest.applicationService.deleteCollaborator( + mockEq(applicationId), mockEq(collaborator), mockEq(admin), mockEq(adminsToEmailSet))(any[HeaderCarrier])) + .thenReturn(failed(new ApplicationNeedsAdmin)) + + val result = await(underTest.deleteCollaborator(applicationId, collaborator, admin, adminsToEmailString)(request)) + + verifyErrorResult(result, SC_FORBIDDEN, ErrorCode.APPLICATION_NEEDS_ADMIN) + } + + "fail with a 500 (internal server error) when an exception is thrown" in new Setup { + when(underTest.applicationService.fetch(applicationId)).thenReturn(Some(aNewApplicationResponse())) + when(underTest.applicationService.deleteCollaborator( + mockEq(applicationId), mockEq(collaborator), mockEq(admin), mockEq(adminsToEmailSet))(any[HeaderCarrier])) + .thenReturn(failed(new RuntimeException("Expected test failure"))) + + val result = await(underTest.deleteCollaborator(applicationId, collaborator, admin, adminsToEmailString)(request)) + + status(result) shouldBe SC_INTERNAL_SERVER_ERROR + } + + } + + "add client secret" should { + val applicationId = UUID.randomUUID() + val tokens = ApplicationTokensResponse( + EnvironmentTokenResponse("prodClientId", "prodToken", Seq(aSecret("prodSecret"), aSecret("prodSecret2"))), + EnvironmentTokenResponse("sandboxClientId", "sandboxToken", Seq(aSecret("sandboxSecret")))) + val secretRequest = ClientSecretRequest("secret 1") + + "succeed with a 200 (ok) if the application exists for the given id" in new PrivilegedAndRopcSetup { + testWithPrivilegedAndRopcGatekeeperLoggedIn(applicationId, { + when(mockCredentialService.addClientSecret(mockEq(applicationId), mockEq(secretRequest))(any[HeaderCarrier])).thenReturn(successful(tokens)) + + val result = await(underTest.addClientSecret(applicationId)(request.withBody(Json.toJson(secretRequest)))) + + status(result) shouldBe SC_OK + jsonBodyOf(result) shouldBe Json.toJson(tokens) + }) + } + + "fail with a 401 (Unauthorized) if the gatekeeper is not logged in" in new PrivilegedAndRopcSetup { + testWithPrivilegedAndRopcGatekeeperNotLoggedIn(applicationId, { + when(mockCredentialService.addClientSecret(mockEq(applicationId), mockEq(secretRequest))(any[HeaderCarrier])) + .thenReturn(failed(new ClientSecretsLimitExceeded)) + + val result = await(underTest.addClientSecret(applicationId)(request.withBody(Json.toJson(secretRequest)))) + + verifyErrorResult(result, SC_UNAUTHORIZED, ErrorCode.UNAUTHORIZED) + }) + } + + "fail with a 403 (Forbidden) if the environment has already the maximum number of secrets set" in new PrivilegedAndRopcSetup { + testWithPrivilegedAndRopcGatekeeperLoggedIn(applicationId, { + when(mockCredentialService.addClientSecret(mockEq(applicationId), mockEq(secretRequest))(any[HeaderCarrier])) + .thenReturn(failed(new ClientSecretsLimitExceeded)) + + val result = await(underTest.addClientSecret(applicationId)(request.withBody(Json.toJson(secretRequest)))) + + verifyErrorResult(result, SC_FORBIDDEN, ErrorCode.CLIENT_SECRET_LIMIT_EXCEEDED) + }) + } + + "fail with a 404 (not found) if no application exists for the given id" in new PrivilegedAndRopcSetup { + testWithPrivilegedAndRopcGatekeeperLoggedIn(applicationId, { + when(mockCredentialService.addClientSecret(mockEq(applicationId), mockEq(secretRequest))(any[HeaderCarrier])) + .thenReturn(failed(new NotFoundException("application not found"))) + + val result = await(underTest.addClientSecret(applicationId)(request.withBody(Json.toJson(secretRequest)))) + + verifyErrorResult(result, SC_NOT_FOUND, ErrorCode.APPLICATION_NOT_FOUND) + }) + } + + "fail with a 500 (internal server error) when an exception is thrown" in new PrivilegedAndRopcSetup { + testWithPrivilegedAndRopcGatekeeperLoggedIn(applicationId, { + when(mockCredentialService.addClientSecret(mockEq(applicationId), mockEq(secretRequest))(any[HeaderCarrier])) + .thenReturn(failed(new RuntimeException)) + + val result = await(underTest.addClientSecret(applicationId)(request.withBody(Json.toJson(secretRequest)))) + + status(result) shouldBe SC_INTERNAL_SERVER_ERROR + }) + } + + } + + "delete client secret" should { + + val applicationId = UUID.randomUUID() + val secrets = "ccc" + val splitSecrets = secrets.split(",").toSeq + val secretRequest = DeleteClientSecretsRequest(splitSecrets) + val tokens = ApplicationTokensResponse( + EnvironmentTokenResponse("aaa", "bbb", Seq()), + EnvironmentTokenResponse("111", "222", Seq(ClientSecret("333", "333")))) + + "succeed with a 204 for a STANDARD application" in new Setup { + + when(underTest.applicationService.fetch(applicationId)).thenReturn(Some(aNewApplicationResponse())) + when(mockCredentialService.deleteClientSecrets(mockEq(applicationId), mockEq(splitSecrets))(any[HeaderCarrier])).thenReturn(successful(tokens)) + + val result = await(underTest.deleteClientSecrets(applicationId)(request.withBody(Json.toJson(secretRequest)))) + + status(result) shouldBe SC_NO_CONTENT + } + + "succeed with a 204 (No Content) for a PRIVILEGED or ROPC application when the Gatekeeper is logged in" in new PrivilegedAndRopcSetup { + testWithPrivilegedAndRopcGatekeeperLoggedIn(applicationId, { + when(mockCredentialService.deleteClientSecrets(mockEq(applicationId), mockEq(splitSecrets))(any[HeaderCarrier])) + .thenReturn(successful(tokens)) + + val result = await(underTest.deleteClientSecrets(applicationId)(request.withBody(Json.toJson(secretRequest)))) + + status(result) shouldBe SC_NO_CONTENT + }) + } + + "fail with a 401 (Unauthorized) for a PRIVILEGED or ROPC application when the Gatekeeper is not logged in" in new PrivilegedAndRopcSetup { + testWithPrivilegedAndRopcGatekeeperNotLoggedIn(applicationId, { + when(mockCredentialService.deleteClientSecrets(mockEq(applicationId), mockEq(splitSecrets))(any[HeaderCarrier])) + .thenReturn(successful(tokens)) + + val result = await(underTest.deleteClientSecrets(applicationId)(request.withBody(Json.toJson(secretRequest)))) + + verifyErrorResult(result, SC_UNAUTHORIZED, ErrorCode.UNAUTHORIZED) + }) + } + } + + private def aSecret(secret: String): ClientSecret = { + ClientSecret(secret, secret) + } + + "validate credentials" should { + val validation = ValidationRequest("clientId", "clientSecret") + val payload = s"""{"clientId":"${validation.clientId}", "clientSecret":"${validation.clientSecret}"}""" + + "succeed with a 200 (ok) if the credentials are valid for an application" in new Setup { + + when(mockCredentialService.validateCredentials(validation)).thenReturn(successful(Some(PRODUCTION))) + + val result = await(underTest.validateCredentials(request.withBody(Json.parse(payload)))) + + status(result) shouldBe SC_OK + jsonBodyOf(result) shouldBe Json.obj("environment" -> PRODUCTION.toString) + } + + "fail with a 401 if credentials are invalid for an application" in new Setup { + + when(mockCredentialService.validateCredentials(validation)).thenReturn(successful(None)) + + val result = await(underTest.validateCredentials(request.withBody(Json.parse(payload)))) + + verifyErrorResult(result, SC_UNAUTHORIZED, ErrorCode.INVALID_CREDENTIALS) + } + + "fail with a 500 (internal server error) when an exception is thrown" in new Setup { + + when(mockCredentialService.validateCredentials(validation)).thenReturn(failed(new RuntimeException("Expected test failure"))) + + val result = await(underTest.validateCredentials(request.withBody(Json.parse(payload)))) + + status(result) shouldBe SC_INTERNAL_SERVER_ERROR + } + + } + + "fetch application" should { + val clientId = "A123XC" + "retrieve by client id" in new Setup { + when(underTest.applicationService.fetchByClientId(clientId)).thenReturn(Future(Some(aNewApplicationResponse()))) + val result = await(underTest.queryDispatcher()(FakeRequest("GET", s"?clientId=$clientId"))) + status(result) shouldBe SC_OK + result.header.headers.get(HeaderNames.CACHE_CONTROL) shouldBe Some(s"max-age=$applicationTtlInSecs") + result.header.headers.get(HeaderNames.VARY) shouldBe None + } + + "retrieve by server token" in new Setup { + val serverToken = "b3c83934c02df8b111e7f9f8700000" + val req = request.withHeaders("X-Server-Token" -> serverToken) + val application = aNewApplicationResponse() + + when(underTest.applicationService.fetchByServerToken(serverToken)).thenReturn(Future(Some(aNewApplicationResponse()))) + + val result = await(underTest.queryDispatcher()(req)) + status(result) shouldBe SC_OK + result.header.headers.get(HeaderNames.CACHE_CONTROL) shouldBe Some(s"max-age=$applicationTtlInSecs") + result.header.headers.get(HeaderNames.VARY) shouldBe Some("X-server-token") + } + + "retrieve by server token (Uppercased header)" in new Setup { + val serverToken = "b3c83934c02df8b111e7f9f8700000" + val req = request.withHeaders("X-SERVER-TOKEN" -> serverToken) + val application = aNewApplicationResponse() + + when(underTest.applicationService.fetchByServerToken(serverToken)).thenReturn(Future(Some(aNewApplicationResponse()))) + + val result = await(underTest.queryDispatcher()(req)) + + verify(underTest.applicationService).fetchByServerToken(serverToken) + status(result) shouldBe SC_OK + result.header.headers.get(HeaderNames.CACHE_CONTROL) shouldBe Some(s"max-age=$applicationTtlInSecs") + result.header.headers.get(HeaderNames.VARY) shouldBe Some("X-server-token") + } + + "retrieve all" in new Setup { + when(underTest.applicationService.fetchAll()).thenReturn(Future(Seq(aNewApplicationResponse(), aNewApplicationResponse()))) + val result = await(underTest.queryDispatcher()(FakeRequest())) + status(result) shouldBe SC_OK + jsonBodyOf(result).as[Seq[JsValue]] should have size 2 + result.header.headers.get(HeaderNames.CACHE_CONTROL) shouldBe None + result.header.headers.get(HeaderNames.VARY) shouldBe None + } + + "retrieve when no subscriptions" in new Setup { + when(underTest.applicationService.fetchAllWithNoSubscriptions()).thenReturn(Future(Seq(aNewApplicationResponse()))) + val result = await(underTest.queryDispatcher()(FakeRequest("GET", s"?noSubscriptions=true"))) + status(result) shouldBe SC_OK + result.header.headers.get(HeaderNames.CACHE_CONTROL) shouldBe None + result.header.headers.get(HeaderNames.VARY) shouldBe None + } + + "fail with a 500 (internal server error) when an exception is thrown from fetchAll" in new Setup { + when(underTest.applicationService.fetchAll()).thenReturn(failed(new RuntimeException("Expected test exception"))) + + val result = await(underTest.queryDispatcher()(FakeRequest())) + + status(result) shouldBe SC_INTERNAL_SERVER_ERROR + result.header.headers.get(HeaderNames.CACHE_CONTROL) shouldBe None + result.header.headers.get(HeaderNames.VARY) shouldBe None + } + + "fail with a 500 (internal server error) when a clientId is supplied" in new Setup { + when(underTest.applicationService.fetchByClientId(clientId)).thenReturn(failed(new RuntimeException("Expected test exception"))) + val result = await(underTest.queryDispatcher()(FakeRequest("GET", s"?clientId=$clientId"))) + + status(result) shouldBe SC_INTERNAL_SERVER_ERROR + result.header.headers.get(HeaderNames.CACHE_CONTROL) shouldBe None + result.header.headers.get(HeaderNames.VARY) shouldBe None + } + } + + "fetchAllForCollaborator" should { + + val emailAddress = "dev@example.com" + val environment = "PRODUCTION" + val queryRequest = FakeRequest("GET", s"?emailAddress=$emailAddress") + + "succeed with a 200 (ok) when applications are found for the collaborator" in new Setup { + val standardApplicationResponse = aNewApplicationResponse(access = Standard()) + val privilegedApplicationResponse = aNewApplicationResponse(access = Privileged()) + val ropcApplicationResponse = aNewApplicationResponse(access = Ropc()) + + when(underTest.applicationService.fetchAllForCollaborator(emailAddress)) + .thenReturn(successful(Seq(standardApplicationResponse, privilegedApplicationResponse, ropcApplicationResponse))) + + status(await(underTest.queryDispatcher()(queryRequest))) shouldBe SC_OK + } + + "succeed with a 200 (ok) when applications are found for the collaborator and the environment" in new Setup { + val queryRequestWithEnvironment = FakeRequest("GET", s"?emailAddress=$emailAddress&environment=$environment") + val standardApplicationResponse = aNewApplicationResponse(access = Standard()) + val privilegedApplicationResponse = aNewApplicationResponse(access = Privileged()) + val ropcApplicationResponse = aNewApplicationResponse(access = Ropc()) + + when(underTest.applicationService.fetchAllForCollaboratorAndEnvironment(emailAddress, environment)) + .thenReturn(successful(Seq(standardApplicationResponse, privilegedApplicationResponse, ropcApplicationResponse))) + + status(await(underTest.queryDispatcher()(queryRequestWithEnvironment))) shouldBe SC_OK + } + + "succeed with a 200 (ok) when no applications are found for the collaborator" in new Setup { + when(underTest.applicationService.fetchAllForCollaborator(emailAddress)).thenReturn(successful(Nil)) + + val result = await(underTest.queryDispatcher()(queryRequest)) + + status(result) shouldBe SC_OK + bodyOf(result) shouldBe "[]" + } + + "fail with a 500 (internal server error) when an exception is thrown" in new Setup { + when(underTest.applicationService.fetchAllForCollaborator(emailAddress)).thenReturn(failed(new RuntimeException("Expected test failure"))) + + val result = await(underTest.queryDispatcher()(queryRequest)) + + status(result) shouldBe SC_INTERNAL_SERVER_ERROR + } + + } + + "fetchAllBySubscription" when { + val subscribesTo = "an-api" + + "not given a version" should { + val queryRequest = FakeRequest("GET", s"?subscribesTo=$subscribesTo") + + "succeed with a 200 (ok) when applications are found" in new Setup { + val standardApplicationResponse = aNewApplicationResponse(access = Standard()) + val privilegedApplicationResponse = aNewApplicationResponse(access = Privileged()) + val ropcApplicationResponse = aNewApplicationResponse(access = Ropc()) + val response = Seq(standardApplicationResponse, privilegedApplicationResponse, ropcApplicationResponse) + + when(underTest.applicationService.fetchAllBySubscription(subscribesTo)).thenReturn(successful(response)) + + val result = await(underTest.queryDispatcher()(queryRequest)) + + status(result) shouldBe SC_OK + + jsonBodyOf(result) shouldBe Json.toJson(response) + } + + "succeed with a 200 (ok) when no applications are found" in new Setup { + when(underTest.applicationService.fetchAllBySubscription(subscribesTo)).thenReturn(successful(Seq())) + + val result = await(underTest.queryDispatcher()(queryRequest)) + + status(result) shouldBe SC_OK + + bodyOf(result) shouldBe "[]" + } + + "fail with a 500 (internal server error) when an exception is thrown" in new Setup { + when(underTest.applicationService.fetchAllBySubscription(subscribesTo)).thenReturn(failed(new RuntimeException("Expected test failure"))) + + val result = await(underTest.queryDispatcher()(queryRequest)) + + status(result) shouldBe SC_INTERNAL_SERVER_ERROR + } + } + + "given a version" should { + val version = "1.0" + val queryRequest = FakeRequest("GET", s"?subscribesTo=$subscribesTo&version=$version") + val apiIdentifier = APIIdentifier(subscribesTo, version) + + "succeed with a 200 (ok) when applications are found" in new Setup { + val standardApplicationResponse = aNewApplicationResponse(access = Standard()) + val privilegedApplicationResponse = aNewApplicationResponse(access = Privileged()) + val ropcApplicationResponse = aNewApplicationResponse(access = Ropc()) + val response = Seq(standardApplicationResponse, privilegedApplicationResponse, ropcApplicationResponse) + + when(underTest.applicationService.fetchAllBySubscription(apiIdentifier)).thenReturn(successful(response)) + + val result = await(underTest.queryDispatcher()(queryRequest)) + + status(result) shouldBe SC_OK + + jsonBodyOf(result) shouldBe Json.toJson(response) + + } + + "succeed with a 200 (ok) when no applications are found" in new Setup { + when(underTest.applicationService.fetchAllBySubscription(apiIdentifier)).thenReturn(successful(Seq())) + + val result = await(underTest.queryDispatcher()(queryRequest)) + + status(result) shouldBe SC_OK + + bodyOf(result) shouldBe "[]" + } + + "fail with a 500 (internal server error) when an exception is thrown" in new Setup { + when(underTest.applicationService.fetchAllBySubscription(apiIdentifier)).thenReturn(failed(new RuntimeException("Expected test failure"))) + + val result = await(underTest.queryDispatcher()(queryRequest)) + + status(result) shouldBe SC_INTERNAL_SERVER_ERROR + } + } + } + + "isSubscribed" should { + + val applicationId: UUID = UUID.randomUUID() + val context: String = "context" + val version: String = "1.0" + val api = APIIdentifier(context, version) + + "succeed with a 200 (ok) when the application is subscribed to a given API version" in new Setup { + + given(mockSubscriptionService.isSubscribed(applicationId, api)).willReturn(true) + + val result = await(underTest.isSubscribed(applicationId, context, version)(request)) + + status(result) shouldBe SC_OK + jsonBodyOf(result) shouldBe Json.toJson(api) + result.header.headers.get(HeaderNames.CACHE_CONTROL) shouldBe Some(s"max-age=$subscriptionTtlInSecs") + } + + "fail with a 404 (not found) when the application is not subscribed to a given API version" in new Setup { + + given(mockSubscriptionService.isSubscribed(applicationId, api)).willReturn(false) + + val result = await(underTest.isSubscribed(applicationId, context, version)(request)) + + status(result) shouldBe SC_NOT_FOUND + verifyErrorResult(result, SC_NOT_FOUND, ErrorCode.SUBSCRIPTION_NOT_FOUND) + result.header.headers.get(HeaderNames.CACHE_CONTROL) shouldBe None + } + + "fail with a 500 (internal server error) when an exception is thrown" in new Setup { + given(mockSubscriptionService.isSubscribed(applicationId, api)).willReturn(failed(new RuntimeException("something went wrong"))) + + val result = await(underTest.isSubscribed(applicationId, context, version)(request)) + + status(result) shouldBe SC_INTERNAL_SERVER_ERROR + result.header.headers.get(HeaderNames.CACHE_CONTROL) shouldBe None + } + } + + "fetchAllSubscriptions by ID" should { + + val applicationId = UUID.randomUUID() + "fail with a 404 (not found) when no application exists for the given application id" in new Setup { + when(mockSubscriptionService.fetchAllSubscriptionsForApplication(mockEq(applicationId))(any[HeaderCarrier])) + .thenReturn(failed(new NotFoundException("application doesn't exist"))) + + val result = await(underTest.fetchAllSubscriptions(applicationId)(request)) + + status(result) shouldBe SC_NOT_FOUND + } + + "succeed with a 200 (ok) when subscriptions are found for the application" in new Setup { + when(mockSubscriptionService.fetchAllSubscriptionsForApplication(mockEq(applicationId))(any[HeaderCarrier])) + .thenReturn(successful(Seq(anAPISubscription()))) + + val result = await(underTest.fetchAllSubscriptions(applicationId)(request)) + + status(result) shouldBe SC_OK + } + + "succeed with a 200 (ok) when no subscriptions are found for the application" in new Setup { + when(mockSubscriptionService.fetchAllSubscriptionsForApplication(mockEq(applicationId))(any[HeaderCarrier])).thenReturn(successful(Seq())) + + val result = await(underTest.fetchAllSubscriptions(applicationId)(request)) + + status(result) shouldBe SC_OK + bodyOf(result) shouldBe "[]" + } + + "fail with a 500 (internal server error) when an exception is thrown" in new Setup { + when(mockSubscriptionService.fetchAllSubscriptionsForApplication(mockEq(applicationId))(any[HeaderCarrier])) + .thenReturn(failed(new RuntimeException("Expected test failure"))) + + val result = await(underTest.fetchAllSubscriptions(applicationId)(request)) + + status(result) shouldBe SC_INTERNAL_SERVER_ERROR + } + + } + + "fetchAllSubscriptions" should { + + "succeed with a 200 (ok) when subscriptions are found for the application" in new Setup { + + val subscriptionData = List(aSubcriptionData(), aSubcriptionData()) + + when(mockSubscriptionService.fetchAllSubscriptions()).thenReturn(successful(subscriptionData)) + + val result = await(underTest.fetchAllAPISubscriptions()(request)) + + status(result) shouldBe SC_OK + } + + "succeed with a 200 (ok) when no subscriptions are found for any application" in new Setup { + when(mockSubscriptionService.fetchAllSubscriptions()).thenReturn(successful(List())) + + val result = await(underTest.fetchAllAPISubscriptions()(request)) + + status(result) shouldBe SC_OK + bodyOf(result) shouldBe "[]" + } + + "fail with a 500 (internal server error) when an exception is thrown" in new Setup { + when(mockSubscriptionService.fetchAllSubscriptions()).thenReturn(failed(new RuntimeException("Expected test failure"))) + + val result = await(underTest.fetchAllAPISubscriptions()(request)) + + status(result) shouldBe SC_INTERNAL_SERVER_ERROR + } + } + + "createSubscriptionForApplication" should { + val applicationId = UUID.randomUUID() + val body = anAPIJson() + + "fail with a 404 (not found) when no application exists for the given application id" in new Setup { + when(underTest.applicationService.fetch(applicationId)).thenReturn(None) + + val result = await(underTest.createSubscriptionForApplication(applicationId)(request.withBody(Json.parse(body)))) + + verifyErrorResult(result, SC_NOT_FOUND, ErrorCode.APPLICATION_NOT_FOUND) + } + + "succeed with a 204 (no content) when a subscription is successfully added to a STANDARD application" in new Setup { + when(underTest.applicationService.fetch(applicationId)).thenReturn(Some(aNewApplicationResponse())) + when(mockSubscriptionService.createSubscriptionForApplication(mockEq(applicationId), any[APIIdentifier])(any[HeaderCarrier])) + .thenReturn(successful(HasSucceeded)) + + val result = await(underTest.createSubscriptionForApplication(applicationId)(request.withBody(Json.parse(body)))) + + status(result) shouldBe SC_NO_CONTENT + } + + "succeed with a 204 (no content) when a subscription is successfully added to a PRIVILEGED or ROPC application and the gatekeeper is logged in" in + new PrivilegedAndRopcSetup { + testWithPrivilegedAndRopcGatekeeperLoggedIn(applicationId, { + when(mockSubscriptionService.createSubscriptionForApplication(mockEq(applicationId), any[APIIdentifier])(any[HeaderCarrier])) + .thenReturn(successful(HasSucceeded)) + + status(underTest.createSubscriptionForApplication(applicationId)(request.withBody(Json.parse(body)))) shouldBe SC_NO_CONTENT + }) + } + + "fail with 401 (Unauthorized) when adding a subscription to a PRIVILEGED or ROPC application and the gatekeeper is not logged in" in + new PrivilegedAndRopcSetup { + testWithPrivilegedAndRopcGatekeeperNotLoggedIn(applicationId, { + when(mockSubscriptionService.createSubscriptionForApplication(mockEq(applicationId), any[APIIdentifier])(any[HeaderCarrier])) + .thenReturn(successful(HasSucceeded)) + + val result = await(underTest.createSubscriptionForApplication(applicationId)(request.withBody(Json.parse(body)))) + + verifyErrorResult(result, SC_UNAUTHORIZED, ErrorCode.UNAUTHORIZED) + }) + } + + "fail with a 422 (unprocessable entity) when unexpected json is provided" in new Setup { + when(underTest.applicationService.fetch(applicationId)).thenReturn(Some(aNewApplicationResponse())) + + val body = """{ "json": "invalid" }""" + + when(mockSubscriptionService.createSubscriptionForApplication(mockEq(applicationId), any[APIIdentifier])(any[HeaderCarrier])) + .thenReturn(successful(HasSucceeded)) + + val result = await(underTest.createSubscriptionForApplication(applicationId)(request.withBody(Json.parse(body)))) + + status(result) shouldBe SC_UNPROCESSABLE_ENTITY + } + + "fail with a 500 (internal server error) when an exception is thrown" in new Setup { + when(underTest.applicationService.fetch(applicationId)).thenReturn(Some(aNewApplicationResponse())) + when(mockSubscriptionService.createSubscriptionForApplication(mockEq(applicationId), any[APIIdentifier])(any[HeaderCarrier])) + .thenReturn(failed(new RuntimeException("Expected test failure"))) + + val result = await(underTest.createSubscriptionForApplication(applicationId)(request.withBody(Json.parse(body)))) + + status(result) shouldBe SC_INTERNAL_SERVER_ERROR + } + + } + + "removeSubscriptionForApplication" should { + val applicationId = UUID.randomUUID() + + "fail with a 404 (not found) when no application exists for the given application id" in new Setup { + when(underTest.applicationService.fetch(applicationId)).thenReturn(None) + + val result = await(underTest.removeSubscriptionForApplication(applicationId, "some-context", "1.0")(request)) + + verifyErrorResult(result, SC_NOT_FOUND, ErrorCode.APPLICATION_NOT_FOUND) + } + + "succeed with a 204 (no content) when a subscription is successfully removed from a STANDARD application" in new Setup { + when(underTest.applicationService.fetch(applicationId)).thenReturn(Some(aNewApplicationResponse())) + when(mockSubscriptionService.removeSubscriptionForApplication(mockEq(applicationId), any[APIIdentifier])(any[HeaderCarrier])) + .thenReturn(successful(HasSucceeded)) + + val result = await(underTest.removeSubscriptionForApplication(applicationId, "some-context", "1.0")(request)) + + status(result) shouldBe SC_NO_CONTENT + } + + "succeed with a 204 (no content) when a subscription is successfully removed from a PRIVILEGED or ROPC application and the gatekeeper is logged in" in + new PrivilegedAndRopcSetup { + testWithPrivilegedAndRopcGatekeeperLoggedIn(applicationId, { + when(mockSubscriptionService.removeSubscriptionForApplication(mockEq(applicationId), any[APIIdentifier])(any[HeaderCarrier])) + .thenReturn(successful(HasSucceeded)) + + status(underTest.removeSubscriptionForApplication(applicationId, "some-context", "1.0")(request)) shouldBe SC_NO_CONTENT + }) + } + + "fail with a 401 (unauthorized) when trying to remove a subscription from a PRIVILEGED or ROPC application and the gatekeeper is not logged in" in + new PrivilegedAndRopcSetup { + testWithPrivilegedAndRopcGatekeeperNotLoggedIn(applicationId, { + when(mockSubscriptionService.removeSubscriptionForApplication(mockEq(applicationId), any[APIIdentifier])(any[HeaderCarrier])) + .thenReturn(successful(HasSucceeded)) + + val result = await(underTest.removeSubscriptionForApplication(applicationId, "some-context", "1.0")(request)) + + verifyErrorResult(result, SC_UNAUTHORIZED, ErrorCode.UNAUTHORIZED) + }) + } + + "fail with a 500 (internal server error) when an exception is thrown" in new Setup { + when(underTest.applicationService.fetch(applicationId)).thenReturn(Some(aNewApplicationResponse())) + when(mockSubscriptionService.removeSubscriptionForApplication(mockEq(applicationId), any[APIIdentifier])(any[HeaderCarrier])) + .thenReturn(failed(new RuntimeException("Expected test failure"))) + + val result = await(underTest.removeSubscriptionForApplication(applicationId, "some-context", "1.0")(request)) + + status(result) shouldBe SC_INTERNAL_SERVER_ERROR + } + + } + + "verifyUplift" should { + + "verify uplift successfully" in new Setup { + val verificationCode = "aVerificationCode" + + when(underTest.applicationService.verifyUplift(mockEq(verificationCode))(any[HeaderCarrier])).thenReturn(successful(UpliftVerified)) + + val result = await(underTest.verifyUplift(verificationCode)(request)) + status(result) shouldBe SC_NO_CONTENT + } + + "verify uplift failed" in new Setup { + val verificationCode = "aVerificationCode" + + when(underTest.applicationService.verifyUplift(mockEq(verificationCode))(any[HeaderCarrier])) + .thenReturn(failed(InvalidUpliftVerificationCode(verificationCode))) + + val result = await(underTest.verifyUplift(verificationCode)(request)) + status(result) shouldBe SC_BAD_REQUEST + } + } + + "requestUplift" should { + val applicationId = UUID.randomUUID() + val requestedByEmailAddress = "big.boss@example.com" + val requestedName = "Application Name" + val upliftRequest = UpliftRequest(requestedName, requestedByEmailAddress) + + "return updated application if successful" in new Setup { + val resultUpliftedApplication = aNewApplicationResponse().copy(state = pendingGatekeeperApprovalState(requestedByEmailAddress)) + + when(underTest.applicationService.requestUplift(mockEq(applicationId), mockEq(requestedName), mockEq(requestedByEmailAddress))(any[HeaderCarrier])) + .thenReturn(UpliftRequested) + + val result = await(underTest.requestUplift(applicationId)(request + .withBody(Json.toJson(upliftRequest)))) + + status(result) shouldBe SC_NO_CONTENT + } + + "return 404 if the application doesn't exist" in new Setup { + + when(underTest.applicationService.requestUplift(mockEq(applicationId), mockEq(requestedName), mockEq(requestedByEmailAddress))(any[HeaderCarrier])) + .thenReturn(failed(new NotFoundException("application doesn't exist"))) + + val result = await(underTest.requestUplift(applicationId)(request.withBody(Json.toJson(upliftRequest)))) + + verifyErrorResult(result, SC_NOT_FOUND, ErrorCode.APPLICATION_NOT_FOUND) + } + + "fail with a 409 (conflict) when an application already exists for that application name" in new Setup { + + when(underTest.applicationService.requestUplift(mockEq(applicationId), mockEq(requestedName), mockEq(requestedByEmailAddress))(any[HeaderCarrier])) + .thenReturn(failed(ApplicationAlreadyExists("applicationName"))) + + val result = await(underTest.requestUplift(applicationId)(request.withBody(Json.toJson(upliftRequest)))) + + verifyErrorResult(result, SC_CONFLICT, ErrorCode.APPLICATION_ALREADY_EXISTS) + } + + "fail with 412 (Precondition Failed) when the application is not in the TESTING state" in new Setup { + + when(underTest.applicationService.requestUplift(mockEq(applicationId), mockEq(requestedName), mockEq(requestedByEmailAddress))(any[HeaderCarrier])) + .thenReturn(failed(new InvalidStateTransition(State.PRODUCTION, State.PENDING_GATEKEEPER_APPROVAL, State.TESTING))) + + val result = await(underTest.requestUplift(applicationId)(request.withBody(Json.toJson(upliftRequest)))) + + verifyErrorResult(result, SC_PRECONDITION_FAILED, ErrorCode.INVALID_STATE_TRANSITION) + } + + "fail with a 500 (internal server error) when an exception is thrown" in new Setup { + when(underTest.applicationService.requestUplift(mockEq(applicationId), mockEq(requestedName), mockEq(requestedByEmailAddress))(any[HeaderCarrier])) + .thenReturn(failed(new RuntimeException("Expected test failure"))) + + val result = await(underTest.requestUplift(applicationId)(request.withBody(Json.toJson(upliftRequest)))) + + verifyErrorResult(result, SC_INTERNAL_SERVER_ERROR, ErrorCode.UNKNOWN_ERROR) + } + } + + "update rate limit tier" should { + + val uuid: UUID = UUID.randomUUID() + val invalidUpdateRateLimitTierJson = Json.parse("""{ "foo" : "bar" }""") + val validUpdateRateLimitTierJson = Json.parse("""{ "rateLimitTier" : "silver" }""") + + "fail with a 422 (unprocessable entity) when request json is invalid" in new Setup { + + when(underTest.authConnector.authorized(mockEq(APIGatekeeper))(any[HeaderCarrier])).thenReturn(true) + + val result = await(underTest.updateRateLimitTier(uuid)(request.withBody(invalidUpdateRateLimitTierJson))) + + status(result) shouldBe SC_UNPROCESSABLE_ENTITY + verify(underTest.applicationService, never).updateRateLimitTier(mockEq(uuid), mockEq(SILVER))(any[HeaderCarrier]) + } + + "fail with a 422 (unprocessable entity) when request json is valid but rate limit tier is an invalid value" in new Setup { + + when(underTest.authConnector.authorized(mockEq(APIGatekeeper))(any[HeaderCarrier])).thenReturn(true) + + val result = await(underTest.updateRateLimitTier(uuid)(request.withBody(Json.parse("""{ "rateLimitTier" : "multicoloured" }""")))) + status(result) shouldBe SC_UNPROCESSABLE_ENTITY + jsonBodyOf(result) shouldBe Json.toJson(Json.parse( + """ + { + "code": "INVALID_REQUEST_PAYLOAD", + "message": "'multicoloured' is an invalid rate limit tier" + }""" + )) + } + + "succeed with a 204 (no content) when rate limit tier is successfully added to application" in new Setup { + + when(underTest.authConnector.authorized(mockEq(APIGatekeeper))(any[HeaderCarrier])).thenReturn(true) + + when(underTest.applicationService.updateRateLimitTier(mockEq(uuid), mockEq(SILVER))(any[HeaderCarrier])).thenReturn(mock[ApplicationData]) + + val result = await(underTest.updateRateLimitTier(uuid)(request.withBody(validUpdateRateLimitTierJson))) + + status(result) shouldBe SC_NO_CONTENT + verify(underTest.applicationService).updateRateLimitTier(mockEq(uuid), mockEq(SILVER))(any[HeaderCarrier]) + } + + "fail with a 500 (internal server error) when an exception is thrown" in new Setup { + + when(underTest.authConnector.authorized(mockEq(APIGatekeeper))(any[HeaderCarrier])).thenReturn(true) + + when(underTest.applicationService.updateRateLimitTier(mockEq(uuid), mockEq(SILVER))(any[HeaderCarrier])) + .thenReturn(failed(new RuntimeException("Expected test exception"))) + + val result = await(underTest.updateRateLimitTier(uuid)(request.withBody(validUpdateRateLimitTierJson))) + + status(result) shouldBe SC_INTERNAL_SERVER_ERROR + } + + "fail with a 401 (Unauthorized) when the request is done without a gatekeeper token" in new Setup { + + when(underTest.authConnector.authorized(mockEq(APIGatekeeper))(any[HeaderCarrier])).thenReturn(false) + + when(underTest.applicationService.updateRateLimitTier(mockEq(uuid), mockEq(SILVER))(any[HeaderCarrier])).thenReturn(mock[ApplicationData]) + + val result = await(underTest.updateRateLimitTier(uuid)(request.withBody(validUpdateRateLimitTierJson))) + + verifyErrorResult(result, SC_UNAUTHORIZED, ErrorCode.UNAUTHORIZED) + } + } + + private def anAPI() = { + new APIIdentifier("some-context", "1.0") + } + + private def anAPISubscription() = { + new APISubscription("name", "service-name", "some-context", Seq(VersionSubscription(APIVersion("1.0", APIStatus.STABLE, None), subscribed = true)), None) + } + + private def aSubcriptionData() = { + SubscriptionData(anAPI(), Set(UUID.randomUUID(), UUID.randomUUID())) + } + + private def anAPIJson() = { + """{ "context" : "some-context", "version" : "1.0" }""" + } + + private def aNewApplicationResponse(access: Access = standardAccess) = { + new ApplicationResponse( + UUID.randomUUID(), + "clientId", + "My Application", + "PRODUCTION", + Some("Description"), + collaborators, + DateTimeUtils.now, + standardAccess.redirectUris, + standardAccess.termsAndConditionsUrl, + standardAccess.privacyPolicyUrl, + access) + } + + private def anUpdateApplicationRequest(access: Access) = UpdateApplicationRequest("My Application", access, Some("Description")) + + private def aCreateApplicationRequest(access: Access, environment: Environment) = CreateApplicationRequest("My Application", access, Some("Description"), + environment, Set(Collaborator("admin@example.com", ADMINISTRATOR), Collaborator("dev@example.com", ADMINISTRATOR))) +} diff --git a/test/unit/controllers/AuthorisationWrapperSpec.scala b/test/unit/controllers/AuthorisationWrapperSpec.scala new file mode 100644 index 000000000..f4cc5128e --- /dev/null +++ b/test/unit/controllers/AuthorisationWrapperSpec.scala @@ -0,0 +1,207 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 controllers + +import java.util.UUID + +import org.apache.http.HttpStatus.{SC_NOT_FOUND, SC_OK, SC_UNAUTHORIZED} +import org.mockito.Matchers._ +import org.mockito.Mockito._ +import org.scalatest.mockito.MockitoSugar +import play.api.libs.json.{JsValue, Json} +import play.api.mvc.BodyParsers +import play.api.test.FakeRequest +import uk.gov.hmrc.connector.AuthConnector +import uk.gov.hmrc.controllers.ErrorCode.{APPLICATION_NOT_FOUND, UNAUTHORIZED} +import uk.gov.hmrc.controllers.{AuthorisationWrapper, JsErrorResponse} +import uk.gov.hmrc.models.AccessType.{PRIVILEGED, ROPC} +import uk.gov.hmrc.models.AuthRole.APIGatekeeper +import uk.gov.hmrc.models.JsonFormatters._ +import uk.gov.hmrc.models._ +import uk.gov.hmrc.play.test.{UnitSpec, WithFakeApplication} +import uk.gov.hmrc.services.ApplicationService +import uk.gov.hmrc.time.DateTimeUtils + +import scala.concurrent.Future._ +import uk.gov.hmrc.http.HeaderCarrier + +class AuthorisationWrapperSpec extends UnitSpec with MockitoSugar with WithFakeApplication { + + implicit lazy val materializer = fakeApplication.materializer + + trait Setup { + val underTest = new AuthorisationWrapper { + implicit val headerCarrier: HeaderCarrier = HeaderCarrier() + override val authConnector: AuthConnector = mock[AuthConnector] + override val applicationService: ApplicationService = mock[ApplicationService] + } + + def mockAuthConnectorToReturn(boolean: Boolean) = + when(underTest.authConnector.authorized(any[AuthRole])(any[HeaderCarrier])) + .thenReturn(successful(boolean)) + + def mockFetchApplicationToReturn(id: UUID, application: Option[ApplicationResponse]) = + when(underTest.applicationService.fetch(id)).thenReturn(application) + } + + "Authenticate for Access Type and Role" should { + val ropcRequest = postRequestWithAccess(Ropc()) + val privilegedRequest = postRequestWithAccess(Privileged()) + val standardRequest = postRequestWithAccess(Standard()) + + "accept the request when access type in the payload is PRIVILEGED and gatekeeper is logged in" in new Setup { + + mockAuthConnectorToReturn(true) + + val result = await(underTest.requiresRoleFor(APIGatekeeper, PRIVILEGED).async(BodyParsers.parse.json)(_ => + Default.Ok(""))(privilegedRequest) + ) + + status(result) shouldBe SC_OK + } + + "accept the request when access type in the payload is ROPC and gatekeeper is logged in" in new Setup { + mockAuthConnectorToReturn(true) + status(underTest.requiresRoleFor(APIGatekeeper, ROPC).async(BodyParsers.parse.json)(_ => Default.Ok(""))(ropcRequest)) shouldBe SC_OK + } + + "skip gatekeeper authentication for payload with STANDARD applications" in new Setup { + + val result = await(underTest.requiresRoleFor(APIGatekeeper, PRIVILEGED).async(BodyParsers.parse.json)(_ => + Default.Ok(""))(standardRequest) + ) + + status(result) shouldBe SC_OK + verifyZeroInteractions(underTest.authConnector) + } + + "return a 401 (Unauthorised) response when access type in the payload is PRIVILEGED and gatekeeper is not logged in" in new Setup { + + mockAuthConnectorToReturn(false) + + val result = await(underTest.requiresRoleFor(APIGatekeeper, PRIVILEGED).async(BodyParsers.parse.json)(_ => + Default.Ok(""))(privilegedRequest) + ) + + status(result) shouldBe SC_UNAUTHORIZED + jsonBodyOf(result) shouldBe JsErrorResponse(UNAUTHORIZED, "Action requires authority: 'api:gatekeeper'") + } + + "return a 401 (Unauthorised) response when access type in the payload is ROPC and gatekeeper is not logged in" in new Setup { + mockAuthConnectorToReturn(false) + + val result = await(underTest.requiresRoleFor(APIGatekeeper, ROPC).async(BodyParsers.parse.json)(_ => Default.Ok(""))(ropcRequest)) + + status(result) shouldBe SC_UNAUTHORIZED + jsonBodyOf(result) shouldBe JsErrorResponse(UNAUTHORIZED, "Action requires authority: 'api:gatekeeper'") + } + } + + "Authenticate for Access Type, Role and Application ID" should { + val applicationId = UUID.randomUUID + val ropcApplication = application(Ropc()) + val privilegedApplication = application(Privileged()) + val standardApplication = application(Standard()) + + "accept the request when access type of the application is PRIVILEGED and gatekeeper is logged in" in new Setup { + + mockFetchApplicationToReturn(applicationId, Some(privilegedApplication)) + + mockAuthConnectorToReturn(true) + + val result = await(underTest.requiresRoleFor(applicationId, APIGatekeeper, PRIVILEGED).async(_ => Default.Ok(""))(FakeRequest())) + + status(result) shouldBe SC_OK + } + + "accept the request when access type of the application is ROPC and gatekeeper is logged in" in new Setup { + mockFetchApplicationToReturn(applicationId, Some(ropcApplication)) + mockAuthConnectorToReturn(true) + status(underTest.requiresRoleFor(applicationId, APIGatekeeper, ROPC).async(_ => Default.Ok(""))(FakeRequest())) shouldBe SC_OK + } + + "skip gatekeeper authentication for STANDARD applications" in new Setup { + + mockFetchApplicationToReturn(applicationId, Some(standardApplication)) + + val result = await(underTest.requiresRoleFor(applicationId, APIGatekeeper, PRIVILEGED).async(_ => Default.Ok(""))(FakeRequest())) + + + status(result) shouldBe SC_OK + verifyZeroInteractions(underTest.authConnector) + } + + "return a 401 (Unauthorised) response when access type of the application is PRIVILEGED and gatekeeper is not logged in" in new Setup { + + mockFetchApplicationToReturn(applicationId, Some(privilegedApplication)) + + mockAuthConnectorToReturn(false) + + val result = await(underTest.requiresRoleFor(applicationId, APIGatekeeper, PRIVILEGED).async(_ => Default.Ok(""))(FakeRequest())) + + status(result) shouldBe SC_UNAUTHORIZED + jsonBodyOf(result) shouldBe JsErrorResponse(UNAUTHORIZED, "Action requires authority: 'api:gatekeeper'") + } + + "return a 401 (Unauthorised) response when access type of the application is ROPC and gatekeeper is not logged in" in new Setup { + mockFetchApplicationToReturn(applicationId, Some(ropcApplication)) + mockAuthConnectorToReturn(false) + + val result = await(underTest.requiresRoleFor(applicationId, APIGatekeeper, ROPC).async(_ => Default.Ok(""))(FakeRequest())) + + status(result) shouldBe SC_UNAUTHORIZED + jsonBodyOf(result) shouldBe JsErrorResponse(UNAUTHORIZED, "Action requires authority: 'api:gatekeeper'") + } + + "return a 404 (Not Found) when the application doesn't exist" in new Setup { + + mockFetchApplicationToReturn(applicationId, None) + + val result = await(underTest.requiresRoleFor(applicationId, APIGatekeeper, PRIVILEGED).async(_ => Default.Ok(""))(FakeRequest())) + + status(result) shouldBe SC_NOT_FOUND + jsonBodyOf(result) shouldBe JsErrorResponse(APPLICATION_NOT_FOUND, s"application $applicationId doesn't exist") + } + } + + "Authenticated by Role" should { + + "accept the request when the gatekeeper is logged in" in new Setup { + + mockAuthConnectorToReturn(true) + + val result = await(underTest.requiresRole(APIGatekeeper).async(_ => Default.Ok(""))(FakeRequest())) + + status(result) shouldBe SC_OK + } + + "return a 401 (Unauthorised) response when the gatekeeper is not logged in" in new Setup { + + mockAuthConnectorToReturn(false) + + val result = await(underTest.requiresRole(APIGatekeeper).async(_ => Default.Ok(""))(FakeRequest())) + + status(result) shouldBe SC_UNAUTHORIZED + jsonBodyOf(result) shouldBe JsErrorResponse(UNAUTHORIZED, "Action requires authority: 'api:gatekeeper'") + } + } + + private def postRequestWithAccess(access: Access) = FakeRequest("POST", "/").withBody(Json.obj("access" -> access).as[JsValue]) + + private def application(access: Access) = ApplicationResponse(UUID.randomUUID, "clientId", "name", "PRODUCTION", None, Set(), DateTimeUtils.now, access = access) + +} \ No newline at end of file diff --git a/test/unit/controllers/GatekeeperControllerSpec.scala b/test/unit/controllers/GatekeeperControllerSpec.scala new file mode 100644 index 000000000..6bfa9dd90 --- /dev/null +++ b/test/unit/controllers/GatekeeperControllerSpec.scala @@ -0,0 +1,357 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 controllers + +import java.util.UUID + +import org.apache.http.HttpStatus._ +import org.joda.time.DateTime +import org.mockito.ArgumentCaptor +import org.mockito.Matchers.{eq => eqTo, any, anyString} +import org.mockito.Mockito._ +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.mockito.MockitoSugar +import play.api.Logger +import play.api.libs.json.Json +import play.api.mvc.{RequestHeader, Result} +import play.api.test.FakeRequest +import uk.gov.hmrc.connector.AuthConnector +import uk.gov.hmrc.controllers.ErrorCode._ +import uk.gov.hmrc.controllers.{ErrorCode, _} +import uk.gov.hmrc.models.ActorType._ +import uk.gov.hmrc.models.JsonFormatters._ +import uk.gov.hmrc.models.State._ +import uk.gov.hmrc.models._ +import uk.gov.hmrc.play.test.{UnitSpec, WithFakeApplication} +import uk.gov.hmrc.services.{ApplicationService, GatekeeperService} +import uk.gov.hmrc.time.DateTimeUtils +import common.uk.gov.hmrc.common.LogSuppressing +import common.uk.gov.hmrc.testutils.ApplicationStateUtil + +import scala.concurrent.Future.{failed, successful} +import uk.gov.hmrc.http.{ HeaderCarrier, NotFoundException } + +class GatekeeperControllerSpec extends UnitSpec with ScalaFutures with MockitoSugar with WithFakeApplication + with ApplicationStateUtil with LogSuppressing { + + val authTokenHeader = "authorization" -> "authorizationToken" + implicit lazy val materializer = fakeApplication.materializer + implicit lazy val request = FakeRequest() + + trait Setup { + val mockGatekeeperService = mock[GatekeeperService] + val mockAuthConnector = mock[AuthConnector] + val mockApplicationService = mock[ApplicationService] + implicit val headers = HeaderCarrier() + + val underTest = new GatekeeperController(mockAuthConnector, mockApplicationService, mockGatekeeperService) { + override implicit def hc(implicit request: RequestHeader): HeaderCarrier = headers + } + + when(mockAuthConnector.authorized(any[AuthRole])(any[HeaderCarrier])).thenReturn(successful(true)) + } + + def verifyUnauthorized(result: Result): Unit = { + status(result) shouldBe 401 + jsonBodyOf(result) shouldBe Json.obj( + "code" -> "UNAUTHORIZED", "message" -> "Action requires authority: 'api:gatekeeper'" + ) + } + + "Fetch apps" should { + "return unauthorised when the user is not authorised" in new Setup { + when(mockAuthConnector.authorized(any[AuthRole])(any[HeaderCarrier])).thenReturn(successful(false)) + + val result = await(underTest.fetchAppsForGatekeeper(request)) + + verifyZeroInteractions(mockGatekeeperService) + verifyUnauthorized(result) + } + + "return apps" in new Setup { + val expected = Seq(anAppResult(), anAppResult(state = productionState("user1"))) + when(mockGatekeeperService.fetchNonTestingAppsWithSubmittedDate()).thenReturn(successful(expected)) + + val result = await(underTest.fetchAppsForGatekeeper(request)) + + jsonBodyOf(result) shouldBe Json.toJson(expected) + } + } + + "Fetch app by id" should { + val appId = UUID.randomUUID() + + "return unauthorised when the user is not authorised" in new Setup { + when(mockAuthConnector.authorized(any[AuthRole])(any[HeaderCarrier])).thenReturn(successful(false)) + + val result = await(underTest.fetchAppById(appId)(request)) + + verifyZeroInteractions(mockGatekeeperService) + verifyUnauthorized(result) + } + + "return app with history" in new Setup { + val expected = ApplicationWithHistory(anAppResponse(appId), Seq(aHistory(appId), aHistory(appId, PRODUCTION))) + when(mockGatekeeperService.fetchAppWithHistory(appId)).thenReturn(successful(expected)) + + val result = await(underTest.fetchAppById(appId)(request)) + + status(result) shouldBe 200 + jsonBodyOf(result) shouldBe Json.toJson(expected) + } + + "return 404 if the application doesn't exist" in new Setup { + when(mockGatekeeperService.fetchAppWithHistory(appId)) + .thenReturn(failed(new NotFoundException("application doesn't exist"))) + + val result = await(underTest.fetchAppById(appId)(request)) + + verifyErrorResult(result, 404, ErrorCode.APPLICATION_NOT_FOUND) + } + } + + "approveUplift" should { + val applicationId = UUID.randomUUID() + val gatekeeperUserId = "big.boss.gatekeeper" + val approveUpliftRequest = ApproveUpliftRequest(gatekeeperUserId) + + "return unauthorised when the user is not authorised" in new Setup { + when(mockAuthConnector.authorized(any[AuthRole])(any[HeaderCarrier])).thenReturn(successful(false)) + + val result = await(underTest.approveUplift(applicationId)(request.withBody(Json.toJson(approveUpliftRequest)).withHeaders(authTokenHeader))) + + verifyZeroInteractions(mockGatekeeperService) + verifyUnauthorized(result) + } + + "successfully approve uplift when user is authorised" in new Setup { + val hcArgCaptor = ArgumentCaptor.forClass(classOf[HeaderCarrier]) + when(mockAuthConnector.authorized(any[AuthRole])(hcArgCaptor.capture())).thenReturn(successful(true)) + + when(mockGatekeeperService.approveUplift(applicationId, gatekeeperUserId)).thenReturn(UpliftApproved) + + val result = await(underTest.approveUplift(applicationId)(request.withBody(Json.toJson(approveUpliftRequest)).withHeaders(authTokenHeader))) + + hcArgCaptor.getValue.authorization.get.value shouldBe "authorizationToken" + status(result) shouldBe 204 + } + + "return 404 if the application doesn't exist" in new Setup { + withSuppressedLoggingFrom(Logger, "application doesn't exist") { suppressedLogs => + when(mockAuthConnector.authorized(any[AuthRole])(any[HeaderCarrier])).thenReturn(successful(true)) + + when(mockGatekeeperService.approveUplift(applicationId, gatekeeperUserId)) + .thenReturn(failed(new NotFoundException("application doesn't exist"))) + + val result = await(underTest.approveUplift(applicationId)(request.withBody(Json.toJson(approveUpliftRequest)))) + + verifyErrorResult(result, 404, ErrorCode.APPLICATION_NOT_FOUND) + } + } + + "fail with 412 (Precondition Failed) when the application is not in the PENDING_GATEKEEPER_APPROVAL state" in new Setup { + when(mockAuthConnector.authorized(any[AuthRole])(any[HeaderCarrier])).thenReturn(successful(true)) + + when(mockGatekeeperService.approveUplift(applicationId, gatekeeperUserId)) + .thenReturn(failed(new InvalidStateTransition(TESTING, PENDING_REQUESTER_VERIFICATION, PENDING_GATEKEEPER_APPROVAL))) + + val result = await(underTest.approveUplift(applicationId)(request.withBody(Json.toJson(approveUpliftRequest)))) + + verifyErrorResult(result, 412, ErrorCode.INVALID_STATE_TRANSITION) + } + + "fail with a 500 (internal server error) when an exception is thrown" in new Setup { + withSuppressedLoggingFrom(Logger, "expected test failure") { suppressedLogs => + when(mockAuthConnector.authorized(any[AuthRole])(any[HeaderCarrier])).thenReturn(successful(true)) + + when(mockGatekeeperService.approveUplift(applicationId, gatekeeperUserId)) + .thenReturn(failed(new RuntimeException("Expected test failure"))) + + val result = await(underTest.approveUplift(applicationId)(request.withBody(Json.toJson(approveUpliftRequest)))) + + verifyErrorResult(result, 500, ErrorCode.UNKNOWN_ERROR) + } + } + } + + "reject Uplift" should { + val applicationId = UUID.randomUUID() + val gatekeeperUserId = "big.boss.gatekeeper" + val rejectUpliftRequest = RejectUpliftRequest(gatekeeperUserId, "Test error") + val testReq = request.withBody(Json.toJson(rejectUpliftRequest)).withHeaders(authTokenHeader) + "return unauthorised when the user is not authorised" in new Setup { + when(mockAuthConnector.authorized(any[AuthRole])(any[HeaderCarrier])).thenReturn(successful(false)) + + val result = await(underTest.rejectUplift(applicationId)(testReq)) + + verifyZeroInteractions(mockGatekeeperService) + verifyUnauthorized(result) + } + + "successfully reject uplift when user is authorised" in new Setup { + val hcArgCaptor = ArgumentCaptor.forClass(classOf[HeaderCarrier]) + when(mockAuthConnector.authorized(any[AuthRole])(hcArgCaptor.capture())).thenReturn(successful(true)) + + when(mockGatekeeperService.rejectUplift(applicationId, rejectUpliftRequest)).thenReturn(UpliftRejected) + + val result = await(underTest.rejectUplift(applicationId)(testReq)) + + hcArgCaptor.getValue.authorization.get.value shouldBe "authorizationToken" + status(result) shouldBe 204 + } + + "return 404 if the application doesn't exist" in new Setup { + when(mockAuthConnector.authorized(any[AuthRole])(any[HeaderCarrier])).thenReturn(successful(true)) + + when(mockGatekeeperService.rejectUplift(applicationId, rejectUpliftRequest)) + .thenReturn(failed(new NotFoundException("application doesn't exist"))) + + val result = await(underTest.rejectUplift(applicationId)(testReq)) + + verifyErrorResult(result, 404, ErrorCode.APPLICATION_NOT_FOUND) + } + + "fail with 412 (Precondition Failed) when the application is not in the PENDING_GATEKEEPER_APPROVAL state" in new Setup { + when(mockAuthConnector.authorized(any[AuthRole])(any[HeaderCarrier])).thenReturn(successful(true)) + + when(mockGatekeeperService.rejectUplift(applicationId, rejectUpliftRequest)) + .thenReturn(failed(new InvalidStateTransition(PENDING_REQUESTER_VERIFICATION, TESTING, PENDING_GATEKEEPER_APPROVAL))) + + val result = await(underTest.rejectUplift(applicationId)(testReq)) + + verifyErrorResult(result, 412, ErrorCode.INVALID_STATE_TRANSITION) + } + + "fail with a 500 (internal server error) when an exception is thrown" in new Setup { + withSuppressedLoggingFrom(Logger, "Expected test failure") { suppressedLogs => + when(mockAuthConnector.authorized(any[AuthRole])(any[HeaderCarrier])).thenReturn(successful(true)) + + when(mockGatekeeperService.rejectUplift(applicationId, rejectUpliftRequest)) + .thenReturn(failed(new RuntimeException("Expected test failure"))) + + val result = await(underTest.rejectUplift(applicationId)(testReq)) + + verifyErrorResult(result, 500, ErrorCode.UNKNOWN_ERROR) + } + } + } + + "resendVerification" should { + val applicationId = UUID.randomUUID() + val gatekeeperUserId = "big.boss.gatekeeper" + val resendVerificationRequest = ResendVerificationRequest(gatekeeperUserId) + + "return unauthorised when the user is not authorised" in new Setup { + when(mockAuthConnector.authorized(any[AuthRole])(any[HeaderCarrier])).thenReturn(successful(false)) + + val result = await(underTest.resendVerification(applicationId)(request.withBody(Json.toJson(resendVerificationRequest)).withHeaders(authTokenHeader))) + + verifyZeroInteractions(mockGatekeeperService) + verifyUnauthorized(result) + } + + "successfully resend verification when user is authorised" in new Setup { + val hcArgCaptor = ArgumentCaptor.forClass(classOf[HeaderCarrier]) + when(mockAuthConnector.authorized(any[AuthRole])(hcArgCaptor.capture())).thenReturn(successful(true)) + + when(mockGatekeeperService.resendVerification(applicationId, gatekeeperUserId)).thenReturn(VerificationResent) + + val result = await(underTest.resendVerification(applicationId)(request.withBody(Json.toJson(resendVerificationRequest)).withHeaders(authTokenHeader))) + + hcArgCaptor.getValue.authorization.get.value shouldBe "authorizationToken" + status(result) shouldBe 204 + } + + "return 404 if the application doesn't exist" in new Setup { + when(mockAuthConnector.authorized(any[AuthRole])(any[HeaderCarrier])).thenReturn(successful(true)) + + when(mockGatekeeperService.resendVerification(applicationId, gatekeeperUserId)) + .thenReturn(failed(new NotFoundException("application doesn't exist"))) + + val result = await(underTest.resendVerification(applicationId)(request.withBody(Json.toJson(resendVerificationRequest)))) + + verifyErrorResult(result, 404, ErrorCode.APPLICATION_NOT_FOUND) + } + + "fail with 412 (Precondition Failed) when the application is not in the PENDING_REQUESTER_VERIFICATION state" in new Setup { + when(mockAuthConnector.authorized(any[AuthRole])(any[HeaderCarrier])).thenReturn(successful(true)) + + when(mockGatekeeperService.resendVerification(applicationId, gatekeeperUserId)) + .thenReturn(failed(new InvalidStateTransition(PENDING_REQUESTER_VERIFICATION, PENDING_REQUESTER_VERIFICATION, PENDING_REQUESTER_VERIFICATION))) + + val result = await(underTest.resendVerification(applicationId)(request.withBody(Json.toJson(resendVerificationRequest)))) + + verifyErrorResult(result, 412, ErrorCode.INVALID_STATE_TRANSITION) + } + + "fail with a 500 (internal server error) when an exception is thrown" in new Setup { + when(mockAuthConnector.authorized(any[AuthRole])(any[HeaderCarrier])).thenReturn(successful(true)) + + when(mockGatekeeperService.resendVerification(applicationId, gatekeeperUserId)) + .thenReturn(failed(new RuntimeException("Expected test failure"))) + + val result = await(underTest.resendVerification(applicationId)(request.withBody(Json.toJson(resendVerificationRequest)))) + + verifyErrorResult(result, 500, ErrorCode.UNKNOWN_ERROR) + } + } + + "deleteApplication" should { + val applicationId = UUID.randomUUID() + val gatekeeperUserId = "big.boss.gatekeeper" + val requestedByEmailAddress = "admin@example.com" + val deleteRequest = DeleteApplicationRequest(gatekeeperUserId, requestedByEmailAddress) + + "succeed with a 204 (no content) when the application is successfully deleted" in new Setup { + when(mockGatekeeperService.deleteApplication(any(), any())(any[HeaderCarrier]())).thenReturn(successful(Deleted)) + + val result = await(underTest.deleteApplication(applicationId)(request.withBody(Json.toJson(deleteRequest)))) + + status(result) shouldBe SC_NO_CONTENT + verify(mockGatekeeperService).deleteApplication(applicationId, deleteRequest) + } + + "fail with a 500 (internal server error) when an exception is thrown" in new Setup { + when(mockGatekeeperService.deleteApplication(any(), any())(any[HeaderCarrier]())).thenReturn(failed(new RuntimeException("Expected test failure"))) + + val result = await(underTest.deleteApplication(applicationId)(request.withBody(Json.toJson(deleteRequest)))) + + status(result) shouldBe SC_INTERNAL_SERVER_ERROR + verify(mockGatekeeperService).deleteApplication(applicationId, deleteRequest) + } + + } + + private def aHistory(appId: UUID, state: State = PENDING_GATEKEEPER_APPROVAL) = { + StateHistoryResponse(appId, state, Actor("anEmail", COLLABORATOR), None, DateTimeUtils.now) + } + + private def anAppResult(id: UUID = UUID.randomUUID(), + submittedOn: DateTime = DateTimeUtils.now, + state: ApplicationState = testingState()) = { + ApplicationWithUpliftRequest(id, "app 1", submittedOn, state.name) + } + + private def verifyErrorResult(result: Result, statusCode: Int, errorCode: ErrorCode): Unit = { + status(result) shouldBe statusCode + (jsonBodyOf(result) \ "code").as[String] shouldBe errorCode.toString + } + + private def anAppResponse(id: UUID = UUID.randomUUID()) = { + new ApplicationResponse(id, "clientId", "My Application", "PRODUCTION", None, Set.empty, DateTimeUtils.now) + } +} \ No newline at end of file diff --git a/test/unit/models/ApplicationSpec.scala b/test/unit/models/ApplicationSpec.scala new file mode 100644 index 000000000..c866cedea --- /dev/null +++ b/test/unit/models/ApplicationSpec.scala @@ -0,0 +1,119 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 models + +import java.util.UUID + +import uk.gov.hmrc.models.State.{PRODUCTION, TESTING} +import uk.gov.hmrc.models.Environment.Environment +import uk.gov.hmrc.models._ +import uk.gov.hmrc.play.test.UnitSpec +import common.uk.gov.hmrc.testutils.ApplicationStateUtil + +class ApplicationSpec extends UnitSpec with ApplicationStateUtil { + + "RateLimitTier" should { + + "have all rate limit tiers" in { + + import RateLimitTier._ + RateLimitTier.values.toSet shouldBe Set(PLATINUM, GOLD, SILVER, BRONZE) + } + } + + "WSO2API" should { + + "construct a WSO2 name from the context and version" in { + + WSO2API.create(APIIdentifier("some/context", "1.0")) shouldBe WSO2API("some--context--1.0", "1.0") + + } + + } + + "API" should { + + "deconstruct the context from a WSO2 api name" in { + + APIIdentifier.create(WSO2API("some--context--1.0", "1.0")) shouldBe APIIdentifier("some/context", "1.0") + + } + + } + + "Application with Uplift request" should { + val app = ApplicationData(UUID.randomUUID(), "MyApp", "myapp", + Set.empty, None, + "a", "a", "a", + ApplicationTokens(EnvironmentToken("cid", "cs", "at"), EnvironmentToken("cid", "cs", "at")), productionState("user1"), + Standard(Seq.empty, None, None)) + val history = StateHistory(app.id, State.PENDING_GATEKEEPER_APPROVAL, Actor("1", ActorType.COLLABORATOR)) + + "create object" in { + val result = ApplicationWithUpliftRequest.create(app, history) + + result shouldBe ApplicationWithUpliftRequest(app.id, app.name, history.changedAt, State.PRODUCTION) + } + + "Fail to create when invalid state is sent" in { + val ex = intercept[InconsistentDataState] { + ApplicationWithUpliftRequest.create(app, history.copy(state = State.PENDING_REQUESTER_VERIFICATION)) + } + + ex.getMessage shouldBe "cannot create with invalid state: PENDING_REQUESTER_VERIFICATION" + } + } + + "Application from CreateApplicationRequest" should { + def createRequest(access: Access, environment: Environment) = { + ApplicationData.create( + application = CreateApplicationRequest( + name = "an application", + access = access, + environment = environment, + collaborators = Set(Collaborator("jim@example.com", Role.ADMINISTRATOR))), + wso2Username = "wso2Username", + wso2Password = "wso2Password", + wso2ApplicationName = "wso2ApplicationName", + tokens = ApplicationTokens( + production = EnvironmentToken("p-clientId", "p-clientSecret", "p-accessToken"), + sandbox = EnvironmentToken("s-clientId", "s-clientSecret", "s-accessToken"))) + } + + "be automatically uplifted to PRODUCTION state when the app is for the sandbox environment" in { + val actual = createRequest(Standard(), Environment.SANDBOX) + actual.state.name shouldBe PRODUCTION + } + + "defer to STANDARD accessType to determine application state when the app is for the production environment" in { + val actual = createRequest(Standard(), Environment.PRODUCTION) + actual.state.name shouldBe TESTING + } + + "defer to PRIVILEGED accessType to determine application state when the app is for the production environment" in { + val actual = createRequest(Privileged(), Environment.PRODUCTION) + actual.state.name shouldBe PRODUCTION + } + + "defer to ROPC accessType to determine application state when the app is for the production environment" in { + val actual = createRequest(Ropc(), Environment.PRODUCTION) + actual.state.name shouldBe PRODUCTION + } + } + + +} diff --git a/test/unit/models/ApplicationStateSpec.scala b/test/unit/models/ApplicationStateSpec.scala new file mode 100644 index 000000000..585123c69 --- /dev/null +++ b/test/unit/models/ApplicationStateSpec.scala @@ -0,0 +1,115 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 models + +import org.joda.time.DateTimeUtils +import org.scalatest.BeforeAndAfterEach +import uk.gov.hmrc.models._ +import uk.gov.hmrc.play.test.UnitSpec +import common.uk.gov.hmrc.testutils.ApplicationStateUtil + +class ApplicationStateSpec extends UnitSpec with ApplicationStateUtil with BeforeAndAfterEach { + + override protected def beforeEach(): Unit = { + DateTimeUtils.setCurrentMillisSystem() + } + + val upliftRequestedBy = "requester@example.com" + + "state transition from TESTING " should { + val startingState = testingState() + "move application to PENDING_GATEKEEPER_APPROVAL state" in { + val resultState = startingState.toPendingGatekeeperApproval(upliftRequestedBy) + + resultState.name shouldBe State.PENDING_GATEKEEPER_APPROVAL + resultState.requestedByEmailAddress shouldBe Some(upliftRequestedBy) + resultState.verificationCode shouldBe None + resultState.updatedOn.isAfter(startingState.updatedOn) shouldBe true + } + + "fail when application state is changed to PRODUCTION" in { + intercept[InvalidStateTransition](startingState.toProduction) + } + + "fail when application state is changed to PENDING_REQUESTER_VERIFICATION" in { + intercept[InvalidStateTransition](startingState.toPendingRequesterVerification) + } + } + + "state transition from PENDING_GATEKEEPER_APPROVAL " should { + val startingState = pendingGatekeeperApprovalState(upliftRequestedBy) + "move application to PENDING_REQUESTER_VERIFICATION state" in { + val resultState = startingState.toPendingRequesterVerification + + resultState.name shouldBe State.PENDING_REQUESTER_VERIFICATION + resultState.requestedByEmailAddress shouldBe Some(upliftRequestedBy) + resultState.verificationCode shouldBe defined + resultState.updatedOn.isAfter(startingState.updatedOn) shouldBe true + } + + "fail when application state is changed to PRODUCTION" in { + intercept[InvalidStateTransition](startingState.toProduction) + } + + "fail when application state is changed to PENDING_GATEKEEPER_APPROVAL" in { + intercept[InvalidStateTransition](startingState.toPendingGatekeeperApproval(upliftRequestedBy)) + } + } + + + "state transition from PENDING_REQUESTER_VERIFICATION " should { + val startingState = pendingRequesterVerificationState(upliftRequestedBy) + "move application to PRODUCTION state" in { + val resultState = startingState.toProduction + + resultState.name shouldBe State.PRODUCTION + resultState.requestedByEmailAddress shouldBe Some(upliftRequestedBy) + resultState.verificationCode shouldBe defined + resultState.updatedOn.isAfter(startingState.updatedOn) shouldBe true + } + "fail when application state is changed to PENDING_GATEKEEPER_APPROVAL" in { + intercept[InvalidStateTransition](startingState.toPendingGatekeeperApproval(upliftRequestedBy)) + } + + "fail when application state is changed to PENDING_REQUESTER_VERIFICATION" in { + intercept[InvalidStateTransition](startingState.toPendingRequesterVerification) + } + } + + "state transition from PRODUCTION " should { + val startingState = productionState(upliftRequestedBy) + "move back application to TESTING state" in { + val resultState = startingState.toTesting + + resultState.name shouldBe State.TESTING + resultState.requestedByEmailAddress shouldBe None + resultState.verificationCode shouldBe None + resultState.updatedOn.isAfter(startingState.updatedOn) shouldBe true + } + "fail when application state is changed to PENDING_GATEKEEPER_APPROVAL" in { + intercept[InvalidStateTransition](startingState.toPendingGatekeeperApproval(upliftRequestedBy)) + } + + "fail when application state is changed to PENDING_REQUESTER_VERIFICATION" in { + intercept[InvalidStateTransition](startingState.toPendingRequesterVerification) + } + + "fail when application state is changed to PRODUCTION" in { + intercept[InvalidStateTransition](startingState.toProduction) + } + } +} diff --git a/test/unit/models/StateHistorySpec.scala b/test/unit/models/StateHistorySpec.scala new file mode 100644 index 000000000..50cac39ba --- /dev/null +++ b/test/unit/models/StateHistorySpec.scala @@ -0,0 +1,41 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 models + +import java.util.UUID + +import uk.gov.hmrc.models._ +import uk.gov.hmrc.play.test.UnitSpec +import uk.gov.hmrc.time.DateTimeUtils +import StateHistory.dateTimeOrdering + +class StateHistorySpec extends UnitSpec { + + val applicationId = UUID.randomUUID() + val now = DateTimeUtils.now + val actor = Actor("admin@example.com", ActorType.COLLABORATOR) + + "State history" should { + "sort by date" in { + val stateHistory1 = StateHistory(applicationId, State.TESTING, actor, changedAt = now.minusHours(5)) + val stateHistory2 = StateHistory(applicationId, State.TESTING, actor, changedAt = now.minusHours(3)) + + Seq(stateHistory2, stateHistory1).sortBy(_.changedAt) should contain inOrder(stateHistory1, stateHistory2) + } + } + +} diff --git a/test/unit/scheduled/RefreshSubscriptionsScheduledJobSpec.scala b/test/unit/scheduled/RefreshSubscriptionsScheduledJobSpec.scala new file mode 100644 index 000000000..19f6fbe64 --- /dev/null +++ b/test/unit/scheduled/RefreshSubscriptionsScheduledJobSpec.scala @@ -0,0 +1,106 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 scheduled + +import java.util.concurrent.TimeUnit.{DAYS, SECONDS} + +import org.joda.time.{DateTime, DateTimeUtils, Duration} +import org.mockito.Matchers._ +import org.mockito.Mockito._ +import org.scalatest.BeforeAndAfterAll +import org.scalatest.mockito.MockitoSugar +import play.api.Logger +import uk.gov.hmrc.config.AppContext +import uk.gov.hmrc.lock.{LockKeeper, LockRepository} +import uk.gov.hmrc.play.test.UnitSpec +import uk.gov.hmrc.scheduled.{JobConfig, RefreshSubscriptionsJobLockKeeper, RefreshSubscriptionsScheduledJob} +import uk.gov.hmrc.services.SubscriptionService +import uk.gov.hmrc.time.{DateTimeUtils => HmrcTime} +import common.uk.gov.hmrc.common.LogSuppressing +import common.uk.gov.hmrc.testutils.ApplicationStateUtil + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.{ExecutionContext, Future} +import uk.gov.hmrc.http.HeaderCarrier + +import scala.concurrent.duration.FiniteDuration + +class RefreshSubscriptionsScheduledJobSpec extends UnitSpec with MockitoSugar with BeforeAndAfterAll with ApplicationStateUtil + with LogSuppressing { + + val FixedTimeNow: DateTime = HmrcTime.now + val expiryTimeInDays = 90 + + trait Setup { + implicit val hc = HeaderCarrier() + + val mockSubscriptionService = mock[SubscriptionService] + + val lockKeeperSuccess: () => Boolean = () => true + + val mockLockKeeper = new RefreshSubscriptionsJobLockKeeper { + override def lockId: String = "testLock" + + override def repo: LockRepository = mock[LockRepository] + + override val forceLockReleaseAfter: Duration = Duration.standardMinutes(5) + + override def tryLock[T](body: => Future[T])(implicit ec: ExecutionContext): Future[Option[T]] = + if (lockKeeperSuccess()) body.map(value => Future.successful(Some(value))) + else Future.successful(None) + } + + val mockAppContext = mock[AppContext] + when(mockAppContext.refreshSubscriptionsJobConfig).thenReturn(JobConfig(FiniteDuration(120, SECONDS), FiniteDuration(60, DAYS), enabled = true)) + + val underTest = new RefreshSubscriptionsScheduledJob(mockLockKeeper, mockSubscriptionService, mockAppContext) + } + + override def beforeAll(): Unit = { + DateTimeUtils.setCurrentMillisFixed(FixedTimeNow.toDate.getTime) + } + + override def afterAll() : Unit = { + DateTimeUtils.setCurrentMillisSystem() + } + + "refresh subscriptions job execution" should { + "attempt to refresh the subscriptions for all applications" in new Setup { + when(mockSubscriptionService.refreshSubscriptions()(any[HeaderCarrier])).thenReturn(Future.successful(5)) + + await(underTest.execute) + verify(mockSubscriptionService, times(1)).refreshSubscriptions()(any[HeaderCarrier]) + } + + "not execute if the job is already running" in new Setup { + override val lockKeeperSuccess: () => Boolean = () => false + + await(underTest.execute) + } + + "handle error when fetching subscription fails" in new Setup { + withSuppressedLoggingFrom(Logger, "Could not refresh subscriptions") { suppressedLogs => + when(mockSubscriptionService.refreshSubscriptions()(any[HeaderCarrier])).thenReturn( + Future.failed(new RuntimeException("A failure on executing refreshSubscriptions")) + ) + val result = await(underTest.execute) + + result.message shouldBe "The execution of scheduled job RefreshSubscriptionsScheduledJob failed with error 'A failure on executing refreshSubscriptions'. The next execution of the job will do retry." + } + } + } +} diff --git a/test/unit/scheduled/RetryingSpec.scala b/test/unit/scheduled/RetryingSpec.scala new file mode 100644 index 000000000..c508b6990 --- /dev/null +++ b/test/unit/scheduled/RetryingSpec.scala @@ -0,0 +1,79 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 scheduled + +import play.api.test.FakeApplication +import play.api.test.Helpers.running +import uk.gov.hmrc.scheduled.Retrying.retry +import uk.gov.hmrc.play.test.UnitSpec + +import scala.concurrent.duration._ +import scala.concurrent.Future + +class RetryingSpec extends UnitSpec { + + private implicit val timeout = 1.second + + "retry" should { + + "execute a successful future without delay" in running(FakeApplication()) { + + def successfulFuture: Future[Int] = { + Future.successful(204) + } + + val res = await(retry(successfulFuture, delay = 5.seconds, retries = 0)) + res shouldBe 204 + } + + "retry to execute a future in case of failure" in running(FakeApplication()) { + + val retryTimes = 3 + + var maxExecutions = retryTimes + 1 + def slowSuccessfulFuture: Future[Int] = { + maxExecutions -= 1 + if (maxExecutions == 0) { + Future.successful(maxExecutions) + } else { + Future.failed(new RuntimeException) + } + } + + val res = await(retry(slowSuccessfulFuture, delay = 20.millis, retries = retryTimes)) + res shouldBe 0 + } + + "retry to execute a future up to `n` times before to throw the error" in running(FakeApplication()) { + + val retryTimes = 5 + + var failures = 0 + def failedFuture = { + failures += 1 + Future.failed(new RuntimeException) + } + + intercept[RuntimeException] { + await(retry(failedFuture, delay = 20.millis, retries = retryTimes)) + } + + failures shouldBe retryTimes + 1 + } + } + +} diff --git a/test/unit/scheduled/UpliftVerificationExpiryJobSpec.scala b/test/unit/scheduled/UpliftVerificationExpiryJobSpec.scala new file mode 100644 index 000000000..a01946708 --- /dev/null +++ b/test/unit/scheduled/UpliftVerificationExpiryJobSpec.scala @@ -0,0 +1,168 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 unit.scheduled + +import java.util.UUID +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeUnit.{HOURS, SECONDS} + +import common.uk.gov.hmrc.testutils.ApplicationStateUtil +import org.joda.time.{DateTime, DateTimeUtils, Duration} +import org.mockito.Matchers._ +import org.mockito.Mockito._ +import org.mockito.invocation.InvocationOnMock +import org.mockito.stubbing.Answer +import org.scalatest.BeforeAndAfterAll +import org.scalatest.mockito.MockitoSugar +import play.modules.reactivemongo.ReactiveMongoComponent +import uk.gov.hmrc.config.AppContext +import uk.gov.hmrc.lock.LockRepository +import uk.gov.hmrc.models.State.PENDING_REQUESTER_VERIFICATION +import uk.gov.hmrc.models._ +import uk.gov.hmrc.mongo.{MongoConnector, MongoSpecSupport} +import uk.gov.hmrc.play.test.UnitSpec +import uk.gov.hmrc.repository.{ApplicationRepository, StateHistoryRepository} +import uk.gov.hmrc.scheduled.{JobConfig, UpliftVerificationExpiryJob, UpliftVerificationExpiryJobLockKeeper} +import uk.gov.hmrc.time.{DateTimeUtils => HmrcTime} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future._ +import scala.concurrent.duration.FiniteDuration +import scala.concurrent.{ExecutionContext, Future} + +class UpliftVerificationExpiryJobSpec extends UnitSpec with MockitoSugar with MongoSpecSupport with BeforeAndAfterAll with ApplicationStateUtil { + + private val reactiveMongoComponent = new ReactiveMongoComponent { + override def mongoConnector: MongoConnector = mongoConnectorForTest + } + + val FixedTimeNow: DateTime = HmrcTime.now + val expiryTimeInDays = 90 + + trait Setup { + val mockApplicationRepository = mock[ApplicationRepository] + val mockStateHistoryRepository = mock[StateHistoryRepository] + + val lockKeeperSuccess: () => Boolean = () => true + + val mockLockKeeper = new UpliftVerificationExpiryJobLockKeeper(reactiveMongoComponent) { + override def lockId: String = ??? + + override def repo: LockRepository = ??? + + override val forceLockReleaseAfter: Duration = Duration.standardMinutes(5) + + override def tryLock[T](body: => Future[T])(implicit ec: ExecutionContext): Future[Option[T]] = + if (lockKeeperSuccess()) body.map(value => Future.successful(Some(value))) + else Future.successful(None) + } + + val mockAppContext = mock[AppContext] + when(mockAppContext.upliftVerificationExpiryJobConfig).thenReturn(JobConfig(FiniteDuration(60, SECONDS), FiniteDuration(24, HOURS), enabled = true)) + when(mockAppContext.upliftVerificationValidity).thenReturn(FiniteDuration(expiryTimeInDays, TimeUnit.DAYS)) + + val underTest = new UpliftVerificationExpiryJob(mockLockKeeper, mockApplicationRepository, mockStateHistoryRepository, mockAppContext) + + when(mockApplicationRepository.save(any[ApplicationData])).thenAnswer(returnSame[ApplicationData]) + + def returnSame[T] = new Answer[Future[T]] { + override def answer(invocationOnMock: InvocationOnMock): Future[T] = { + val argument = invocationOnMock.getArguments()(0) + successful(argument.asInstanceOf[T]) + } + } + } + + override def beforeAll(): Unit = { + DateTimeUtils.setCurrentMillisFixed(FixedTimeNow.toDate.getTime) + } + + override def afterAll(): Unit = { + DateTimeUtils.setCurrentMillisSystem() + } + + "uplift verification expiry job execution" should { + "expire all application uplifts having expiry date before the expiry time" in new Setup { + val app1 = anApplicationData(UUID.randomUUID(), "aaa", "111") + val app2 = anApplicationData(UUID.randomUUID(), "aaa", "111") + + when(mockApplicationRepository.fetchAllByStatusDetails(refEq(PENDING_REQUESTER_VERIFICATION), any[DateTime])) + .thenReturn(Future.successful(Seq(app1, app2))) + + await(underTest.execute) + verify(mockApplicationRepository).fetchAllByStatusDetails(PENDING_REQUESTER_VERIFICATION, FixedTimeNow.minusDays(expiryTimeInDays)) + verify(mockApplicationRepository).save(app1.copy(state = testingState())) + verify(mockApplicationRepository).save(app2.copy(state = testingState())) + verify(mockStateHistoryRepository).insert(StateHistory(app1.id, State.TESTING, + Actor("UpliftVerificationExpiryJob", ActorType.SCHEDULED_JOB), Some(PENDING_REQUESTER_VERIFICATION))) + verify(mockStateHistoryRepository).insert(StateHistory(app2.id, State.TESTING, + Actor("UpliftVerificationExpiryJob", ActorType.SCHEDULED_JOB), Some(PENDING_REQUESTER_VERIFICATION))) + } + + "not execute if the job is already running" in new Setup { + override val lockKeeperSuccess: () => Boolean = () => false + + await(underTest.execute) + } + + "handle error on first database call to fetch all applications" in new Setup { + when(mockApplicationRepository.fetchAllByStatusDetails(refEq(PENDING_REQUESTER_VERIFICATION), any[DateTime])).thenReturn( + Future.failed(new RuntimeException("A failure on executing fetchAllByStatusDetails db query")) + ) + val result = await(underTest.execute) + + result.message shouldBe + "The execution of scheduled job UpliftVerificationExpiryJob failed with error 'A failure on executing fetchAllByStatusDetails db query'." + + " The next execution of the job will do retry." + } + + "handle error on subsequent database call to update an application" in new Setup { + val app1 = anApplicationData(UUID.randomUUID(), "aaa", "111") + val app2 = anApplicationData(UUID.randomUUID(), "aaa", "111") + + when(mockApplicationRepository.fetchAllByStatusDetails(refEq(PENDING_REQUESTER_VERIFICATION), any[DateTime])) + .thenReturn(Future.successful(Seq(app1, app2))) + when(mockApplicationRepository.save(any[ApplicationData])).thenReturn( + Future.failed(new RuntimeException("A failure on executing save db query")) + ) + + val result = await(underTest.execute) + + verify(mockApplicationRepository).fetchAllByStatusDetails(PENDING_REQUESTER_VERIFICATION, FixedTimeNow.minusDays(expiryTimeInDays)) + result.message shouldBe + "The execution of scheduled job UpliftVerificationExpiryJob failed with error 'A failure on executing save db query'." + + " The next execution of the job will do retry." + } + + } + + def anApplicationData(id: UUID, prodClientId: String, sandboxClientId: String, state: ApplicationState = testingState()): ApplicationData = { + ApplicationData( + id, + s"myApp-$id", + s"myapp-$id", + Set(Collaborator("user@example.com", Role.ADMINISTRATOR)), + Some("description"), + "username", + "password", + "myapplication", + ApplicationTokens( + EnvironmentToken(prodClientId, "bbb", "ccc"), + EnvironmentToken(sandboxClientId, "222", "333")), + state, Standard(Seq.empty, None, None)) + } +} diff --git a/test/unit/services/AccessServiceSpec.scala b/test/unit/services/AccessServiceSpec.scala new file mode 100644 index 000000000..cdcc93eb4 --- /dev/null +++ b/test/unit/services/AccessServiceSpec.scala @@ -0,0 +1,226 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 services + +import java.util.UUID + +import org.mockito.ArgumentCaptor +import org.mockito.Matchers.any +import org.mockito.Mockito.{verify, when} +import org.scalatest.mockito.MockitoSugar +import uk.gov.hmrc.controllers.{OverridesRequest, OverridesResponse, ScopeRequest, ScopeResponse} +import uk.gov.hmrc.http.HeaderCarrier +import uk.gov.hmrc.models._ +import uk.gov.hmrc.play.audit.http.connector.AuditResult +import uk.gov.hmrc.play.test.UnitSpec +import uk.gov.hmrc.repository.ApplicationRepository +import uk.gov.hmrc.services.AuditAction.{OverrideAdded, OverrideRemoved, ScopeAdded, ScopeRemoved} +import uk.gov.hmrc.services.{AccessService, AuditAction, AuditService} + +import scala.concurrent.Future +import scala.concurrent.Future.successful + +class AccessServiceSpec extends UnitSpec with MockitoSugar { + + "Access service update scopes function" should { + + "invoke repository save function with updated privileged application data access scopes" in new ScopeFixture { + mockApplicationRepositoryFetchAndSave(privilegedApplicationDataWithScopes(applicationId), Set.empty, scopes1to4) + await(accessService.updateScopes(applicationId, ScopeRequest(scopes1to4))(hc)) + captureApplicationRepositorySaveArgument().access.asInstanceOf[Privileged].scopes shouldBe scopes1to4 + } + + "invoke repository save function with updated ropc application data access scopes" in new ScopeFixture { + mockApplicationRepositoryFetchAndSave(ropcApplicationDataWithScopes(applicationId), Set.empty, scopes1to4) + await(accessService.updateScopes(applicationId, ScopeRequest(scopes1to4))(hc)) + captureApplicationRepositorySaveArgument().access.asInstanceOf[Ropc].scopes shouldBe scopes1to4 + } + + "invoke audit service for privileged scopes" in new ScopeFixture { + mockApplicationRepositoryFetchAndSave(privilegedApplicationDataWithScopes(applicationId), scopes1to3, Set.empty) + await(accessService.updateScopes(applicationId, ScopeRequest(scopes2to4))(hc)) + verify(mockAuditService).audit(ScopeRemoved, Map("removedScope" -> scope1))(hc) + verify(mockAuditService).audit(ScopeAdded, Map("newScope" -> scope4))(hc) + } + + "invoke audit service for ropc scopes" in new ScopeFixture { + mockApplicationRepositoryFetchAndSave(ropcApplicationDataWithScopes(applicationId), scopes1to3, Set.empty) + await(accessService.updateScopes(applicationId, ScopeRequest(scopes2to4))(hc)) + verify(mockAuditService).audit(ScopeRemoved, Map("removedScope" -> scope1))(hc) + verify(mockAuditService).audit(ScopeAdded, Map("newScope" -> scope4))(hc) + } + + } + + "Access service read scopes function" should { + + "return privileged scopes when repository save succeeds" in new ScopeFixture { + mockApplicationRepositoryFetchToReturn(successful(Some(privilegedApplicationDataWithScopes(applicationId)(scopes1to4)))) + await(accessService.readScopes(applicationId)) shouldBe ScopeResponse(scopes1to4) + } + + "return ropc scopes when repository save succeeds" in new ScopeFixture { + mockApplicationRepositoryFetchToReturn(successful(Some(ropcApplicationDataWithScopes(applicationId)(scopes1to4)))) + await(accessService.readScopes(applicationId)) shouldBe ScopeResponse(scopes1to4) + } + + } + + "Access service read overrides function" should { + + "return the overrides saved on the application" in new OverridesFixture { + mockApplicationRepositoryFetchToReturn(successful(Some(standardApplicationDataWithOverrides(applicationId, overrides)))) + await(accessService.readOverrides(applicationId)) shouldBe OverridesResponse(overrides) + } + + } + + "Access service update overrides function" should { + + "invoke repository save function with updated application data access overrides" in new OverridesFixture { + val oldOverrides = Set[OverrideFlag](override1) + val applicationDataWithOverrides = standardApplicationDataWithOverrides(applicationId, oldOverrides) + mockApplicationRepositoryFetchToReturn(successful(Some(applicationDataWithOverrides))) + mockApplicationRepositorySaveToReturn(successful(applicationDataWithOverrides)) + + val newOverrides = Set[OverrideFlag](override2, override3, override4) + await(accessService.updateOverrides(applicationId, OverridesRequest(newOverrides))(hc)) + + val capturedApplicationData = captureApplicationRepositorySaveArgument() + capturedApplicationData.access.asInstanceOf[Standard].overrides shouldBe newOverrides + } + + "overwrite the existing overrides with the new ones" in new OverridesFixture { + val grantWithoutConsent1 = GrantWithoutConsent(Set("scope1")) + val grantWithoutConsent2 = GrantWithoutConsent(Set("scope2")) + + val oldOverrides = Set[OverrideFlag](grantWithoutConsent1) + val applicationDataWithOverrides = standardApplicationDataWithOverrides(applicationId, oldOverrides) + + mockApplicationRepositoryFetchToReturn(successful(Some(applicationDataWithOverrides))) + mockApplicationRepositorySaveToReturn(successful(applicationDataWithOverrides)) + + val newOverrides = Set[OverrideFlag](grantWithoutConsent2) + await(accessService.updateOverrides(applicationId, OverridesRequest(newOverrides))(hc)) + + val capturedApplicationData = captureApplicationRepositorySaveArgument() + capturedApplicationData.access.asInstanceOf[Standard].overrides shouldBe Set(grantWithoutConsent2) + } + + "invoke audit service" in new OverridesFixture { + val oldOverrides = Set[OverrideFlag](override1) + val newOverrides = Set[OverrideFlag](override2) + + val applicationDataWithOverrides = standardApplicationDataWithOverrides(applicationId, oldOverrides) + mockApplicationRepositoryFetchToReturn(successful(Some(applicationDataWithOverrides))) + mockApplicationRepositorySaveToReturn(successful(applicationDataWithOverrides)) + + await(accessService.updateOverrides(applicationId, OverridesRequest(newOverrides))(hc)) + + verify(mockAuditService).audit(OverrideRemoved, Map("removedOverride" -> override1.overrideType.toString))(hc) + verify(mockAuditService).audit(OverrideAdded, Map("newOverride" -> override2.overrideType.toString))(hc) + } + + } + + trait Fixture { + + val applicationId = UUID.randomUUID() + implicit val hc = HeaderCarrier() + + val mockApplicationRepository = mock[ApplicationRepository] + val mockAuditService = mock[AuditService] + + val accessService = new AccessService(mockApplicationRepository, mockAuditService) + + val applicationDataArgumentCaptor = ArgumentCaptor.forClass(classOf[ApplicationData]) + + def mockApplicationRepositoryFetchToReturn(eventualMaybeApplicationData: Future[Option[ApplicationData]]) = + when(mockApplicationRepository.fetch(any[UUID])).thenReturn(eventualMaybeApplicationData) + + def mockApplicationRepositorySaveToReturn(eventualApplicationData: Future[ApplicationData]) = + when(mockApplicationRepository.save(any[ApplicationData])).thenReturn(eventualApplicationData) + + def captureApplicationRepositorySaveArgument(): ApplicationData = { + verify(mockApplicationRepository).save(applicationDataArgumentCaptor.capture()) + applicationDataArgumentCaptor.getValue + } + + def captureApplicationRepositorySaveArgumentsAccessScopes(): Set[String] = { + verify(mockApplicationRepository).save(applicationDataArgumentCaptor.capture()) + applicationDataArgumentCaptor.getValue.access.asInstanceOf[Privileged].scopes + } + + when(mockAuditService.audit(any[AuditAction], any[Map[String, String]])(any[HeaderCarrier])).thenReturn(successful(AuditResult.Success)) + + } + + trait ScopeFixture extends Fixture { + val scope1 = "scope:key" + val scope2 = "read:performance-test" + val scope3 = "write:performance-test" + val scope4 = "scope:key2" + val scopes1to4 = Set(scope1, scope2, scope3, scope4) + val scopes1to3 = Set(scope1, scope2, scope3) + val scopes2to4 = Set(scope2, scope3, scope4) + + def mockApplicationRepositoryFetchAndSave(partialApplication: (Set[String]) => ApplicationData, + fetchScopes: Set[String], saveScopes: Set[String] = Set.empty) = { + mockApplicationRepositoryFetchToReturn(successful(Some(partialApplication(fetchScopes)))) + mockApplicationRepositorySaveToReturn(successful(partialApplication(saveScopes))) + } + } + + trait OverridesFixture extends Fixture { + val override1 = GrantWithoutConsent(Set("scope1", "scope2")) + val override2 = PersistLogin() + val override3 = SuppressIvForAgents(Set("scope1", "scope2")) + val override4 = SuppressIvForOrganisations(Set("scope1", "scope2")) + val overrides = Set[OverrideFlag](override1, override2, override3, override4) + } + + private def privilegedApplicationDataWithScopes(applicationId: UUID)(scopes: Set[String]): ApplicationData = + ApplicationData( + applicationId, "name", "normalisedName", + Set(Collaborator("user@example.com", Role.ADMINISTRATOR)), None, + "wso2Username", "wso2Password", "wso2ApplicationName", + ApplicationTokens( + EnvironmentToken("a", "b", "c"), + EnvironmentToken("1", "2", "3")), + ApplicationState(), Privileged(None, scopes)) + + private def ropcApplicationDataWithScopes(applicationId: UUID)(scopes: Set[String]): ApplicationData = + ApplicationData( + applicationId, "name", "normalisedName", + Set(Collaborator("user@example.com", Role.ADMINISTRATOR)), None, + "wso2Username", "wso2Password", "wso2ApplicationName", + ApplicationTokens( + EnvironmentToken("a", "b", "c"), + EnvironmentToken("1", "2", "3")), + ApplicationState(), Ropc(scopes)) + + private def standardApplicationDataWithOverrides(applicationId: UUID, overrides: Set[OverrideFlag]): ApplicationData = + ApplicationData( + applicationId, "name", "normalisedName", + Set(Collaborator("user@example.com", Role.ADMINISTRATOR)), None, + "wso2Username", "wso2Password", "wso2ApplicationName", + ApplicationTokens( + EnvironmentToken("a", "b", "c"), + EnvironmentToken("1", "2", "3")), + ApplicationState(), Standard(redirectUris = Seq.empty, overrides = overrides)) + +} diff --git a/test/unit/services/ApplicationServiceSpec.scala b/test/unit/services/ApplicationServiceSpec.scala new file mode 100644 index 000000000..ef87aafbc --- /dev/null +++ b/test/unit/services/ApplicationServiceSpec.scala @@ -0,0 +1,1210 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 services + +import java.util.UUID +import java.util.concurrent.{TimeUnit, TimeoutException} + +import common.uk.gov.hmrc.testutils.ApplicationStateUtil +import org.joda.time.{DateTime, DateTimeUtils} +import org.mockito.BDDMockito.given +import org.mockito.Matchers._ +import org.mockito.Mockito._ +import org.mockito.invocation.InvocationOnMock +import org.mockito.stubbing.Answer +import org.mockito.{ArgumentCaptor, Mockito} +import org.scalatest.BeforeAndAfterAll +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.mockito.MockitoSugar +import org.scalatestplus.play.OneAppPerTest +import uk.gov.hmrc.config.AppContext +import uk.gov.hmrc.connector.{EmailConnector, TOTPConnector} +import uk.gov.hmrc.controllers.{AddCollaboratorRequest, AddCollaboratorResponse} +import uk.gov.hmrc.http.{ForbiddenException, HeaderCarrier, HttpResponse, NotFoundException} +import uk.gov.hmrc.models.ActorType.{COLLABORATOR, GATEKEEPER} +import uk.gov.hmrc.models.Environment.{Environment, PRODUCTION} +import uk.gov.hmrc.models.RateLimitTier.{RateLimitTier, SILVER} +import uk.gov.hmrc.models.Role._ +import uk.gov.hmrc.models.State._ +import uk.gov.hmrc.models._ +import uk.gov.hmrc.play.test.UnitSpec +import uk.gov.hmrc.repository.{ApplicationRepository, StateHistoryRepository, SubscriptionRepository} +import uk.gov.hmrc.services.AuditAction._ +import uk.gov.hmrc.services._ +import uk.gov.hmrc.util.CredentialGenerator +import uk.gov.hmrc.util.http.HttpHeaders._ + +import scala.collection.mutable +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future.{failed, successful} +import scala.concurrent.duration.Duration +import scala.concurrent.{Await, ExecutionContext, Future} + +class ApplicationServiceSpec extends UnitSpec with ScalaFutures with MockitoSugar + with BeforeAndAfterAll with ApplicationStateUtil with OneAppPerTest { + + trait Setup { + + lazy val locked = false + val mockWSO2APIStore = mock[WSO2APIStore] + val mockApplicationRepository = mock[ApplicationRepository] + val mockSubscriptionRepository = mock[SubscriptionRepository] + val mockStateHistoryRepository = mock[StateHistoryRepository] + val mockAuditService = mock[AuditService] + val mockEmailConnector = mock[EmailConnector] + val mockTotpConnector = mock[TOTPConnector] + val mockLockKeeper = new MockLockKeeper(locked) + val response = mock[HttpResponse] + val trustedApplicationId1 = UUID.fromString("162017dc-607b-4405-8208-a28308672f76") + val trustedApplicationId2 = UUID.fromString("162017dc-607b-4405-8208-a28308672f77") + + val mockAppContext = mock[AppContext] + when(mockAppContext.trustedApplications).thenReturn(Seq(trustedApplicationId1.toString, trustedApplicationId2.toString)) + + val applicationResponseCreator = new ApplicationResponseCreator(mockAppContext) + + implicit val hc = HeaderCarrier().withExtraHeaders( + LOGGED_IN_USER_EMAIL_HEADER -> loggedInUser, + LOGGED_IN_USER_NAME_HEADER -> "John Smith" + ) + + val mockCredentialGenerator = mock[CredentialGenerator] + + val underTest = new ApplicationService(mockApplicationRepository, mockStateHistoryRepository, mockSubscriptionRepository, mockAuditService, mockEmailConnector, mockTotpConnector, mockLockKeeper, mockWSO2APIStore, + applicationResponseCreator, mockCredentialGenerator, mockAppContext) + + when(mockCredentialGenerator.generate()).thenReturn("a" * 10) + when(mockWSO2APIStore.createApplication(any(), any(), any())(any[HeaderCarrier])).thenReturn(successful(ApplicationTokens(productionToken, sandboxToken))) + when(mockApplicationRepository.save(any())).thenAnswer(new Answer[Future[ApplicationData]] { + override def answer(invocation: InvocationOnMock): Future[ApplicationData] = { + successful(invocation.getArguments()(0).asInstanceOf[ApplicationData]) + } + }) + when(mockStateHistoryRepository.insert(any())).thenAnswer(new Answer[Future[StateHistory]] { + override def answer(invocation: InvocationOnMock): Future[StateHistory] = { + successful(invocation.getArguments()(0).asInstanceOf[StateHistory]) + } + }) + when(mockEmailConnector.sendRemovedCollaboratorNotification(anyString(), anyString(), any())(any[HeaderCarrier]())).thenReturn(successful(response)) + when(mockEmailConnector.sendRemovedCollaboratorConfirmation(anyString(), any())(any[HeaderCarrier]())).thenReturn(successful(response)) + when(mockEmailConnector.sendApplicationApprovedAdminConfirmation(anyString(), anyString(), any())(any[HeaderCarrier]())).thenReturn(successful(response)) + when(mockEmailConnector.sendApplicationApprovedNotification(anyString(), any())(any[HeaderCarrier]())).thenReturn(successful(response)) + mockWso2ApiStoreUpdateApplicationToReturn(HasSucceeded) + mockWso2SubscribeToReturn(HasSucceeded) + + def mockApplicationRepositoryFetchToReturn(uuid: UUID, eventualMaybeApplicationData: Future[Option[ApplicationData]]) = { + when(mockApplicationRepository fetch uuid) thenReturn eventualMaybeApplicationData + } + + def mockWso2ApiStoreUpdateApplicationToReturn(eventualHasSucceeded: Future[HasSucceeded]) = { + when(mockWSO2APIStore.updateApplication(anyString(), anyString(), anyString(), any[RateLimitTier])(any[HeaderCarrier])) thenReturn eventualHasSucceeded + } + + def mockWso2ApiStoreGetSubscriptionsToReturn(apiIdentifiers: Seq[APIIdentifier]) = { + when(mockWSO2APIStore.getSubscriptions(anyString(), anyString(), anyString())(any[HeaderCarrier])) thenReturn apiIdentifiers + } + + def mockWso2SubscribeToReturn(eventualHasSucceeded: Future[HasSucceeded]) = { + when(mockWSO2APIStore.resubscribeApi(any[Seq[APIIdentifier]], anyString(), anyString(), anyString(), any[APIIdentifier], any[RateLimitTier])(any[HeaderCarrier])) + .thenReturn(eventualHasSucceeded) + } + + def mockApplicationRepositorySaveToReturn(eventualApplicationData: Future[ApplicationData]) = { + when(mockApplicationRepository save any[ApplicationData]) thenReturn eventualApplicationData + } + + } + + private def aSecret(secret: String): ClientSecret = { + ClientSecret(secret, secret) + } + + private val loggedInUser = "loggedin@example.com" + private val productionToken = EnvironmentToken("aaa", "bbb", "wso2Secret", Seq(aSecret("secret1"), aSecret("secret2"))) + private val sandboxToken = EnvironmentToken("111", "222", "wso2SandboxSecret", Seq(aSecret("secret3"), aSecret("secret4"))) + + trait LockedSetup extends Setup { + override lazy val locked = true + } + + class MockLockKeeper(locked: Boolean) extends ApplicationLockKeeper { + override def repo = null + + override def lockId = "" + + override val forceLockReleaseAfter = null + var callsMadeToLockKeeper: Int = 0 + + override def tryLock[T](body: => Future[T])(implicit ec: ExecutionContext): Future[Option[T]] = { + callsMadeToLockKeeper = callsMadeToLockKeeper + 1 + locked match { + case true => successful(None) + case false => successful(Some(Await.result(body, Duration(1, TimeUnit.SECONDS)))) + } + } + } + + override def beforeAll() { + DateTimeUtils.setCurrentMillisFixed(DateTimeUtils.currentTimeMillis()) + } + + override def afterAll() { + DateTimeUtils.setCurrentMillisSystem() + } + + "Create" should { + + "create a new standard application in Mongo and WSO2 for the PRODUCTION environment" in new Setup { + val applicationRequest = aNewApplicationRequest(access = Standard(), environment = Environment.PRODUCTION) + + val createdApp = await(underTest.create(applicationRequest)(hc)) + + val expectedApplicationData = anApplicationData(createdApp.application.id, state = testingState(), + environment = Environment.PRODUCTION) + createdApp.totp shouldBe None + verify(mockWSO2APIStore).createApplication(any(), any(), any())(any[HeaderCarrier]) + verify(mockApplicationRepository).save(expectedApplicationData) + verify(mockStateHistoryRepository).insert(StateHistory(createdApp.application.id, TESTING, Actor(loggedInUser, COLLABORATOR))) + verify(mockAuditService).audit(AppCreated, + Map( + "applicationId" -> createdApp.application.id.toString, + "newApplicationName" -> applicationRequest.name, + "newApplicationDescription" -> applicationRequest.description.get + )) + } + + "create a new standard application in Mongo and WSO2 for the SANDBOX environment" in new Setup { + val applicationRequest = aNewApplicationRequest(access = Standard(), environment = Environment.SANDBOX) + + val createdApp = await(underTest.create(applicationRequest)(hc)) + + val expectedApplicationData = anApplicationData(createdApp.application.id, state = ApplicationState(State.PRODUCTION), + environment = Environment.SANDBOX) + createdApp.totp shouldBe None + verify(mockWSO2APIStore).createApplication(any(), any(), any())(any[HeaderCarrier]) + verify(mockApplicationRepository).save(expectedApplicationData) + verify(mockStateHistoryRepository).insert(StateHistory(createdApp.application.id, State.PRODUCTION, Actor(loggedInUser, COLLABORATOR))) + verify(mockAuditService).audit(AppCreated, + Map( + "applicationId" -> createdApp.application.id.toString, + "newApplicationName" -> applicationRequest.name, + "newApplicationDescription" -> applicationRequest.description.get + )) + } + + "create a new standard application in Mongo and WSO2" in new Setup { + val applicationRequest = aNewApplicationRequest(access = Standard()) + + val createdApp = await(underTest.create(applicationRequest)(hc)) + + val expectedApplicationData = anApplicationData(createdApp.application.id, state = testingState()) + createdApp.totp shouldBe None + verify(mockWSO2APIStore).createApplication(any(), any(), any())(any[HeaderCarrier]) + verify(mockApplicationRepository).save(expectedApplicationData) + verify(mockStateHistoryRepository).insert(StateHistory(createdApp.application.id, TESTING, Actor(loggedInUser, COLLABORATOR))) + verify(mockAuditService).audit(AppCreated, + Map( + "applicationId" -> createdApp.application.id.toString, + "newApplicationName" -> applicationRequest.name, + "newApplicationDescription" -> applicationRequest.description.get + )) + } + + "create a new Privileged application in Mongo and WSO2 with a Production state" in new Setup { + val applicationRequest = aNewApplicationRequest(access = Privileged()) + when(mockApplicationRepository.fetchNonTestingApplicationByName(applicationRequest.name)).thenReturn(None) + + val prodTOTP = TOTP("prodTotp", "prodTotpId") + val sandboxTOTP = TOTP("sandboxTotp", "sandboxTotpId") + val totpQueue = mutable.Queue(prodTOTP, sandboxTOTP) + given(mockTotpConnector.generateTotp()).willAnswer(new Answer[Future[TOTP]] { + override def answer(invocationOnMock: InvocationOnMock): Future[TOTP] = successful(totpQueue.dequeue()) + }) + + val createdApp = await(underTest.create(applicationRequest)(hc)) + + val expectedApplicationData = anApplicationData( + createdApp.application.id, + state = ApplicationState(name = State.PRODUCTION, requestedByEmailAddress = Some(loggedInUser)), + access = Privileged(totpIds = Some(TotpIds("prodTotpId", "sandboxTotpId"))) + ) + val expectedTotp = ApplicationTotps(prodTOTP, sandboxTOTP) + createdApp.totp shouldBe Some(TotpSecrets(expectedTotp.production.secret, expectedTotp.sandbox.secret)) + + verify(mockWSO2APIStore).createApplication(any(), any(), any())(any[HeaderCarrier]) + verify(mockApplicationRepository).save(expectedApplicationData) + verify(mockStateHistoryRepository).insert(StateHistory(createdApp.application.id, State.PRODUCTION, Actor("", GATEKEEPER))) + verify(mockAuditService).audit(AppCreated, + Map( + "applicationId" -> createdApp.application.id.toString, + "newApplicationName" -> applicationRequest.name, + "newApplicationDescription" -> applicationRequest.description.get + )) + } + + "create a new ROPC application in Mongo and WSO2 with a Production state" in new Setup { + val applicationRequest = aNewApplicationRequest(access = Ropc()) + + when(mockApplicationRepository.fetchNonTestingApplicationByName(applicationRequest.name)).thenReturn(None) + + val createdApp = await(underTest.create(applicationRequest)(hc)) + + val expectedApplicationData = anApplicationData(createdApp.application.id, state = ApplicationState(name = State.PRODUCTION, requestedByEmailAddress = Some(loggedInUser)), access = Ropc()) + verify(mockWSO2APIStore).createApplication(any(), any(), any())(any[HeaderCarrier]) + verify(mockApplicationRepository).save(expectedApplicationData) + verify(mockStateHistoryRepository).insert(StateHistory(createdApp.application.id, State.PRODUCTION, Actor("", GATEKEEPER))) + verify(mockAuditService).audit(AppCreated, + Map( + "applicationId" -> createdApp.application.id.toString, + "newApplicationName" -> applicationRequest.name, + "newApplicationDescription" -> applicationRequest.description.get + )) + } + + "fail with ApplicationAlreadyExists for privileged application when the name already exists for another application not in testing mode" in new Setup { + val applicationRequest = aNewApplicationRequest(Privileged()) + + when(mockApplicationRepository.fetchNonTestingApplicationByName(applicationRequest.name)).thenReturn(Some(anApplicationData(UUID.randomUUID()))) + + intercept[ApplicationAlreadyExists] { + await(underTest.create(applicationRequest)(hc)) + } + verify(mockAuditService).audit(CreatePrivilegedApplicationRequestDeniedDueToNonUniqueName, + Map("applicationName" -> applicationRequest.name)) + } + + "fail with ApplicationAlreadyExists for ropc application when the name already exists for another application not in testing mode" in new Setup { + val applicationRequest = aNewApplicationRequest(Ropc()) + + when(mockApplicationRepository.fetchNonTestingApplicationByName(applicationRequest.name)).thenReturn(Some(anApplicationData(UUID.randomUUID()))) + + intercept[ApplicationAlreadyExists] { + await(underTest.create(applicationRequest)(hc)) + } + verify(mockAuditService).audit(CreateRopcApplicationRequestDeniedDueToNonUniqueName, Map("applicationName" -> applicationRequest.name)) + } + + //See https://wso2.org/jira/browse/CAPIMGT-1 + "not create the application when there is already an application being published" in new LockedSetup { + val applicationRequest = aNewApplicationRequest() + + intercept[TimeoutException] { + await(underTest.create(applicationRequest)) + } + + mockLockKeeper.callsMadeToLockKeeper should be > 1 + verifyZeroInteractions(mockWSO2APIStore) + verifyZeroInteractions(mockApplicationRepository) + } + + "delete application when failed to create app and generate tokens" in new Setup { + val applicationRequest = aNewApplicationRequest() + val applicationData = anApplicationData(UUID.randomUUID()) + + private val exception = new scala.RuntimeException("failed to generate tokens") + when(mockWSO2APIStore.createApplication(any(), any(), any())(any[HeaderCarrier])).thenReturn(failed(exception)) + when(mockWSO2APIStore.deleteApplication(anyString(), anyString(), anyString())(any[HeaderCarrier])).thenReturn(Future(HasSucceeded)) + + val ex = intercept[RuntimeException](await(underTest.create(applicationRequest))) + ex.getMessage shouldBe exception.getMessage + + verify(mockApplicationRepository, never()).save(any()) + verify(mockWSO2APIStore).deleteApplication(anyString(), anyString(), anyString())(any[HeaderCarrier]) + } + + "delete application when failed to create state history" in new Setup { + val dbApplication = ArgumentCaptor.forClass(classOf[ApplicationData]) + val applicationRequest = aNewApplicationRequest() + + when(mockStateHistoryRepository.insert(any())).thenReturn(failed(new RuntimeException("Expected test failure"))) + when(mockWSO2APIStore.deleteApplication(anyString(), anyString(), anyString())(any[HeaderCarrier])).thenReturn(Future(HasSucceeded)) + + val ex = intercept[RuntimeException](await(underTest.create(applicationRequest))) + + verify(mockApplicationRepository).save(dbApplication.capture()) + verify(mockWSO2APIStore).deleteApplication(anyString(), anyString(), anyString())(any[HeaderCarrier]) + verify(mockApplicationRepository).delete(dbApplication.getValue.id) + } + } + + "Update" should { + val id = UUID.randomUUID() + val applicationRequest = anExistingApplicationRequest() + val applicationData = anApplicationData(id) + + "update an existing application if an id is provided" in new Setup { + + mockApplicationRepositoryFetchToReturn(id, successful(Some(applicationData))) + mockApplicationRepositorySaveToReturn(successful(applicationData)) + + await(underTest.update(id, applicationRequest)) + + verify(mockApplicationRepository).save(any()) + } + + "update an existing application if an id is provided and name is changed" in new Setup { + + mockApplicationRepositoryFetchToReturn(id, successful(Some(applicationData))) + mockApplicationRepositorySaveToReturn(successful(applicationData)) + + await(underTest.update(id, applicationRequest)) + + verify(mockApplicationRepository).save(any()) + } + + "throw a NotFoundException if application doesn't exist in repository for the given application id" in new Setup { + + mockApplicationRepositoryFetchToReturn(id, successful(None)) + + intercept[NotFoundException](await(underTest.update(id, applicationRequest))) + + verify(mockApplicationRepository, never()).save(any()) + } + + "throw a ForbiddenException when trying to change the access type of an application" in new Setup { + + val privilegedApplicationRequest = applicationRequest.copy(access = Privileged()) + + mockApplicationRepositoryFetchToReturn(id, successful(Some(applicationData))) + + intercept[ForbiddenException](await(underTest.update(id, privilegedApplicationRequest))) + + verify(mockApplicationRepository, never()).save(any()) + } + } + + "update approval" should { + val id = UUID.randomUUID() + val approvalInformation = CheckInformation(Some(ContactDetails("Tester", "test@example.com", "12345677890"))) + val applicationData = anApplicationData(id) + val applicationDataWithApproval = applicationData.copy(checkInformation = Some(approvalInformation)) + + "update an existing application if an id is provided" in new Setup { + + mockApplicationRepositoryFetchToReturn(id, successful(Some(applicationData))) + mockApplicationRepositorySaveToReturn(successful(applicationDataWithApproval)) + + await(underTest.updateCheck(id, approvalInformation)) + + verify(mockApplicationRepository).save(any()) + } + + "throw a NotFoundException if application doesn't exist in repository for the given application id" in new Setup { + + mockApplicationRepositoryFetchToReturn(id, successful(None)) + + intercept[NotFoundException](await(underTest.updateCheck(id, approvalInformation))) + + verify(mockApplicationRepository, never()).save(any()) + } + } + + "fetch application" should { + val applicationId = UUID.randomUUID() + + "return none when no application exists in the repository for the given application id" in new Setup { + mockApplicationRepositoryFetchToReturn(applicationId, None) + + val result = await(underTest.fetch(applicationId)) + + result shouldBe None + } + + "return an application when it exists in the repository for the given application id" in new Setup { + val data = anApplicationData(applicationId, rateLimitTier = Some(SILVER)) + + mockApplicationRepositoryFetchToReturn(applicationId, Some(data)) + when(mockWSO2APIStore.getSubscriptions(anyString(), anyString(), anyString())(any[HeaderCarrier])) + .thenReturn(successful(Nil)) + + val result = await(underTest.fetch(applicationId)) + + result shouldBe Some(ApplicationResponse(applicationId, productionToken.clientId, data.name, data.environment, data.description, data.collaborators, + data.createdOn, Seq.empty, None, None, data.access, None, data.state, SILVER, false)) + } + + "return an application with trusted flag when the application is in the whitelist" in new Setup { + + val applicationData = anApplicationData(trustedApplicationId2) + + when(mockAppContext.isTrusted(applicationData)).thenReturn(true) + + when(mockApplicationRepository.fetch(trustedApplicationId2)).thenReturn(Some(applicationData)) + when(mockWSO2APIStore.getSubscriptions(anyString(), anyString(), anyString())(any[HeaderCarrier])) + .thenReturn(successful(Nil)) + + val result = await(underTest.fetch(trustedApplicationId2)) + + result.get.trusted shouldBe true + } + + "send an audit event for each type of change" in new Setup { + val admin = Collaborator("test@example.com", ADMINISTRATOR) + val id = UUID.randomUUID() + val tokens = ApplicationTokens( + EnvironmentToken("prodId", "prodSecret", "prodToken"), + EnvironmentToken("sandboxId", "sandboxSecret", "sandboxToken") + ) + + val existingApplication = ApplicationData( + id = id, + name = "app name", + normalisedName = "app name", + collaborators = Set(admin), + wso2Password = "wso2Password", + wso2ApplicationName = "wso2ApplicationName", + wso2Username = "wso2Username", + tokens = tokens, + state = testingState() + ) + + val updatedApplication = existingApplication.copy( + name = "new name", + normalisedName = "new name", + access = Standard( + Seq("http://new-url.example.com"), + Some("http://new-url.example.com/terms-and-conditions"), + Some("http://new-url.example.com/privacy-policy")) + ) + + mockApplicationRepositoryFetchToReturn(id, successful(Some(existingApplication))) + mockApplicationRepositorySaveToReturn(successful(updatedApplication)) + + await(underTest.update(id, UpdateApplicationRequest(updatedApplication.name))) + + verify(mockAuditService).audit(refEq(AppNameChanged), any[Map[String, String]])(any[HeaderCarrier]) + verify(mockAuditService).audit(refEq(AppTermsAndConditionsUrlChanged), any[Map[String, String]])(any[HeaderCarrier]) + verify(mockAuditService).audit(refEq(AppRedirectUrisChanged), any[Map[String, String]])(any[HeaderCarrier]) + verify(mockAuditService).audit(refEq(AppPrivacyPolicyUrlChanged), any[Map[String, String]])(any[HeaderCarrier]) + } + } + + "add collaborator" should { + val admin: String = "admin@example.com" + val admin2: String = "admin2@example.com" + val email: String = "test@example.com" + val adminsToEmail = Set(admin2) + + def collaboratorRequest(admin: String = admin, email: String = email, role: Role = DEVELOPER, isRegistered: Boolean = false, adminsToEmail: Set[String] = adminsToEmail) = { + AddCollaboratorRequest(admin, Collaborator(email, role), isRegistered, adminsToEmail) + } + + val applicationId = UUID.randomUUID() + val applicationData = anApplicationData(applicationId) + val userResponse = UserResponse(email, "John", "Bloggs", DateTime.now(), DateTime.now()) + val request = collaboratorRequest() + val expected = applicationData.copy(collaborators = applicationData.collaborators + request.collaborator) + + "throw notFoundException if no application exists in the repository for the given application id" in new Setup { + mockApplicationRepositoryFetchToReturn(applicationId, None) + + intercept[NotFoundException](await(underTest.addCollaborator(applicationId, request))) + + verify(mockApplicationRepository, never()).save(any[ApplicationData]) + } + + "update collaborators when application exists in the repository for the given application id" in new Setup { + + mockApplicationRepositoryFetchToReturn(applicationId, Some(applicationData)) + mockApplicationRepositorySaveToReturn(successful(expected)) + + private val addRequest = collaboratorRequest(isRegistered = true) + val result = await(underTest.addCollaborator(applicationId, addRequest)) + + verify(mockApplicationRepository).save(expected) + verify(mockAuditService).audit(CollaboratorAdded, + AuditHelper.applicationId(applicationId) ++ CollaboratorAdded.details(addRequest.collaborator)) + result shouldBe AddCollaboratorResponse(registeredUser = true) + } + + "send confirmation and notification emails to the developer and all relevant administrators when adding a registered collaborator" in new Setup { + + val collaborators = Set( + Collaborator(admin, ADMINISTRATOR), + Collaborator(admin2, ADMINISTRATOR), + Collaborator("dev@example.com", DEVELOPER)) + val applicationData = anApplicationData(applicationId = applicationId, collaborators = collaborators) + val expected = applicationData.copy(collaborators = applicationData.collaborators + request.collaborator) + + mockApplicationRepositoryFetchToReturn(applicationId, successful(Some(applicationData))) + mockApplicationRepositorySaveToReturn(successful(expected)) + + + val result = await(underTest.addCollaborator(applicationId, collaboratorRequest(isRegistered = true))) + + verify(mockApplicationRepository).save(expected) + + verify(mockEmailConnector, Mockito.timeout(1000)).sendAddedCollaboratorConfirmation("developer", applicationData.name, Set(email)) + verify(mockEmailConnector, Mockito.timeout(1000)).sendAddedCollaboratorNotification(email, "developer", applicationData.name, adminsToEmail) + result shouldBe AddCollaboratorResponse(registeredUser = true) + } + + "send confirmation and notification emails to the developer and all relevant administrators when adding an unregistered collaborator" in new Setup { + + val collaborators = Set( + Collaborator(admin, ADMINISTRATOR), + Collaborator(admin2, ADMINISTRATOR), + Collaborator("dev@example.com", DEVELOPER)) + val applicationData = anApplicationData(applicationId = applicationId, collaborators = collaborators) + val expected = applicationData.copy(collaborators = applicationData.collaborators + request.collaborator) + + mockApplicationRepositoryFetchToReturn(applicationId, successful(Some(applicationData))) + mockApplicationRepositorySaveToReturn(successful(expected)) + + + val result = await(underTest.addCollaborator(applicationId, collaboratorRequest())) + + verify(mockApplicationRepository).save(expected) + verify(mockEmailConnector, Mockito.timeout(1000)).sendAddedCollaboratorConfirmation("developer", applicationData.name, Set(email)) + verify(mockEmailConnector, Mockito.timeout(1000)).sendAddedCollaboratorNotification(email, "developer", applicationData.name, adminsToEmail) + result shouldBe AddCollaboratorResponse(registeredUser = false) + } + + "send email confirmation to the developer and no notifications when there are no admins to email" in new Setup { + + val admin = "theonlyadmin@example.com" + val collaborators = Set( + Collaborator(admin, ADMINISTRATOR), + Collaborator("dev@example.com", DEVELOPER)) + val applicationData = anApplicationData(applicationId = applicationId, collaborators = collaborators) + val expected = applicationData.copy(collaborators = applicationData.collaborators + request.collaborator) + + mockApplicationRepositoryFetchToReturn(applicationId, successful(Some(applicationData))) + mockApplicationRepositorySaveToReturn(successful(expected)) + + + val result = await(underTest.addCollaborator(applicationId, collaboratorRequest(admin = admin, isRegistered = true, adminsToEmail = Set.empty[String]))) + + verify(mockApplicationRepository).save(expected) + verify(mockEmailConnector, Mockito.timeout(1000)).sendAddedCollaboratorConfirmation("developer", applicationData.name, Set(email)) + verifyNoMoreInteractions(mockEmailConnector) + result shouldBe AddCollaboratorResponse(registeredUser = true) + } + + "handle an unexpected failure when sending confirmation email" in new Setup { + val collaborators = Set( + Collaborator(admin, ADMINISTRATOR), + Collaborator("dev@example.com", DEVELOPER)) + val applicationData = anApplicationData(applicationId = applicationId, collaborators = collaborators) + val expected = applicationData.copy(collaborators = applicationData.collaborators + request.collaborator) + + mockApplicationRepositoryFetchToReturn(applicationId, successful(Some(applicationData))) + mockApplicationRepositorySaveToReturn(successful(expected)) + + when(mockEmailConnector.sendAddedCollaboratorConfirmation(any(), any(), any())(any())).thenReturn(failed(new RuntimeException)) + + val result = await(underTest.addCollaborator(applicationId, collaboratorRequest(isRegistered = true))) + + verify(mockApplicationRepository).save(expected) + verify(mockEmailConnector).sendAddedCollaboratorConfirmation(any(), any(), any())(any()) + result shouldBe AddCollaboratorResponse(registeredUser = true) + } + + "throw UserAlreadyPresent error when adding an existing collaborator with the same role" in new Setup { + mockApplicationRepositoryFetchToReturn(applicationId, Some(applicationData)) + + intercept[UserAlreadyExists](await(underTest.addCollaborator(applicationId, collaboratorRequest(email = loggedInUser, role = ADMINISTRATOR)))) + + verify(mockApplicationRepository, never()).save(any[ApplicationData]) + } + + "throw UserAlreadyPresent error when adding an existing collaborator with different role" in new Setup { + mockApplicationRepositoryFetchToReturn(applicationId, Some(applicationData)) + + intercept[UserAlreadyExists](await(underTest.addCollaborator(applicationId, collaboratorRequest(email = loggedInUser, role = DEVELOPER)))) + + verify(mockApplicationRepository, never()).save(any[ApplicationData]) + } + + "throw UserAlreadyPresent error when adding an existing collaborator with different case" in new Setup { + mockApplicationRepositoryFetchToReturn(applicationId, Some(applicationData)) + + intercept[UserAlreadyExists](await(underTest.addCollaborator(applicationId, collaboratorRequest(email = loggedInUser.toUpperCase, role = DEVELOPER)))) + + verify(mockApplicationRepository, never()).save(any[ApplicationData]) + } + } + + "delete collaborator" should { + val applicationId = UUID.randomUUID() + val admin = "admin@example.com" + val admin2: String = "admin2@example.com" + val collaborator = "test@example.com" + val adminsToEmail = Set(admin2) + val collaborators = Set( + Collaborator(admin, ADMINISTRATOR), + Collaborator(admin2, ADMINISTRATOR), + Collaborator(collaborator, DEVELOPER)) + val applicationData = anApplicationData(applicationId = applicationId, collaborators = collaborators) + val updatedData = applicationData.copy(collaborators = applicationData.collaborators - Collaborator(collaborator, DEVELOPER)) + + "throw not found exception when no application exists in the repository for the given application id" in new Setup { + + mockApplicationRepositoryFetchToReturn(applicationId, None) + + intercept[NotFoundException](await(underTest.deleteCollaborator(applicationId, collaborator, admin, adminsToEmail))) + verify(mockApplicationRepository, never()).save(any[ApplicationData]) + verifyZeroInteractions(mockEmailConnector) + } + + "remove collaborator and send confirmation and notification emails" in new Setup { + + + mockApplicationRepositoryFetchToReturn(applicationId, Some(applicationData)) + mockApplicationRepositorySaveToReturn(successful(updatedData)) + + val result = await(underTest.deleteCollaborator(applicationId, collaborator, admin, adminsToEmail)) + + verify(mockApplicationRepository).save(updatedData) + verify(mockEmailConnector, Mockito.timeout(1000)).sendRemovedCollaboratorConfirmation(applicationData.name, Set(collaborator)) + verify(mockEmailConnector, Mockito.timeout(1000)).sendRemovedCollaboratorNotification(collaborator, applicationData.name, adminsToEmail) + verify(mockAuditService).audit(CollaboratorRemoved, + AuditHelper.applicationId(applicationId) ++ CollaboratorRemoved.details(Collaborator(collaborator, DEVELOPER))) + result shouldBe updatedData.collaborators + } + + "remove collaborator with email address in different case" in new Setup { + + mockApplicationRepositoryFetchToReturn(applicationId, Some(applicationData)) + mockApplicationRepositorySaveToReturn(successful(updatedData)) + + val result = await(underTest.deleteCollaborator(applicationId, collaborator.toUpperCase, admin, adminsToEmail)) + + verify(mockApplicationRepository).save(updatedData) + verify(mockEmailConnector, Mockito.timeout(1000)).sendRemovedCollaboratorConfirmation(applicationData.name, Set(collaborator)) + verify(mockEmailConnector, Mockito.timeout(1000)).sendRemovedCollaboratorNotification(collaborator, applicationData.name, adminsToEmail) + result shouldBe updatedData.collaborators + } + + "fail to delete last remaining admin user" in new Setup { + val collaborators = Set( + Collaborator(admin, ADMINISTRATOR), + Collaborator(collaborator, DEVELOPER)) + val applicationData = anApplicationData(applicationId = applicationId, collaborators = collaborators) + mockApplicationRepositoryFetchToReturn(applicationId, Some(applicationData)) + + intercept[ApplicationNeedsAdmin](await(underTest.deleteCollaborator(applicationId, admin, admin, adminsToEmail))) + verify(mockApplicationRepository, never()).save(any[ApplicationData]) + verifyZeroInteractions(mockEmailConnector) + } + } + + "fetchByClientId" should { + + "return none when no application exists in the repository for the given client id" in new Setup { + + val clientId = "some-client-id" + when(mockApplicationRepository.fetchByClientId(clientId)).thenReturn(None) + + val result = await(underTest.fetchByClientId(clientId)) + + result shouldBe None + } + + "return an application when it exists in the repository for the given client id" in new Setup { + + val applicationId = UUID.randomUUID() + val applicationData = anApplicationData(applicationId) + + when(mockApplicationRepository.fetchByClientId(applicationData.tokens.production.clientId)).thenReturn(Some(applicationData)) + when(mockWSO2APIStore.getSubscriptions(anyString(), anyString(), anyString())(any[HeaderCarrier])).thenReturn(successful(Nil)) + + val result = await(underTest.fetchByClientId(applicationData.tokens.production.clientId)) + + result.get.id shouldBe applicationId + result.get.environment shouldBe Some(PRODUCTION) + result.get.collaborators shouldBe applicationData.collaborators + result.get.createdOn shouldBe applicationData.createdOn + } + + "return an application with trusted flag when the application is in the whitelist" in new Setup { + + val applicationData = anApplicationData(trustedApplicationId1) + + when(mockAppContext.isTrusted(applicationData)).thenReturn(true) + + when(mockApplicationRepository.fetchByClientId(applicationData.tokens.production.clientId)).thenReturn(Some(applicationData)) + when(mockWSO2APIStore.getSubscriptions(anyString(), anyString(), anyString())(any[HeaderCarrier])).thenReturn(successful(Nil)) + + val result = await(underTest.fetchByClientId(applicationData.tokens.production.clientId)) + + result.get.trusted shouldBe true + } + + } + + "fetchByServerToken" should { + + val serverToken = "b3c83934c02df8b111e7f9f8700000" + + "return none when no application exists in the repository for the given server token" in new Setup { + + when(mockApplicationRepository.fetchByServerToken(serverToken)).thenReturn(None) + + val result = await(underTest.fetchByServerToken(serverToken)) + + result shouldBe None + } + + "return an application when it exists in the repository for the given server token" in new Setup { + + val productionToken = EnvironmentToken("aaa", "wso2Secret", serverToken, Seq(aSecret("secret1"), aSecret("secret2"))) + val sandboxToken = EnvironmentToken("111", "wso2SandboxSecret", "000", Seq(aSecret("secret3"), aSecret("secret4"))) + + val applicationId = UUID.randomUUID() + val applicationData = anApplicationData(applicationId).copy(tokens = ApplicationTokens(production = productionToken, sandbox = sandboxToken)) + + when(mockApplicationRepository.fetchByServerToken(serverToken)).thenReturn(Some(applicationData)) + when(mockWSO2APIStore.getSubscriptions(anyString(), anyString(), anyString())(any[HeaderCarrier])).thenReturn(successful(Nil)) + + val result = await(underTest.fetchByServerToken(serverToken)) + + result.get.id shouldBe applicationId + result.get.collaborators shouldBe applicationData.collaborators + result.get.createdOn shouldBe applicationData.createdOn + } + } + + "fetchAllForCollaborator" should { + + "fetch all applications for a given collaborator email address" in new Setup { + val applicationId = UUID.randomUUID() + val emailAddress = "user@example.com" + val standardApplicationData = anApplicationData(applicationId, access = Standard()) + val privilegedApplicationData = anApplicationData(applicationId, access = Privileged()) + val ropcApplicationData = anApplicationData(applicationId, access = Ropc()) + + when(mockApplicationRepository.fetchAllForEmailAddress(emailAddress)) + .thenReturn(successful(Seq(standardApplicationData, privilegedApplicationData, ropcApplicationData))) + when(mockWSO2APIStore.getAllSubscriptions(anyString(), anyString())(any[HeaderCarrier])) + .thenReturn(successful(Map.empty[String, Seq[APIIdentifier]])) + + await(underTest.fetchAllForCollaborator(emailAddress)).size shouldBe 3 + } + + } + + "fetchAllBySubscription" should { + + "return applications for a given subscription to an API context" in new Setup { + + val applicationId = UUID.randomUUID() + val apiContext = "some-context" + val applicationData = anApplicationData(applicationId) + + when(mockApplicationRepository.fetchAllForContext(apiContext)).thenReturn(successful(Seq(applicationData))) + val result = await(underTest.fetchAllBySubscription(apiContext)) + + result.size shouldBe 1 + result shouldBe Seq(applicationData).map(app => ApplicationResponse(data = app, clientId = None, trusted = false)) + } + + "return no matching applications for a given subscription to an API context" in new Setup { + + val applicationId = UUID.randomUUID() + val apiContext = "some-context" + val applicationData = anApplicationData(applicationId) + + when(mockApplicationRepository.fetchAllForContext(apiContext)).thenReturn(successful(Nil)) + val result = await(underTest.fetchAllBySubscription(apiContext)) + + result.size shouldBe 0 + } + + "return applications for a given subscription to an API identifier" in new Setup { + + val applicationId = UUID.randomUUID() + val apiIdentifier = APIIdentifier("some-context", "some-version") + val applicationData = anApplicationData(applicationId) + + when(mockApplicationRepository.fetchAllForApiIdentifier(apiIdentifier)).thenReturn(successful(Seq(applicationData))) + val result = await(underTest.fetchAllBySubscription(apiIdentifier)) + + result.size shouldBe 1 + result shouldBe Seq(applicationData).map(app => ApplicationResponse(data = app, clientId = None, trusted = false)) + } + + "return no matching applications for a given subscription to an API identifier" in new Setup { + + val applicationId = UUID.randomUUID() + val apiIdentifier = APIIdentifier("some-context", "some-version") + val applicationData = anApplicationData(applicationId) + + when(mockApplicationRepository.fetchAllForApiIdentifier(apiIdentifier)).thenReturn(successful(Nil)) + val result = await(underTest.fetchAllBySubscription(apiIdentifier)) + + result.size shouldBe 0 + } + } + + "fetchAllWithNoSubscriptions" should { + + "return no matching applications if application has a subscription" in new Setup { + + val applicationId = UUID.randomUUID() + val apiContext = "some-context" + val applicationData = anApplicationData(applicationId) + + when(mockApplicationRepository.fetchAllWithNoSubscriptions()).thenReturn(successful(Nil)) + val result = await(underTest.fetchAllWithNoSubscriptions()) + + result.size shouldBe 0 + } + + "return applications when there are no matching subscriptions" in new Setup { + + val applicationId = UUID.randomUUID() + val apiContext = "some-context" + val applicationData = anApplicationData(applicationId) + + when(mockApplicationRepository.fetchAllWithNoSubscriptions()).thenReturn(successful(Seq(applicationData))) + val result = await(underTest.fetchAllWithNoSubscriptions()) + + result.size shouldBe 1 + result shouldBe Seq(applicationData).map(app => ApplicationResponse(data = app, clientId = None, trusted = false)) + } + } + + "verifyUplift" should { + val applicationId = UUID.randomUUID() + val upliftRequestedBy = "email@example.com" + + "update the state of the application when application is in pendingRequesterVerification state" in new Setup { + val expectedStateHistory = StateHistory(applicationId, State.PRODUCTION, Actor(upliftRequestedBy, COLLABORATOR), Some(PENDING_REQUESTER_VERIFICATION)) + val upliftRequest = StateHistory(applicationId, PENDING_GATEKEEPER_APPROVAL, Actor(upliftRequestedBy, COLLABORATOR), Some(TESTING)) + + val application = anApplicationData(applicationId, pendingRequesterVerificationState(upliftRequestedBy)) + + val expectedApplication = application.copy(state = productionState(upliftRequestedBy)) + + when(mockApplicationRepository.fetchVerifiableUpliftBy(generatedVerificationCode)).thenReturn(Some(application)) + when(mockStateHistoryRepository.fetchLatestByStateForApplication(applicationId, PENDING_GATEKEEPER_APPROVAL)).thenReturn(Some(upliftRequest)) + + val result = await(underTest.verifyUplift(generatedVerificationCode)) + verify(mockApplicationRepository).save(expectedApplication) + result shouldBe UpliftVerified + verify(mockStateHistoryRepository).insert(expectedStateHistory) + } + + "fail if the application save fails" in new Setup { + val application = anApplicationData(applicationId, pendingRequesterVerificationState(upliftRequestedBy)) + val saveException = new RuntimeException("application failed to save") + + when(mockApplicationRepository.fetchVerifiableUpliftBy(generatedVerificationCode)).thenReturn(Some(application)) + mockApplicationRepositorySaveToReturn(failed(saveException)) + + intercept[RuntimeException] { + await(underTest.verifyUplift(generatedVerificationCode)) + } + } + + "rollback if saving the state history fails" in new Setup { + val application = anApplicationData(applicationId, pendingRequesterVerificationState(upliftRequestedBy)) + + when(mockApplicationRepository.fetchVerifiableUpliftBy(generatedVerificationCode)).thenReturn(Some(application)) + when(mockStateHistoryRepository.insert(any())).thenReturn(failed(new RuntimeException("Expected test failure"))) + + intercept[RuntimeException] { + await(underTest.verifyUplift(generatedVerificationCode)) + } + + verify(mockApplicationRepository).save(application) + } + + "not update the state but result in success of the application when application is already in production state" in new Setup { + val application = anApplicationData(applicationId, productionState(upliftRequestedBy)) + + when(mockApplicationRepository.fetchVerifiableUpliftBy(generatedVerificationCode)).thenReturn(Some(application)) + + val result = await(underTest.verifyUplift(generatedVerificationCode)) + verify(mockApplicationRepository, times(0)).save(any[ApplicationData]) + result shouldBe UpliftVerified + } + + "fail when application is in testing state" in new Setup { + val application = anApplicationData(applicationId, testingState()) + + when(mockApplicationRepository.fetchVerifiableUpliftBy(generatedVerificationCode)).thenReturn(Some(application)) + intercept[InvalidUpliftVerificationCode] { + await(underTest.verifyUplift(generatedVerificationCode)) + } + } + + "fail when application is in pendingGatekeeperApproval state" in new Setup { + val application = anApplicationData(applicationId, pendingGatekeeperApprovalState(upliftRequestedBy)) + + when(mockApplicationRepository.fetchVerifiableUpliftBy(generatedVerificationCode)).thenReturn(Some(application)) + intercept[InvalidUpliftVerificationCode] { + await(underTest.verifyUplift(generatedVerificationCode)) + } + } + + "fail when application is not found by verification code" in new Setup { + val application = anApplicationData(applicationId, pendingGatekeeperApprovalState(upliftRequestedBy)) + + when(mockApplicationRepository.fetchVerifiableUpliftBy(generatedVerificationCode)).thenReturn(None) + intercept[InvalidUpliftVerificationCode] { + await(underTest.verifyUplift(generatedVerificationCode)) + } + } + } + + "requestUplift" should { + val applicationId = UUID.randomUUID() + val requestedName = "application name" + val upliftRequestedBy = "email@example.com" + + "update the state of the application" in new Setup { + val application = anApplicationData(applicationId, testingState()) + val expectedApplication = application.copy(state = pendingGatekeeperApprovalState(upliftRequestedBy), + name = requestedName, normalisedName = requestedName.toLowerCase) + val expectedStateHistory = StateHistory(applicationId = expectedApplication.id, state = PENDING_GATEKEEPER_APPROVAL, + actor = Actor(upliftRequestedBy, COLLABORATOR), previousState = Some(TESTING)) + + mockApplicationRepositoryFetchToReturn(applicationId, Some(application)) + when(mockApplicationRepository.fetchNonTestingApplicationByName(requestedName)).thenReturn(None) + + val result = await(underTest.requestUplift(applicationId, requestedName, upliftRequestedBy)) + + verify(mockApplicationRepository).save(expectedApplication) + result shouldBe UpliftRequested + verify(mockStateHistoryRepository).insert(expectedStateHistory) + } + + "rollback the application when storing the state history fails" in new Setup { + val application = anApplicationData(applicationId, testingState()) + + mockApplicationRepositoryFetchToReturn(applicationId, Some(application)) + when(mockApplicationRepository.fetchNonTestingApplicationByName(requestedName)).thenReturn(None) + when(mockStateHistoryRepository.insert(any())).thenReturn(failed(new RuntimeException("Expected test failure"))) + + intercept[RuntimeException] { + await(underTest.requestUplift(applicationId, requestedName, upliftRequestedBy)) + } + + verify(mockApplicationRepository).save(application) + } + + "send an Audit event when an application uplift is successfully requested with no name change" in new Setup { + val application = anApplicationData(applicationId, testingState()) + val expectedApplication = application.copy(state = pendingGatekeeperApprovalState(upliftRequestedBy), + name = requestedName, normalisedName = requestedName.toLowerCase) + + + mockApplicationRepositoryFetchToReturn(applicationId, Some(application)) + when(mockApplicationRepository.fetchNonTestingApplicationByName(application.name)).thenReturn(None) + + val result = await(underTest.requestUplift(applicationId, application.name, upliftRequestedBy)) + verify(mockAuditService).audit(ApplicationUpliftRequested, Map("applicationId" -> application.id.toString)) + } + + "send an Audit event when an application uplift is successfully requested with a name change" in new Setup { + val application = anApplicationData(applicationId, testingState()) + val expectedApplication = application.copy(state = pendingGatekeeperApprovalState(upliftRequestedBy), + name = requestedName, normalisedName = requestedName.toLowerCase) + + + mockApplicationRepositoryFetchToReturn(applicationId, Some(application)) + when(mockApplicationRepository.fetchNonTestingApplicationByName(requestedName)).thenReturn(None) + + val result = await(underTest.requestUplift(applicationId, requestedName, upliftRequestedBy)) + + val expectedAuditDetails = Map("applicationId" -> application.id.toString, "newApplicationName" -> requestedName) + verify(mockAuditService).audit(ApplicationUpliftRequested, expectedAuditDetails) + } + + "fail with InvalidStateTransition without invoking fetchNonTestingApplicationByName when the application is not in testing" in new Setup { + val application = anApplicationData(applicationId, pendingGatekeeperApprovalState("test@example.com")) + + mockApplicationRepositoryFetchToReturn(applicationId, Some(application)) + + intercept[InvalidStateTransition] { + await(underTest.requestUplift(applicationId, requestedName, upliftRequestedBy)) + } + verify(mockApplicationRepository, never).fetchNonTestingApplicationByName(requestedName) + } + + "fail with ApplicationAlreadyExists when another uplifted application already exist with the same name" in new Setup { + val application = anApplicationData(applicationId, testingState()) + val anotherApplication = anApplicationData(UUID.randomUUID(), productionState("admin@example.com")) + + mockApplicationRepositoryFetchToReturn(applicationId, Some(application)) + when(mockApplicationRepository.fetchNonTestingApplicationByName(requestedName)).thenReturn(Some(anotherApplication)) + + intercept[ApplicationAlreadyExists] { + await(underTest.requestUplift(applicationId, requestedName, upliftRequestedBy)) + } + val expectedAuditDetails = Map("applicationId" -> application.id.toString, "applicationName" -> requestedName) + verify(mockAuditService).audit(ApplicationUpliftRequestDeniedDueToNonUniqueName, expectedAuditDetails) + } + + "propagate the exception when the repository fail" in new Setup { + + mockApplicationRepositoryFetchToReturn(applicationId, failed(new RuntimeException("Expected test failure"))) + + intercept[RuntimeException] { + await(underTest.requestUplift(applicationId, requestedName, upliftRequestedBy)) + } + } + } + + "update rate limit tier" should { + + val uuid: UUID = UUID.randomUUID() + val originalApplicationData: ApplicationData = anApplicationData(uuid) + val updatedApplicationData: ApplicationData = originalApplicationData copy (rateLimitTier = Some(SILVER)) + val apiIdentifier: APIIdentifier = APIIdentifier("myContext", "myVersion") + val anotherApiIdentifier: APIIdentifier = APIIdentifier("myContext-2", "myVersion-2") + + "update the application in wso2 and mongo, and re-subscribe to the apis" in new Setup { + + mockApplicationRepositoryFetchToReturn(uuid, Some(originalApplicationData)) + mockApplicationRepositorySaveToReturn(updatedApplicationData) + mockWso2ApiStoreGetSubscriptionsToReturn(Seq(apiIdentifier, anotherApiIdentifier)) + + when(mockWSO2APIStore.checkApplicationRateLimitTier(originalApplicationData.wso2Username, originalApplicationData.wso2Username, + originalApplicationData.wso2Password, SILVER)).thenReturn(successful(HasSucceeded)) + + await(underTest updateRateLimitTier(uuid, SILVER)) + + verify(mockWSO2APIStore) updateApplication(originalApplicationData.wso2Username, originalApplicationData.wso2Password, + originalApplicationData.wso2ApplicationName, SILVER) + + verify(mockWSO2APIStore) resubscribeApi(Seq(apiIdentifier, anotherApiIdentifier), originalApplicationData.wso2Username, + originalApplicationData.wso2Password, originalApplicationData.wso2ApplicationName, apiIdentifier, SILVER) + verify(mockWSO2APIStore) resubscribeApi(Seq(apiIdentifier, anotherApiIdentifier), originalApplicationData.wso2Username, + originalApplicationData.wso2Password, originalApplicationData.wso2ApplicationName, anotherApiIdentifier, SILVER) + + verify(mockApplicationRepository) save updatedApplicationData + } + + "fail fast when retrieving application data fails" in new Setup { + + mockApplicationRepositoryFetchToReturn(uuid, failed(new RuntimeException)) + mockWso2ApiStoreGetSubscriptionsToReturn(Seq(apiIdentifier)) + + intercept[RuntimeException] { + await(underTest updateRateLimitTier(uuid, SILVER)) + } + + verify(mockWSO2APIStore, never).updateApplication(anyString, anyString, anyString, any[RateLimitTier])(any[HeaderCarrier]) + verify(mockWSO2APIStore, never).resubscribeApi(any[Seq[APIIdentifier]], anyString, anyString, anyString, + any[APIIdentifier], any[RateLimitTier])(any[HeaderCarrier]) + verify(mockApplicationRepository, never) save updatedApplicationData + } + + "fail fast when wso2 application update fails" in new Setup { + + mockApplicationRepositoryFetchToReturn(uuid, Some(originalApplicationData)) + mockWso2ApiStoreUpdateApplicationToReturn(failed(new RuntimeException)) + mockWso2ApiStoreGetSubscriptionsToReturn(Seq(apiIdentifier)) + + intercept[RuntimeException] { + await(underTest updateRateLimitTier(uuid, SILVER)) + } + + verify(mockWSO2APIStore).updateApplication(anyString, anyString, anyString, any[RateLimitTier])(any[HeaderCarrier]) + verify(mockWSO2APIStore, never).resubscribeApi(any[Seq[APIIdentifier]], anyString, anyString, anyString, + any[APIIdentifier], any[RateLimitTier])(any[HeaderCarrier]) + verify(mockApplicationRepository, never) save updatedApplicationData + } + + "fail when wso2 resubscribe fails, updating the application in wso2, but leaving the Mongo application in a wrong state" in new Setup { + + mockApplicationRepositoryFetchToReturn(uuid, Some(originalApplicationData)) + mockWso2ApiStoreGetSubscriptionsToReturn(Seq(apiIdentifier)) + mockWso2SubscribeToReturn(failed(new RuntimeException)) + + intercept[RuntimeException] { + await(underTest updateRateLimitTier(uuid, SILVER)) + } + + verify(mockWSO2APIStore).updateApplication(anyString, anyString, anyString, any[RateLimitTier])(any[HeaderCarrier]) + verify(mockApplicationRepository, never) save updatedApplicationData + } + + "fail when one single api fails to resubscribe in wso2, updating the application in wso2, but leaving the Mongo application and some APIs (in wso2) in a wrong state" in new Setup { + + mockApplicationRepositoryFetchToReturn(uuid, Some(originalApplicationData)) + mockApplicationRepositorySaveToReturn(updatedApplicationData) + mockWso2ApiStoreGetSubscriptionsToReturn(Seq(apiIdentifier, anotherApiIdentifier)) + + when(mockWSO2APIStore.checkApplicationRateLimitTier(originalApplicationData.wso2Username, originalApplicationData.wso2Password, + originalApplicationData.wso2ApplicationName, SILVER)).thenReturn(successful(HasSucceeded)) + + when(mockWSO2APIStore.resubscribeApi(Seq(apiIdentifier, anotherApiIdentifier), originalApplicationData.wso2Username, originalApplicationData.wso2Password, + originalApplicationData.wso2ApplicationName, apiIdentifier, SILVER)).thenReturn(successful(HasSucceeded)) + when(mockWSO2APIStore.resubscribeApi(Seq(apiIdentifier, anotherApiIdentifier), originalApplicationData.wso2Username, originalApplicationData.wso2Password, + originalApplicationData.wso2ApplicationName, anotherApiIdentifier, SILVER)).thenReturn(failed(new RuntimeException)) + + intercept[RuntimeException] { + await(underTest updateRateLimitTier(uuid, SILVER)) + } + + verify(mockWSO2APIStore).updateApplication(originalApplicationData.wso2Username, originalApplicationData.wso2Password, + originalApplicationData.wso2ApplicationName, SILVER) + + verify(mockWSO2APIStore).resubscribeApi(Seq(apiIdentifier, anotherApiIdentifier), originalApplicationData.wso2Username, + originalApplicationData.wso2Password, originalApplicationData.wso2ApplicationName, apiIdentifier, SILVER) + verify(mockWSO2APIStore).resubscribeApi(Seq(apiIdentifier, anotherApiIdentifier), originalApplicationData.wso2Username, + originalApplicationData.wso2Password, originalApplicationData.wso2ApplicationName, anotherApiIdentifier, SILVER) + + verify(mockApplicationRepository, never) save updatedApplicationData + } + } + + private def aNewApplicationRequest(access: Access = Standard(), environment: Environment = Environment.PRODUCTION) = { + CreateApplicationRequest("MyApp", access, Some("description"), environment, + Set(Collaborator(loggedInUser, ADMINISTRATOR))) + } + + private def anExistingApplicationRequest() = { + CreateApplicationRequest( + "My Application", + access = Standard( + redirectUris = Seq("http://example.com/redirect"), + termsAndConditionsUrl = Some("http://example.com/terms"), + privacyPolicyUrl = Some("http://example.com/privacy"), + overrides = Set.empty + ), + Some("Description"), + environment = Environment.PRODUCTION, + Set( + Collaborator(loggedInUser, ADMINISTRATOR), + Collaborator("dev@example.com", DEVELOPER))) + } + + private val requestedByEmail = "john.smith@example.com" + + private def anApplicationData(applicationId: UUID, state: ApplicationState = productionState(requestedByEmail), + collaborators: Set[Collaborator] = Set(Collaborator(loggedInUser, ADMINISTRATOR)), + access: Access = Standard(), + rateLimitTier: Option[RateLimitTier] = Some(RateLimitTier.BRONZE), + environment: Environment = Environment.PRODUCTION) = { + ApplicationData( + applicationId, + "MyApp", + "myapp", + collaborators, + Some("description"), + "aaaaaaaaaa", + "aaaaaaaaaa", + "aaaaaaaaaa", + ApplicationTokens(productionToken, sandboxToken), state, access, rateLimitTier = rateLimitTier, + environment = environment.toString) + } +} diff --git a/test/unit/services/AuditServiceSpec.scala b/test/unit/services/AuditServiceSpec.scala new file mode 100644 index 000000000..b89d5ca1f --- /dev/null +++ b/test/unit/services/AuditServiceSpec.scala @@ -0,0 +1,187 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 unit.services + +import java.util.UUID + +import common.uk.gov.hmrc.testutils.ApplicationStateUtil +import org.mockito.ArgumentMatcher +import org.mockito.Matchers._ +import org.mockito.Mockito._ +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.mockito.MockitoSugar +import uk.gov.hmrc.http.HeaderCarrier +import uk.gov.hmrc.models.Role._ +import uk.gov.hmrc.models._ +import uk.gov.hmrc.play.audit.AuditExtensions.auditHeaderCarrier +import uk.gov.hmrc.play.audit.http.connector.AuditConnector +import uk.gov.hmrc.play.audit.model.DataEvent +import uk.gov.hmrc.play.test.UnitSpec +import uk.gov.hmrc.services.AuditAction._ +import uk.gov.hmrc.services.{AuditHelper, AuditService} +import uk.gov.hmrc.util.http.HttpHeaders._ + +import scala.concurrent.ExecutionContext + +class AuditServiceSpec extends UnitSpec with ScalaFutures with MockitoSugar with ApplicationStateUtil { + + class Setup { + val mockAuditConnector = mock[AuditConnector] + val auditService = new AuditService(mockAuditConnector) + } + + def isSameDataEvent(expected: DataEvent) = + new ArgumentMatcher[DataEvent] { + override def matches(actual: Object) = actual match { + case de: DataEvent => + de.auditSource == expected.auditSource && + de.auditType == expected.auditType && + de.tags == expected.tags && + de.detail == expected.detail + } + } + + "AuditService audit" should { + "pass through data to underlying auditConnector" in new Setup { + val data = Map("some-header" -> "la-di-dah") + implicit val hc = HeaderCarrier() + + val event = DataEvent( + auditSource = "third-party-application", + auditType = AppCreated.auditType, + tags = hc.toAuditTags(AppCreated.name, "-"), + detail = hc.toAuditDetails(data.toSeq: _*) + ) + + auditService.audit(AppCreated, data) + verify(auditService.auditConnector).sendEvent(argThat(isSameDataEvent(event)))(any[HeaderCarrier], any[ExecutionContext]) + } + + "add user context where it exists in the header carrier" in new Setup { + val data = Map("some-header" -> "la-di-dah") + val email = "test@example.com" + val name = "John Smith" + implicit val hc = HeaderCarrier().withExtraHeaders( + LOGGED_IN_USER_EMAIL_HEADER -> email, + LOGGED_IN_USER_NAME_HEADER -> name + ) + + val event = DataEvent( + auditSource = "third-party-application", + auditType = AppCreated.auditType, + tags = hc.toAuditTags(AppCreated.name, "-"), + detail = hc.toAuditDetails(data.toSeq: _*) + ) + + val expected = event.copy( + tags = event.tags ++ Map( + "developerEmail" -> email, + "developerFullName" -> name + ) + ) + + auditService.audit(AppCreated, data) + verify(auditService.auditConnector).sendEvent(argThat(isSameDataEvent(expected)))(any[HeaderCarrier], any[ExecutionContext]) + } + + "add as much user context as possible where only partial data exists" in new Setup { + val data = Map("some-header" -> "la-di-dah") + val email = "test@example.com" + val name = "John Smith" + implicit val emailHc = HeaderCarrier().withExtraHeaders(LOGGED_IN_USER_EMAIL_HEADER -> email) + + val event = DataEvent( + auditSource = "third-party-application", + auditType = AppCreated.auditType, + tags = emailHc.toAuditTags(AppCreated.name, "-"), + detail = emailHc.toAuditDetails(data.toSeq: _*) + ) + + val expected = event.copy( + tags = event.tags ++ Map( + "developerEmail" -> email + ) + ) + + auditService.audit(AppCreated, data) + verify(auditService.auditConnector).sendEvent(argThat(isSameDataEvent(expected)))(any[HeaderCarrier], any[ExecutionContext]) + } + } + + "AuditHelper calculateAppChanges" should { + + val id = UUID.randomUUID() + val admin = Collaborator("test@example.com", ADMINISTRATOR) + val tokens = ApplicationTokens( + EnvironmentToken("prodId", "prodSecret", "prodToken"), + EnvironmentToken("sandboxId", "sandboxSecret", "sandboxToken") + ) + val previousApp = ApplicationData( + id = id, + name = "app name", + normalisedName = "app name", + collaborators = Set(admin), + wso2Password = "wso2Password", + wso2ApplicationName = "wso2ApplicationName", + wso2Username = "wso2Username", + tokens = tokens, + state = testingState() + ) + + val updatedApp = previousApp.copy( + name = "new name", + access = Standard( + Seq("http://new-url.example.com", "http://new-url.example.com/other-redirect"), + Some("http://new-url.example.com/terms-and-conditions"), + Some("http://new-url.example.com/privacy-policy") + ) + ) + + val commonAuditData = Map( + "applicationId" -> id.toString + ) + + val appNameAudit = + AppNameChanged -> + (Map("newApplicationName" -> "new name") ++ commonAuditData) + + val appPrivacyAudit = + AppPrivacyPolicyUrlChanged -> + (Map("newPrivacyPolicyUrl" -> "http://new-url.example.com/privacy-policy") ++ commonAuditData) + + val appRedirectUrisAudit = + AppRedirectUrisChanged -> + (Map("newRedirectUris" -> "http://new-url.example.com,http://new-url.example.com/other-redirect") ++ commonAuditData) + + val appTermsAndConditionsUrlAudit = + AppTermsAndConditionsUrlChanged -> + (Map("newTermsAndConditionsUrl" -> "http://new-url.example.com/terms-and-conditions") ++ commonAuditData) + + "produce the audit events required by an application update" in { + AuditHelper.calculateAppChanges(previousApp, updatedApp) shouldEqual + Set(appNameAudit, appPrivacyAudit, appRedirectUrisAudit, appTermsAndConditionsUrlAudit) + } + + "only produce audit events if the fields have been updated" in { + val partiallyUpdatedApp = previousApp.copy( + name = updatedApp.name + ) + + AuditHelper.calculateAppChanges(previousApp, partiallyUpdatedApp) shouldEqual Set(appNameAudit) + } + } +} diff --git a/test/unit/services/CredentialServiceSpec.scala b/test/unit/services/CredentialServiceSpec.scala new file mode 100644 index 000000000..300d2990a --- /dev/null +++ b/test/unit/services/CredentialServiceSpec.scala @@ -0,0 +1,368 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 services + +import java.util.UUID +import java.util.concurrent.TimeUnit + +import common.uk.gov.hmrc.testutils.ApplicationStateUtil +import org.joda.time.DateTimeUtils +import org.mockito.ArgumentCaptor +import org.mockito.Matchers._ +import org.mockito.Mockito._ +import org.mockito.invocation.InvocationOnMock +import org.mockito.stubbing.Answer +import org.scalatest.BeforeAndAfterAll +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.mockito.MockitoSugar +import play.api.libs.ws.WSResponse +import uk.gov.hmrc.config.AppContext +import uk.gov.hmrc.connector.EmailConnector +import uk.gov.hmrc.controllers.{ClientSecretRequest, ValidationRequest} +import uk.gov.hmrc.http.{HeaderCarrier, NotFoundException} +import uk.gov.hmrc.lock.LockKeeper +import uk.gov.hmrc.models.Environment._ +import uk.gov.hmrc.models.Role._ +import uk.gov.hmrc.models._ +import uk.gov.hmrc.play.audit.http.connector.AuditResult +import uk.gov.hmrc.play.test.UnitSpec +import uk.gov.hmrc.repository.{ApplicationRepository, StateHistoryRepository} +import uk.gov.hmrc.services.AuditAction._ +import uk.gov.hmrc.services._ +import uk.gov.hmrc.util.http.HttpHeaders._ + +import scala.concurrent.Future.successful +import scala.concurrent.duration.Duration +import scala.concurrent.{Await, ExecutionContext, Future} + +class CredentialServiceSpec extends UnitSpec with ScalaFutures with MockitoSugar with BeforeAndAfterAll with ApplicationStateUtil { + + trait Setup { + + lazy val locked = false + val mockWSO2APIStore = mock[WSO2APIStore] + val mockApplicationRepository = mock[ApplicationRepository] + val mockStateHistoryRepository = mock[StateHistoryRepository] + val mockAuditService = mock[AuditService] + val mockEmailConnector = mock[EmailConnector] + val response = mock[WSResponse] + + val mockAppContext = mock[AppContext] + when(mockAppContext.clientSecretLimit).thenReturn(5) + when(mockAppContext.trustedApplications).thenReturn(Seq.empty) + + val applicationResponseCreator = new ApplicationResponseCreator(mockAppContext) + + + implicit val hc = HeaderCarrier().withExtraHeaders( + LOGGED_IN_USER_EMAIL_HEADER -> loggedInUser, + LOGGED_IN_USER_NAME_HEADER -> "John Smith" + ) + + + val underTest = new CredentialService(mockApplicationRepository, mockAuditService, mockAppContext, applicationResponseCreator) + + when(mockApplicationRepository.save(any())).thenAnswer(new Answer[Future[ApplicationData]] { + override def answer(invocation: InvocationOnMock): Future[ApplicationData] = { + successful(invocation.getArguments()(0).asInstanceOf[ApplicationData]) + } + }) + } + + private def aSecret(secret: String): ClientSecret = { + ClientSecret(secret, secret) + } + + private val loggedInUser = "loggedin@example.com" + private val productionToken = EnvironmentToken("aaa", "bbb", "wso2Secret", Seq(aSecret("secret1"), aSecret("secret2"))) + private val sandboxToken = EnvironmentToken("111", "222", "wso2SandboxSecret", Seq(aSecret("secret3"), aSecret("secret4"))) + + trait LockedSetup extends Setup { + override lazy val locked = true + } + + class MockLockKeeper(locked: Boolean) extends LockKeeper { + override def repo = null + + override def lockId = "" + + val forceLockReleaseAfter = null + var callsMadeToLockKeeper: Int = 0 + + override def tryLock[T](body: => Future[T])(implicit ec: ExecutionContext): Future[Option[T]] = { + callsMadeToLockKeeper = callsMadeToLockKeeper + 1 + locked match { + case true => successful(None) + case false => successful(Some(Await.result(body, Duration(1, TimeUnit.SECONDS)))) + } + } + } + + override def beforeAll() { + DateTimeUtils.setCurrentMillisFixed(DateTimeUtils.currentTimeMillis()) + } + + override def afterAll() { + DateTimeUtils.setCurrentMillisSystem() + } + + "fetch credentials" should { + + "return none when no application exists in the repository for the given application id" in new Setup { + + val applicationId = UUID.randomUUID() + when(mockApplicationRepository.fetch(applicationId)).thenReturn(None) + + val result = await(underTest.fetchCredentials(applicationId)) + + result shouldBe None + } + + "return tokens when application exists in the repository for the given application id" in new Setup { + + val applicationId = UUID.randomUUID() + val applicationData = anApplicationData(applicationId) + val expectedResult = ApplicationTokensResponse( + EnvironmentTokenResponse(productionToken.clientId, productionToken.accessToken, productionToken.clientSecrets), + EnvironmentTokenResponse(sandboxToken.clientId, sandboxToken.accessToken, sandboxToken.clientSecrets)) + + when(mockApplicationRepository.fetch(applicationId)).thenReturn(Some(applicationData)) + + val result = await(underTest.fetchCredentials(applicationId)) + + result shouldBe Some(expectedResult) + } + } + + "fetch wso2 credentials by clientId" should { + + "return none when no application exists in the repository for the given application clientId" in new Setup { + + val clientId = "aClientId" + when(mockApplicationRepository.fetchByClientId(clientId)).thenReturn(None) + + val result = await(underTest.fetchWso2Credentials(clientId)) + + result shouldBe None + } + + "return wso2 credentials for the given client id" in new Setup { + + val applicationId = UUID.randomUUID() + val applicationData = anApplicationData(applicationId) + + when(mockApplicationRepository.fetchByClientId(productionToken.clientId)).thenReturn(Some(applicationData)) + + val result = await(underTest.fetchWso2Credentials(productionToken.clientId)) + + result shouldBe Some(Wso2Credentials(productionToken.clientId, productionToken.accessToken, productionToken.wso2ClientSecret)) + } + + "fail when the repository fails to return the application" in new Setup { + + val applicationId = UUID.randomUUID() + val applicationData = anApplicationData(applicationId) + + when(mockApplicationRepository.fetch(applicationId)).thenThrow(new RuntimeException("test error")) + + intercept[RuntimeException] { + await(underTest.fetchWso2Credentials(applicationData.tokens.production.clientId)) + } + } + } + + "validate credentials" should { + + "return none when no application exists in the repository for the given client id" in new Setup { + + val clientId = "some-client-id" + when(mockApplicationRepository.fetchByClientId(clientId)).thenReturn(None) + + val result = await(underTest.validateCredentials(ValidationRequest(clientId, "aSecret"))) + + result shouldBe None + } + + "return none when credentials don't match with an application" in new Setup { + + val applicationData = anApplicationData(UUID.randomUUID()) + val clientId = applicationData.tokens.production.clientId + when(mockApplicationRepository.fetchByClientId(clientId)).thenReturn(Some(applicationData)) + + val result = await(underTest.validateCredentials(ValidationRequest(clientId, "wrongSecret"))) + + result shouldBe None + } + + "return none when sandbox secret is used with production client id" in new Setup { + + val applicationData = anApplicationData(UUID.randomUUID()) + val productionClientId = applicationData.tokens.production.clientId + when(mockApplicationRepository.fetchByClientId(productionClientId)).thenReturn(Some(applicationData)) + + val result = await(underTest.validateCredentials(ValidationRequest(productionClientId, applicationData.tokens.sandbox.clientSecrets.head.secret))) + + result shouldBe None + } + + "return environment when credentials match with an application" in new Setup { + + val applicationData = anApplicationData(UUID.randomUUID()) + val clientId = applicationData.tokens.production.clientId + when(mockApplicationRepository.fetchByClientId(clientId)).thenReturn(Some(applicationData)) + + val result = await(underTest.validateCredentials(ValidationRequest(clientId, applicationData.tokens.production.clientSecrets.head.secret))) + + result shouldBe Some(PRODUCTION) + } + } + + "addClientSecret" should { + val applicationId = UUID.randomUUID() + val applicationData = anApplicationData(applicationId) + val secretRequest = ClientSecretRequest("secret-1") + + "add the client secret for production" in new Setup { + val captor = ArgumentCaptor.forClass(classOf[ApplicationData]) + + when(mockApplicationRepository.fetch(applicationId)).thenReturn(successful(Some(applicationData))) + + val result = await(underTest.addClientSecret(applicationId, secretRequest)) + + verify(mockApplicationRepository).save(captor.capture()) + val updatedProductionSecrets = captor.getValue.tokens.production.clientSecrets + updatedProductionSecrets should have size productionToken.clientSecrets.size + 1 + val newSecret = updatedProductionSecrets diff productionToken.clientSecrets + result shouldBe ApplicationTokensResponse.create(ApplicationTokens(productionToken.copy(clientSecrets = updatedProductionSecrets), sandboxToken)) + verify(mockAuditService).audit(ClientSecretAdded, + Map("applicationId" -> applicationId.toString, "newClientSecret" -> newSecret.head.secret, "clientSecretType" -> PRODUCTION.toString)) + } + + "add the client secret for a Sandbox app" in new Setup { + + val sandboxAppData = applicationData.copy(environment = "SANDBOX") + + val captor = ArgumentCaptor.forClass(classOf[ApplicationData]) + + when(mockApplicationRepository.fetch(applicationId)).thenReturn(successful(Some(sandboxAppData))) + + val result = await(underTest.addClientSecret(applicationId, secretRequest)) + + verify(mockApplicationRepository).save(captor.capture()) + val updatedSecrets = captor.getValue.tokens.production.clientSecrets + updatedSecrets should have size productionToken.clientSecrets.size + 1 + result shouldBe ApplicationTokensResponse.create(ApplicationTokens(productionToken.copy(clientSecrets = updatedSecrets), sandboxToken)) + } + + "throw a NotFoundException when no application exists in the repository for the given application id" in new Setup { + + when(mockApplicationRepository.fetch(applicationId)).thenReturn(successful(None)) + + intercept[NotFoundException](await(underTest.addClientSecret(applicationId, secretRequest))) + + verify(mockApplicationRepository, never()).save(any[ApplicationData]) + } + + "throw a ClientSecretsLimitExceeded when app contains already 5 secrets" in new Setup { + + val prodTokenWith5Secrets = productionToken.copy(clientSecrets = Seq(1, 2, 3, 4, 5).map(v => ClientSecret(v.toString))) + val applicationDataWith5Secrets = anApplicationData(applicationId).copy(tokens = ApplicationTokens(prodTokenWith5Secrets, sandboxToken)) + + when(mockApplicationRepository.fetch(applicationId)).thenReturn(successful(Some(applicationDataWith5Secrets))) + + intercept[ClientSecretsLimitExceeded](await(underTest.addClientSecret(applicationId, secretRequest))) + + verify(mockApplicationRepository, never()).save(any[ApplicationData]) + } + } + + "delete client secrets" should { + + val applicationId = UUID.randomUUID() + val applicationData = anApplicationData(applicationId) + + "remove a client secret form an app with more than one client secret" in new Setup { + + val secretsToRemove = Seq("secret1") + val captor = ArgumentCaptor.forClass(classOf[ApplicationData]) + + when(mockApplicationRepository.fetch(applicationId)).thenReturn(successful(Some(applicationData))) + when(mockAuditService.audit(ClientSecretRemoved, Map("applicationId" -> applicationId.toString, + "removedClientSecret" -> secretsToRemove.head))).thenReturn(Future.successful(AuditResult.Success)) + + val result = await(underTest.deleteClientSecrets(applicationId, secretsToRemove)) + + verify(mockApplicationRepository).save(captor.capture()) + val updatedClientSecrets = captor.getValue.tokens.production.clientSecrets + updatedClientSecrets should have size productionToken.clientSecrets.size - secretsToRemove.length + result shouldBe ApplicationTokensResponse.create(ApplicationTokens(productionToken.copy(clientSecrets = updatedClientSecrets), sandboxToken)) + verify(mockAuditService, times(secretsToRemove.length)).audit(ClientSecretRemoved, + Map("applicationId" -> applicationId.toString, "removedClientSecret" -> secretsToRemove.head)) + + } + + "throw an IllegalArgumentException when reqested to remove all secrets" in new Setup { + + val secretsToRemove = Seq("secret1", "secret2") + + when(mockApplicationRepository.fetch(applicationId)).thenReturn(successful(Some(applicationData))) + + intercept[IllegalArgumentException](await(underTest.deleteClientSecrets(applicationId, secretsToRemove))) + + verify(mockApplicationRepository, never()).save(any[ApplicationData]) + verify(mockAuditService, never()).audit(any(), any())(any[HeaderCarrier]) + } + + "throw a NotFoundException when no application exists in the repository for the given application id" in new Setup { + val secretsToRemove = Seq("secret1") + + when(mockApplicationRepository.fetch(applicationId)).thenReturn(successful(None)) + + intercept[NotFoundException](await(underTest.deleteClientSecrets(applicationId, secretsToRemove))) + + verify(mockApplicationRepository, never()).save(any[ApplicationData]) + verify(mockAuditService, never()).audit(any(), any())(any[HeaderCarrier]) + } + + "throw a NotFoundException when trying to delete a secret which does not exist" in new Setup { + val secretsToRemove = Seq("notARealSecret") + + when(mockApplicationRepository.fetch(applicationId)).thenReturn(successful(Some(applicationData))) + + intercept[NotFoundException](await(underTest.deleteClientSecrets(applicationId, secretsToRemove))) + + verify(mockApplicationRepository, never()).save(any[ApplicationData]) + verify(mockAuditService, never()).audit(any(), any())(any[HeaderCarrier]) + } + } + + private val requestedByEmail = "john.smith@example.com" + + private def anApplicationData(applicationId: UUID, state: ApplicationState = productionState(requestedByEmail), + collaborators: Set[Collaborator] = Set(Collaborator(loggedInUser, ADMINISTRATOR))) = { + ApplicationData( + applicationId, + "MyApp", + "myapp", + collaborators, + Some("description"), + "aaaaaaaaaa", + "aaaaaaaaaa", + "aaaaaaaaaa", + ApplicationTokens(productionToken, sandboxToken), state, Standard(Seq.empty, None, None)) + } +} diff --git a/test/unit/services/DataUtilSpec.scala b/test/unit/services/DataUtilSpec.scala new file mode 100644 index 000000000..ad89f5660 --- /dev/null +++ b/test/unit/services/DataUtilSpec.scala @@ -0,0 +1,47 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 services + +import org.scalatest.concurrent.ScalaFutures +import uk.gov.hmrc.play.test.UnitSpec +import uk.gov.hmrc.services.DataUtil + +class DataUtilSpec extends UnitSpec with ScalaFutures { + + def zip(first: Int, second: String) = s"$first-$second" + + def error(from: String)(key: String) = new RuntimeException(s"$from: test error") + + val map1 = Map("A" -> 1, "B" -> 2) + val map2 = Map("A" -> "9", "B" -> "8") + "DataUtil" should { + + "zip apps with history" in { + val result = DataUtil.zipper(map1, map2, zip, error("Map1"), error("Map2")) + result should contain theSameElementsAs Seq("1-9", "2-8") + } + + "throw map1 error for inconsistency" in { + val ex = intercept[RuntimeException](DataUtil.zipper(map1, map2 + ("C" -> "7"), zip, error("Map1"), error("Map2"))) + ex.getMessage shouldBe "Map1: test error" + } + "throw map2 error for inconsistency" in { + val ex = intercept[RuntimeException](DataUtil.zipper(map1 + ("C" -> 3), map2, zip, error("Map1"), error("Map2"))) + ex.getMessage shouldBe "Map2: test error" + } + } +} diff --git a/test/unit/services/GatekeeperServiceSpec.scala b/test/unit/services/GatekeeperServiceSpec.scala new file mode 100644 index 000000000..73382aac0 --- /dev/null +++ b/test/unit/services/GatekeeperServiceSpec.scala @@ -0,0 +1,467 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 services + +import java.util.UUID + +import common.uk.gov.hmrc.testutils.ApplicationStateUtil +import org.joda.time.DateTimeUtils +import org.mockito.ArgumentCaptor +import org.mockito.Matchers.{any, anyString, eq => eqTo} +import org.mockito.Mockito._ +import org.mockito.invocation.InvocationOnMock +import org.mockito.stubbing.Answer +import org.scalatest.BeforeAndAfterAll +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.mockito.MockitoSugar +import uk.gov.hmrc.config.AppContext +import uk.gov.hmrc.connector.{ApiSubscriptionFieldsConnector, EmailConnector, ThirdPartyDelegatedAuthorityConnector} +import uk.gov.hmrc.controllers.{DeleteApplicationRequest, RejectUpliftRequest} +import uk.gov.hmrc.http.{HeaderCarrier, HttpResponse, NotFoundException} +import uk.gov.hmrc.models.ActorType.{COLLABORATOR, _} +import uk.gov.hmrc.models.Role._ +import uk.gov.hmrc.models.State._ +import uk.gov.hmrc.models.{State, _} +import uk.gov.hmrc.play.test.UnitSpec +import uk.gov.hmrc.repository.{ApplicationRepository, StateHistoryRepository, SubscriptionRepository} +import uk.gov.hmrc.services.AuditAction._ +import uk.gov.hmrc.services._ + +import scala.concurrent.Future +import scala.concurrent.Future.successful + +class GatekeeperServiceSpec extends UnitSpec with ScalaFutures with MockitoSugar with BeforeAndAfterAll with ApplicationStateUtil { + + private val requestedByEmail = "john.smith@example.com" + + private def aSecret(secret: String) = ClientSecret(secret, secret) + + private val loggedInUser = "loggedin@example.com" + private val productionToken = EnvironmentToken("aaa", "bbb", "wso2Secret", Seq(aSecret("secret1"), aSecret("secret2"))) + private val sandboxToken = EnvironmentToken("111", "222", "wso2SandboxSecret", Seq(aSecret("secret3"), aSecret("secret4"))) + + private def aHistory(appId: UUID, state: State = PENDING_GATEKEEPER_APPROVAL): StateHistory = { + StateHistory(appId, state, Actor("anEmail", COLLABORATOR), Some(TESTING)) + } + + private def anApplicationData(applicationId: UUID, state: ApplicationState = productionState(requestedByEmail), + collaborators: Set[Collaborator] = Set(Collaborator(loggedInUser, ADMINISTRATOR))) = { + ApplicationData(applicationId, "MyApp", "myapp", + collaborators, Some("description"), + "aaaaaaaaaa", "aaaaaaaaaa", "aaaaaaaaaa", + ApplicationTokens(productionToken, sandboxToken), state, Standard(Seq(), None, None)) + } + + trait Setup { + + lazy val locked = false + val mockWSO2APIStore = mock[WSO2APIStore] + val mockApplicationRepository = mock[ApplicationRepository] + val mockStateHistoryRepository = mock[StateHistoryRepository] + val mockSubscriptionRepository = mock[SubscriptionRepository] + val mockAuditService = mock[AuditService] + val mockEmailConnector = mock[EmailConnector] + val mockApiSubscriptionFieldsConnector = mock[ApiSubscriptionFieldsConnector] + val mockThirdPartyDelegatedAuthorityConnector = mock[ThirdPartyDelegatedAuthorityConnector] + val response = mock[HttpResponse] + val mockAppContext = mock[AppContext] + when(mockAppContext.trustedApplications).thenReturn(Seq.empty) + + val applicationResponseCreator = new ApplicationResponseCreator(mockAppContext) + + implicit val hc = HeaderCarrier() + + val underTest = new GatekeeperService(mockApplicationRepository, + mockStateHistoryRepository, + mockSubscriptionRepository, + mockAuditService, + mockEmailConnector, + mockApiSubscriptionFieldsConnector, + mockWSO2APIStore, + applicationResponseCreator, + mockAppContext, + mockThirdPartyDelegatedAuthorityConnector) + + when(mockApplicationRepository.save(any())).thenAnswer(new Answer[Future[ApplicationData]] { + override def answer(invocation: InvocationOnMock): Future[ApplicationData] = { + successful(invocation.getArguments()(0).asInstanceOf[ApplicationData]) + } + }) + when(mockStateHistoryRepository.insert(any())).thenAnswer(new Answer[Future[StateHistory]] { + override def answer(invocation: InvocationOnMock): Future[StateHistory] = { + successful(invocation.getArguments()(0).asInstanceOf[StateHistory]) + } + }) + when(mockEmailConnector.sendRemovedCollaboratorNotification(anyString(), anyString(), any())(any[HeaderCarrier]())).thenReturn(successful(response)) + when(mockEmailConnector.sendRemovedCollaboratorConfirmation(anyString(), any())(any[HeaderCarrier]())).thenReturn(successful(response)) + when(mockEmailConnector.sendApplicationApprovedAdminConfirmation(anyString(), anyString(), any())(any[HeaderCarrier]())).thenReturn(successful(response)) + when(mockEmailConnector.sendApplicationApprovedNotification(anyString(), any())(any[HeaderCarrier]())).thenReturn(successful(response)) + when(mockEmailConnector.sendApplicationRejectedNotification(anyString(), any(), anyString())(any[HeaderCarrier]())).thenReturn(successful(response)) + when(mockEmailConnector.sendApplicationDeletedNotification(anyString(), anyString(), any())(any[HeaderCarrier]())).thenReturn(successful(response)) + } + + override def beforeAll() { + DateTimeUtils.setCurrentMillisFixed(DateTimeUtils.currentTimeMillis()) + } + + override def afterAll() { + DateTimeUtils.setCurrentMillisSystem() + } + + "fetch nonTestingApps with submitted date" should { + + "return apps" in new Setup { + val app1 = anApplicationData(UUID.randomUUID()) + val app2 = anApplicationData(UUID.randomUUID()) + val history1 = aHistory(app1.id) + val history2 = aHistory(app2.id) + + when(mockApplicationRepository.fetchStandardNonTestingApps()).thenReturn(successful(Seq(app1, app2))) + when(mockStateHistoryRepository.fetchByState(State.PENDING_GATEKEEPER_APPROVAL)).thenReturn(successful(Seq(history1, history2))) + + val result = await(underTest.fetchNonTestingAppsWithSubmittedDate()) + + result should contain theSameElementsAs Seq(ApplicationWithUpliftRequest.create(app1, history1), + ApplicationWithUpliftRequest.create(app2, history2)) + } + } + + "fetch application with history" should { + val appId: UUID = UUID.randomUUID() + + "return app" in new Setup { + val app1 = anApplicationData(appId) + val history = Seq(aHistory(app1.id), aHistory(app1.id, State.PRODUCTION)) + + when(mockApplicationRepository.fetch(appId)).thenReturn(successful(Some(app1))) + when(mockStateHistoryRepository.fetchByApplicationId(appId)).thenReturn(successful(history)) + + val result = await(underTest.fetchAppWithHistory(appId)) + + result shouldBe ApplicationWithHistory(ApplicationResponse(data = app1, clientId = None, trusted = false), history.map(StateHistoryResponse.from)) + } + + "throw not found exception" in new Setup { + when(mockApplicationRepository.fetch(appId)).thenReturn(successful(None)) + + intercept[NotFoundException](await(underTest.fetchAppWithHistory(appId))) + } + + "propagate the exception when the app repository fail" in new Setup { + when(mockApplicationRepository.fetch(appId)).thenReturn(Future.failed(new RuntimeException("Expected test failure"))) + + intercept[RuntimeException](await(underTest.fetchAppWithHistory(appId))) + } + + "propagate the exception when the history repository fail" in new Setup { + when(mockApplicationRepository.fetch(appId)).thenReturn(successful(Some(anApplicationData(appId)))) + when(mockStateHistoryRepository.fetchByApplicationId(appId)).thenReturn(Future.failed(new RuntimeException("Expected test failure"))) + + intercept[RuntimeException](await(underTest.fetchAppWithHistory(appId))) + } + + } + + "approveUplift" should { + val applicationId = UUID.randomUUID() + val upliftRequestedBy = "email@example.com" + val gatekeeperUserId: String = "big.boss.gatekeeper" + + "update the state of the application" in new Setup { + val application = anApplicationData(applicationId, pendingGatekeeperApprovalState(upliftRequestedBy)) + val expectedApplication = application.copy(state = pendingRequesterVerificationState(upliftRequestedBy)) + val expectedStateHistory = StateHistory(applicationId = expectedApplication.id, state = PENDING_REQUESTER_VERIFICATION, + actor = Actor(gatekeeperUserId, GATEKEEPER), previousState = Some(PENDING_GATEKEEPER_APPROVAL)) + + when(mockApplicationRepository.fetch(applicationId)).thenReturn(Some(application)) + + val result = await(underTest.approveUplift(applicationId, gatekeeperUserId)) + + result shouldBe UpliftApproved + val appDataArgCaptor = ArgumentCaptor.forClass(classOf[ApplicationData]) + verify(mockApplicationRepository).save(appDataArgCaptor.capture()) + verify(mockStateHistoryRepository).insert(expectedStateHistory) + + val savedApplication = appDataArgCaptor.getValue + + savedApplication.state.name shouldBe State.PENDING_REQUESTER_VERIFICATION + savedApplication.state.verificationCode shouldBe defined + } + + "rollback the application when storing the state history fails" in new Setup { + val application = anApplicationData(applicationId, pendingGatekeeperApprovalState(upliftRequestedBy)) + + when(mockApplicationRepository.fetch(applicationId)).thenReturn(Some(application)) + + when(mockStateHistoryRepository.insert(any())).thenReturn(Future.failed(new RuntimeException("Expected test failure"))) + + intercept[RuntimeException] { + await(underTest.approveUplift(applicationId, gatekeeperUserId)) + } + + verify(mockApplicationRepository).save(application) + } + + "send an Audit event when an application uplift approved request is successful" in new Setup { + val application = anApplicationData(applicationId, pendingGatekeeperApprovalState(upliftRequestedBy)) + + when(mockApplicationRepository.fetch(applicationId)).thenReturn(Some(application)) + + val result = await(underTest.approveUplift(applicationId, gatekeeperUserId)) + verify(mockAuditService).audit(ApplicationUpliftApproved, + AuditHelper.gatekeeperActionDetails(application), Map("gatekeeperId" -> gatekeeperUserId)) + } + + "fail with InvalidStateTransition when the application is not in PENDING_GATEKEEPER_APPROVAL state" in new Setup { + val application = anApplicationData(applicationId, pendingRequesterVerificationState("test@example.com")) + + when(mockApplicationRepository.fetch(applicationId)).thenReturn(Some(application)) + + intercept[InvalidStateTransition] { + await(underTest.approveUplift(applicationId, gatekeeperUserId)) + } + } + + "propagate the exception when the repository fail" in new Setup { + + when(mockApplicationRepository.fetch(applicationId)).thenReturn(Future.failed(new RuntimeException("Expected test failure"))) + + intercept[RuntimeException] { + await(underTest.approveUplift(applicationId, gatekeeperUserId)) + } + } + + "send confirmation email to admin uplift requester" in new Setup { + val application = anApplicationData(applicationId, pendingGatekeeperApprovalState(upliftRequestedBy)) + + when(mockApplicationRepository.fetch(applicationId)).thenReturn(Some(application)) + + val result = await(underTest.approveUplift(applicationId, gatekeeperUserId)) + verify(mockEmailConnector).sendApplicationApprovedAdminConfirmation( + eqTo(application.name), anyString(), eqTo(Set(application.state.requestedByEmailAddress.get)))(any[HeaderCarrier]()) + } + + "send notification email to all admins except requester" in new Setup { + val admin1 = Collaborator("admin1@example.com", Role.ADMINISTRATOR) + val admin2 = Collaborator("admin2@example.com", Role.ADMINISTRATOR) + val requester = Collaborator(upliftRequestedBy, Role.ADMINISTRATOR) + val developer = Collaborator("somedev@example.com", Role.DEVELOPER) + + val application = anApplicationData( + applicationId, pendingGatekeeperApprovalState(upliftRequestedBy), collaborators = Set(admin1, admin2, requester, developer)) + + when(mockApplicationRepository.fetch(applicationId)).thenReturn(Some(application)) + + val result = await(underTest.approveUplift(applicationId, gatekeeperUserId)) + verify(mockEmailConnector).sendApplicationApprovedNotification(application.name, Set(admin1.emailAddress, admin2.emailAddress)) + } + } + + "rejectUplift" should { + val applicationId = UUID.randomUUID() + val upliftRequestedBy = "email@example.com" + val gatekeeperUserId = "big.boss.gatekeeper" + val rejectReason = "Reason of rejection" + val rejectUpliftRequest = RejectUpliftRequest(gatekeeperUserId, rejectReason) + val application = anApplicationData(applicationId, pendingGatekeeperApprovalState(upliftRequestedBy)) + + "update the state of the application" in new Setup { + val expectedApplication = application.copy(state = testingState()) + val expectedStateHistory = StateHistory(applicationId = application.id, state = TESTING, + actor = Actor(gatekeeperUserId, GATEKEEPER), previousState = Some(PENDING_GATEKEEPER_APPROVAL), + notes = Some(rejectReason)) + + when(mockApplicationRepository.fetch(applicationId)).thenReturn(Some(application)) + + val result = await(underTest.rejectUplift(applicationId, rejectUpliftRequest)) + + result shouldBe UpliftRejected + val appDataArgCaptor = ArgumentCaptor.forClass(classOf[ApplicationData]) + verify(mockApplicationRepository).save(expectedApplication) + verify(mockStateHistoryRepository).insert(expectedStateHistory) + } + + "rollback the application when storing the state history fails" in new Setup { + when(mockApplicationRepository.fetch(applicationId)).thenReturn(Some(application)) + + when(mockStateHistoryRepository.insert(any())).thenReturn(Future.failed(new RuntimeException("Expected test failure"))) + + intercept[RuntimeException] { + await(underTest.rejectUplift(applicationId, rejectUpliftRequest)) + } + + verify(mockApplicationRepository).save(application) + } + + "send an Audit event when an application uplift is rejected" in new Setup { + when(mockApplicationRepository.fetch(applicationId)).thenReturn(Some(application)) + + val result = await(underTest.rejectUplift(applicationId, rejectUpliftRequest)) + verify(mockAuditService).audit(ApplicationUpliftRejected, + AuditHelper.gatekeeperActionDetails(application) + ("reason" -> rejectUpliftRequest.reason), + Map("gatekeeperId" -> gatekeeperUserId)) + } + + "fail with InvalidStateTransition when the application is not in PENDING_GATEKEEPER_APPROVAL state" in new Setup { + when(mockApplicationRepository.fetch(applicationId)) + .thenReturn(Some(application.copy(state = pendingRequesterVerificationState("test@example.com")))) + + intercept[InvalidStateTransition] { + await(underTest.rejectUplift(applicationId, rejectUpliftRequest)) + } + } + + "propagate the exception when the repository fail" in new Setup { + when(mockApplicationRepository.fetch(applicationId)).thenReturn(Future.failed(new RuntimeException("Expected test failure"))) + + intercept[RuntimeException] { + await(underTest.rejectUplift(applicationId, rejectUpliftRequest)) + } + } + + "send notification emails to all admins" in new Setup { + when(mockApplicationRepository.fetch(applicationId)).thenReturn(Some(application)) + + val result = await(underTest.rejectUplift(applicationId, rejectUpliftRequest)) + verify(mockEmailConnector).sendApplicationRejectedNotification( + application.name, application.admins.map(_.emailAddress), rejectUpliftRequest.reason) + } + + } + + "resendVerification" should { + val applicationId = UUID.randomUUID() + val upliftRequestedBy = "email@example.com" + val gatekeeperUserId: String = "big.boss.gatekeeper" + + "send an Audit event when a resend verification request is successful" in new Setup { + val application = anApplicationData(applicationId, pendingRequesterVerificationState(upliftRequestedBy)) + + when(mockApplicationRepository.fetch(applicationId)).thenReturn(Some(application)) + + val result = await(underTest.resendVerification(applicationId, gatekeeperUserId)) + verify(mockAuditService).audit(ApplicationVerficationResent, + AuditHelper.gatekeeperActionDetails(application), Map("gatekeeperId" -> gatekeeperUserId)) + } + + "fail with InvalidStateTransition when the application is not in PENDING_REQUESTER_VERIFICATION state" in new Setup { + val application = anApplicationData(applicationId, pendingGatekeeperApprovalState("test@example.com")) + + when(mockApplicationRepository.fetch(applicationId)).thenReturn(Some(application)) + + intercept[InvalidStateTransition] { + await(underTest.resendVerification(applicationId, gatekeeperUserId)) + } + } + + "send verification email to requester" in new Setup { + val application = anApplicationData(applicationId, pendingRequesterVerificationState(upliftRequestedBy)) + + when(mockApplicationRepository.fetch(applicationId)).thenReturn(Some(application)) + + val result = await(underTest.resendVerification(applicationId, gatekeeperUserId)) + verify(mockEmailConnector).sendApplicationApprovedAdminConfirmation( + eqTo(application.name), anyString(), eqTo(Set(application.state.requestedByEmailAddress.get)))(any[HeaderCarrier]()) + } + } + + "deleting an application" should { + val deleteRequestedBy = "email@example.com" + val gatekeeperUserId = "big.boss.gatekeeper" + val request = DeleteApplicationRequest(gatekeeperUserId, deleteRequestedBy) + val applicationId = UUID.randomUUID() + val application = anApplicationData(applicationId) + val api1 = APIIdentifier("hello", "1.0") + val api2 = APIIdentifier("goodbye", "1.0") + + trait DeleteApplicationSetup extends Setup { + when(mockApplicationRepository.fetch(any())).thenReturn(Some(application)) + when(mockWSO2APIStore.getSubscriptions(any(), any(), any())(any[HeaderCarrier])).thenReturn(successful(Seq(api1, api2))) + when(mockWSO2APIStore.removeSubscription(any(), any(), any(), any())(any[HeaderCarrier])).thenReturn(successful(HasSucceeded)) + when(mockSubscriptionRepository.remove(any(), any())).thenReturn(successful(HasSucceeded)) + when(mockWSO2APIStore.deleteApplication(any(), any(), any())(any[HeaderCarrier])).thenReturn(successful(HasSucceeded)) + when(mockApplicationRepository.delete(any())).thenReturn(successful(HasSucceeded)) + when(mockStateHistoryRepository.deleteByApplicationId(any())).thenReturn(successful(HasSucceeded)) + when(mockApiSubscriptionFieldsConnector.deleteSubscriptions(any())(any[HeaderCarrier])).thenReturn(successful(HasSucceeded)) + when(mockThirdPartyDelegatedAuthorityConnector.revokeApplicationAuthorities(any())(any[HeaderCarrier])).thenReturn(successful(HasSucceeded)) + } + + "return a state change to indicate that the application has been deleted" in new DeleteApplicationSetup { + val result = await(underTest.deleteApplication(applicationId, request)) + result shouldBe Deleted + } + + "call to WSO2 to delete the application" in new DeleteApplicationSetup { + await(underTest.deleteApplication(applicationId, request)) + verify(mockWSO2APIStore).deleteApplication(eqTo(application.wso2Username), eqTo(application.wso2Password), + eqTo(application.wso2ApplicationName))(any[HeaderCarrier]) + } + + "call to WSO2 to remove the subscriptions" in new DeleteApplicationSetup { + await(underTest.deleteApplication(applicationId, request)) + verify(mockWSO2APIStore).removeSubscription(eqTo(application.wso2Username), eqTo(application.wso2Password), + eqTo(application.wso2ApplicationName), eqTo(api1))(any[HeaderCarrier]) + verify(mockWSO2APIStore).removeSubscription(eqTo(application.wso2Username), eqTo(application.wso2Password), + eqTo(application.wso2ApplicationName), eqTo(api2))(any[HeaderCarrier]) + } + + "call to the API Subscription Fields service to delete subscription field data" in new DeleteApplicationSetup { + await(underTest.deleteApplication(applicationId, request)) + verify(mockApiSubscriptionFieldsConnector).deleteSubscriptions(eqTo(application.tokens.production.clientId))(any[HeaderCarrier]) + } + + "delete the application subscriptions from the repository" in new DeleteApplicationSetup { + await(underTest.deleteApplication(applicationId, request)) + verify(mockSubscriptionRepository).remove(eqTo(applicationId), eqTo(api1)) + verify(mockSubscriptionRepository).remove(eqTo(applicationId), eqTo(api2)) + } + + "delete the application from the repository" in new DeleteApplicationSetup { + await(underTest.deleteApplication(applicationId, request)) + verify(mockApplicationRepository).delete(applicationId) + } + + "delete the application state history from the repository" in new DeleteApplicationSetup { + await(underTest.deleteApplication(applicationId, request)) + verify(mockStateHistoryRepository).deleteByApplicationId(applicationId) + } + + "audit the application deletion" in new DeleteApplicationSetup { + await(underTest.deleteApplication(applicationId, request)) + verify(mockAuditService).audit(ApplicationDeleted, + AuditHelper.gatekeeperActionDetails(application) + ("requestedByEmailAddress" -> deleteRequestedBy), + Map("gatekeeperId" -> gatekeeperUserId)) + } + + "send the application deleted notification email" in new DeleteApplicationSetup { + await(underTest.deleteApplication(applicationId, request)) + verify(mockEmailConnector).sendApplicationDeletedNotification( + application.name, deleteRequestedBy, application.admins.map(_.emailAddress)) + } + + "silently ignore the delete request if no application exists for the application id (to ensure idempotency)" in new Setup { + when(mockApplicationRepository.fetch(any())).thenReturn(None) + + val result = await(underTest.deleteApplication(applicationId, request)) + result shouldBe Deleted + + verify(mockApplicationRepository).fetch(applicationId) + verifyNoMoreInteractions(mockWSO2APIStore, mockApplicationRepository, mockStateHistoryRepository, + mockSubscriptionRepository, mockAuditService, mockEmailConnector, mockApiSubscriptionFieldsConnector) + } + } +} diff --git a/test/unit/services/SubscriptionServiceSpec.scala b/test/unit/services/SubscriptionServiceSpec.scala new file mode 100644 index 000000000..af4aefaac --- /dev/null +++ b/test/unit/services/SubscriptionServiceSpec.scala @@ -0,0 +1,415 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 unit.services + +import java.util.UUID + +import common.uk.gov.hmrc.testutils.ApplicationStateUtil +import org.joda.time.{DateTime, DateTimeUtils} +import org.mockito.BDDMockito.given +import org.mockito.Matchers._ +import org.mockito.Mockito._ +import org.mockito.invocation.InvocationOnMock +import org.mockito.stubbing.Answer +import org.scalatest.BeforeAndAfterAll +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.mockito.MockitoSugar +import play.api.libs.ws.WSResponse +import uk.gov.hmrc.config.AppContext +import uk.gov.hmrc.connector.{APIDefinitionConnector, EmailConnector} +import uk.gov.hmrc.http.{HeaderCarrier, HttpReads, NotFoundException} +import uk.gov.hmrc.models.JsonFormatters._ +import uk.gov.hmrc.models.RateLimitTier.{BRONZE, GOLD, RateLimitTier} +import uk.gov.hmrc.models.Role._ +import uk.gov.hmrc.models._ +import uk.gov.hmrc.play.test.UnitSpec +import uk.gov.hmrc.repository.{ApplicationRepository, StateHistoryRepository, SubscriptionRepository} +import uk.gov.hmrc.services.AuditAction._ +import uk.gov.hmrc.services._ +import uk.gov.hmrc.util.http.HttpHeaders._ + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future.{failed, successful} +import scala.concurrent.{ExecutionContext, Future} + +class SubscriptionServiceSpec extends UnitSpec with ScalaFutures with MockitoSugar with BeforeAndAfterAll with ApplicationStateUtil { + + trait Setup { + + lazy val locked = false + val mockWSO2APIStore = mock[WSO2APIStore] + val mockApplicationRepository = mock[ApplicationRepository] + val mockStateHistoryRepository = mock[StateHistoryRepository] + val mockApiDefinitionConnector = mock[APIDefinitionConnector] + val mockAuditService = mock[AuditService] + val mockEmailConnector = mock[EmailConnector] + val mockSubscriptionRepository = mock[SubscriptionRepository] + val response = mock[WSResponse] + + val mockAppContext = mock[AppContext] + when(mockAppContext.trustedApplications).thenReturn(Seq(trustedApplicationId.toString)) + + implicit val hc = HeaderCarrier().withExtraHeaders( + LOGGED_IN_USER_EMAIL_HEADER -> loggedInUser, + LOGGED_IN_USER_NAME_HEADER -> "John Smith" + ) + + val underTest = new SubscriptionService( + mockApplicationRepository, mockSubscriptionRepository, mockApiDefinitionConnector, mockAuditService, mockWSO2APIStore, mockAppContext) + + when(mockWSO2APIStore.createApplication(any(), any(), any())(any[HeaderCarrier])).thenReturn(successful(ApplicationTokens(productionToken, sandboxToken))) + when(mockApplicationRepository.save(any())).thenAnswer(new Answer[Future[ApplicationData]] { + override def answer(invocation: InvocationOnMock): Future[ApplicationData] = { + successful(invocation.getArguments()(0).asInstanceOf[ApplicationData]) + } + }) + when(mockApiDefinitionConnector.fetchAllAPIs(any())(any[HttpReads[Seq[APIDefinition]]](), any[HeaderCarrier], any[ExecutionContext])) + .thenReturn(Seq(anAPIDefinition())) + when(mockWSO2APIStore.getSubscriptions(any(), any(), any())(any[HeaderCarrier])).thenReturn(Seq()) + when(mockWSO2APIStore.addSubscription(any(), any(), any(), any(), any())(any[HeaderCarrier])).thenReturn(HasSucceeded) + when(mockWSO2APIStore.removeSubscription(any(), any(), any(), any())(any[HeaderCarrier])).thenReturn(HasSucceeded) + when(mockSubscriptionRepository.add(any(), any())).thenReturn(HasSucceeded) + when(mockSubscriptionRepository.remove(any(), any())).thenReturn(HasSucceeded) + } + + private def aSecret(secret: String): ClientSecret = { + ClientSecret(secret, secret) + } + + private val loggedInUser = "loggedin@example.com" + private val productionToken = EnvironmentToken("aaa", "bbb", "wso2Secret", Seq(aSecret("secret1"), aSecret("secret2"))) + private val sandboxToken = EnvironmentToken("111", "222", "wso2SandboxSecret", Seq(aSecret("secret3"), aSecret("secret4"))) + private val trustedApplicationId = UUID.randomUUID() + + override def beforeAll() { + DateTimeUtils.setCurrentMillisFixed(DateTimeUtils.currentTimeMillis()) + } + + override def afterAll() { + DateTimeUtils.setCurrentMillisSystem() + } + + "isSubscribed" should { + val applicationId = UUID.randomUUID() + val api = APIIdentifier("context", "1.0") + + "return true when the application is subscribed to a given API version" in new Setup { + given(mockSubscriptionRepository.isSubscribed(applicationId, api)).willReturn(true) + + val result = await(underTest.isSubscribed(applicationId, api)) + + result shouldBe true + } + + "return false when the application is not subscribed to a given API version" in new Setup { + given(mockSubscriptionRepository.isSubscribed(applicationId, api)).willReturn(false) + + val result = await(underTest.isSubscribed(applicationId, api)) + + result shouldBe false + } + } + + "fetchAllSubscriptionsForApplication" should { + val applicationId = UUID.randomUUID() + + "throw a NotFoundException when no application exists in the repository for the given application id" in new Setup { + when(mockApplicationRepository.fetch(applicationId)).thenReturn(successful(None)) + + intercept[NotFoundException] { + await(underTest.fetchAllSubscriptionsForApplication(applicationId)) + } + } + + "fetch all API subscriptions from api-definition for the given application id when an application exists" in new Setup { + val applicationData = anApplicationData(applicationId) + + when(mockApplicationRepository.fetch(applicationId)) + .thenReturn(successful(Some(applicationData))) + when(mockApiDefinitionConnector.fetchAllAPIs(refEq(applicationId))(any[HttpReads[Seq[APIDefinition]]](), any[HeaderCarrier](), any[ExecutionContext]())) + .thenReturn(Seq(anAPIDefinition("context", Seq(anAPIVersion("1.0"), anAPIVersion("2.0"))))) + when(mockWSO2APIStore.getSubscriptions(any(), any(), any())(any[HeaderCarrier])) + .thenReturn(successful(Seq(anAPI("context", "1.0")))) + + val result = await(underTest.fetchAllSubscriptionsForApplication(applicationId)) + + result shouldBe Seq(APISubscription("name", "service", "context", Seq( + VersionSubscription(APIVersion("1.0", APIStatus.STABLE, None), subscribed = true), + VersionSubscription(APIVersion("2.0", APIStatus.STABLE, None), subscribed = false) + ), Some(false)) + ) + } + + "fetch APIs which require trust for a trusted application" in new Setup { + val applicationData = anApplicationData(trustedApplicationId) + val requiresTrustAPI = anAPIDefinition("context", Seq(anAPIVersion("1.0"))).copy(requiresTrust = Some(true)) + + when(mockApplicationRepository.fetch(trustedApplicationId)).thenReturn(successful(Some(applicationData))) + when(mockApiDefinitionConnector + .fetchAllAPIs(refEq(trustedApplicationId))(any[HttpReads[Seq[APIDefinition]]](), any[HeaderCarrier], any[ExecutionContext])) + .thenReturn(Seq(requiresTrustAPI)) + + val result = await(underTest.fetchAllSubscriptionsForApplication(trustedApplicationId)) + + result shouldBe Seq(APISubscription("name", "service", "context", Seq( + VersionSubscription(APIVersion("1.0", APIStatus.STABLE, None), subscribed = false)), Some(true)) + ) + } + + "filter APIs which require trust for a non trusted application" in new Setup { + val applicationData = anApplicationData(applicationId) + val requiresTrustAPI = anAPIDefinition("context", Seq(anAPIVersion("1.0"))).copy(requiresTrust = Some(true)) + + when(mockApplicationRepository.fetch(applicationId)).thenReturn(successful(Some(applicationData))) + when(mockApiDefinitionConnector.fetchAllAPIs(refEq(applicationId))(any[HttpReads[Seq[APIDefinition]]](), any[HeaderCarrier], any[ExecutionContext])) + .thenReturn(Seq(requiresTrustAPI)) + + val result = await(underTest.fetchAllSubscriptionsForApplication(applicationId)) + + result shouldBe Seq() + } + } + + "createSubscriptionForApplication" should { + val applicationId = UUID.randomUUID() + val applicationData = anApplicationData(applicationId, rateLimitTier = Some(GOLD)) + val api = anAPI() + + "create a subscription in WSO2 and Mongo for the given application when an application exists in the repository" in new Setup { + + when(mockApplicationRepository.fetch(applicationId)).thenReturn(successful(Some(applicationData))) + when(mockApiDefinitionConnector.fetchAllAPIs(refEq(applicationId))(any[HttpReads[Seq[APIDefinition]]](), any[HeaderCarrier], any[ExecutionContext])) + .thenReturn(Seq(anAPIDefinition())) + + val result = await(underTest.createSubscriptionForApplication(applicationId, api)) + + result shouldBe HasSucceeded + verify(mockAuditService).audit(refEq(Subscribed), any[Map[String, String]])(refEq(hc)) + verify(mockWSO2APIStore).addSubscription(refEq(applicationData.wso2Username), refEq(applicationData.wso2Password), + refEq(applicationData.wso2ApplicationName), refEq(api), refEq(Some(GOLD)))(any[HeaderCarrier]) + verify(mockWSO2APIStore).addSubscription(refEq(applicationData.wso2Username), refEq(applicationData.wso2Password), + refEq(applicationData.wso2ApplicationName), refEq(api), refEq(Some(GOLD)))(any[HeaderCarrier]) + verify(mockSubscriptionRepository).add(applicationId, api) + } + + "create a subscription in WSO2 and Mongo when the API requires trust and the application is trusted" in new Setup { + val trustedApplication = anApplicationData(trustedApplicationId) + val trustedApi = anAPIDefinition().copy(requiresTrust = Some(true)) + + when(mockApplicationRepository.fetch(trustedApplicationId)).thenReturn(successful(Some(trustedApplication))) + when(mockApiDefinitionConnector + .fetchAllAPIs(refEq(trustedApplicationId))(any[HttpReads[Seq[APIDefinition]]](), any[HeaderCarrier], any[ExecutionContext])) + .thenReturn(Seq(trustedApi)) + + val result = await(underTest.createSubscriptionForApplication(trustedApplicationId, api)) + + result shouldBe HasSucceeded + verify(mockWSO2APIStore).addSubscription(refEq(applicationData.wso2Username), refEq(applicationData.wso2Password), + refEq(applicationData.wso2ApplicationName), refEq(api), refEq(Some(RateLimitTier.BRONZE)))(any[HeaderCarrier]) + verify(mockWSO2APIStore).addSubscription(refEq(applicationData.wso2Username), refEq(applicationData.wso2Password), + refEq(applicationData.wso2ApplicationName), refEq(api), refEq(Some(BRONZE)))(any[HeaderCarrier]) + verify(mockSubscriptionRepository).add(trustedApplicationId, api) + } + + "throw SubscriptionAlreadyExistsException if already subscribed" in new Setup { + + when(mockApplicationRepository.fetch(applicationId)).thenReturn(successful(Some(applicationData))) + when(mockApiDefinitionConnector.fetchAllAPIs(refEq(applicationId))(any[HttpReads[Seq[APIDefinition]]](), any[HeaderCarrier], any[ExecutionContext])) + .thenReturn(Seq(anAPIDefinition())) + when(mockWSO2APIStore.getSubscriptions(any(), any(), any())(any[HeaderCarrier])).thenReturn(Seq(api)) + + intercept[SubscriptionAlreadyExistsException] { + await(underTest.createSubscriptionForApplication(applicationId, api)) + } + + verify(mockWSO2APIStore, never).addSubscription(any(), any(), any(), any(), any())(any[HeaderCarrier]) + } + + "throw a NotFoundException when no application exists in the repository for the given application id" in new Setup { + + when(mockApplicationRepository.fetch(applicationId)).thenReturn(None) + when(mockApiDefinitionConnector.fetchAllAPIs(refEq(applicationId))(any[HttpReads[Seq[APIDefinition]]](), any[HeaderCarrier], any[ExecutionContext])) + .thenReturn(Seq(anAPIDefinition())) + + intercept[NotFoundException] { + await(underTest.createSubscriptionForApplication(applicationId, api)) + } + + verify(mockWSO2APIStore, never).addSubscription(any(), any(), any(), any(), any())(any[HeaderCarrier]) + } + + "throw a NotFoundException when the API does not exist" in new Setup { + + when(mockApplicationRepository.fetch(applicationId)).thenReturn(successful(Some(applicationData))) + when(mockApiDefinitionConnector.fetchAllAPIs(refEq(applicationId))(any[HttpReads[Seq[APIDefinition]]](), any[HeaderCarrier], any[ExecutionContext])) + .thenReturn(Seq.empty) + + intercept[NotFoundException] { + await(underTest.createSubscriptionForApplication(applicationId, api)) + } + + verify(mockWSO2APIStore, never).addSubscription(any(), any(), any(), any(), any())(any[HeaderCarrier]) + } + + "throw a NotFoundException when the version does not exist for the given context" in new Setup { + val apiWithWrongVersion = api.copy(version = "10.0") + + when(mockApplicationRepository.fetch(applicationId)).thenReturn(successful(Some(applicationData))) + when(mockApiDefinitionConnector.fetchAllAPIs(refEq(applicationId))(any[HttpReads[Seq[APIDefinition]]](), any[HeaderCarrier](), any[ExecutionContext]())) + .thenReturn(Seq(anAPIDefinition())) + + intercept[NotFoundException] { + await(underTest.createSubscriptionForApplication(applicationId, apiWithWrongVersion)) + } + verify(mockWSO2APIStore, never).addSubscription(any(), any(), any(), any(), any())(any[HeaderCarrier]) + } + + "throw a NotFoundException when the API requires trust and the application is not trusted" in new Setup { + val trustedApi = anAPIDefinition().copy(requiresTrust = Some(true)) + + when(mockApplicationRepository.fetch(applicationId)).thenReturn(successful(Some(applicationData))) + when(mockApiDefinitionConnector.fetchAllAPIs(refEq(applicationId))(any[HttpReads[Seq[APIDefinition]]](), any[HeaderCarrier], any[ExecutionContext])) + .thenReturn(Seq(trustedApi)) + + intercept[NotFoundException] { + await(underTest.createSubscriptionForApplication(applicationId, api)) + } + verify(mockWSO2APIStore, never).addSubscription(any(), any(), any(), any(), any())(any[HeaderCarrier]) + } + } + + "removeSubscriptionForApplication" should { + val applicationId = UUID.randomUUID() + val api = anAPI() + + "throw a NotFoundException when no application exists in the repository for the given application id" in new Setup { + when(mockApplicationRepository.fetch(applicationId)).thenReturn(successful(None)) + + intercept[NotFoundException] { + await(underTest.removeSubscriptionForApplication(applicationId, api)) + } + } + + "remove the API subscription from WSO2 and Mongo for the given application id when an application exists" in new Setup { + val applicationData = anApplicationData(applicationId) + + when(mockApplicationRepository.fetch(applicationId)).thenReturn(successful(Some(applicationData))) + + val result = await(underTest.removeSubscriptionForApplication(applicationId, api)) + + result shouldBe HasSucceeded + verify(mockSubscriptionRepository).remove(applicationId, api) + verify(mockWSO2APIStore).removeSubscription(any(), any(), any(), any())(any[HeaderCarrier]) + verify(mockAuditService).audit(refEq(Unsubscribed), any[Map[String, String]])(refEq(hc)) + } + } + + "refreshSubscriptions" should { + val applicationId = UUID.randomUUID() + val api = anAPI() + val applicationData = anApplicationData(applicationId) + + "add in Mongo the subscriptions present in WSO2 and not in Mongo" in new Setup { + + given(mockApplicationRepository.findAll()).willReturn(List(applicationData)) + given(mockWSO2APIStore.getSubscriptions( + refEq(applicationData.wso2Username), refEq(applicationData.wso2Password), refEq(applicationData.wso2ApplicationName))(any[HeaderCarrier])) + .willReturn(Seq(api)) + given(mockSubscriptionRepository.getSubscriptions(applicationId)).willReturn(Seq.empty) + + val result = await(underTest.refreshSubscriptions()) + + result shouldBe 1 + verify(mockSubscriptionRepository).add(applicationId, api) + } + + "remove from Mongo the subscriptions not present in WSO2 " in new Setup { + + given(mockApplicationRepository.findAll()).willReturn(List(applicationData)) + given(mockWSO2APIStore.getSubscriptions( + refEq(applicationData.wso2Username), refEq(applicationData.wso2Password), refEq(applicationData.wso2ApplicationName))(any[HeaderCarrier])) + .willReturn(Seq.empty) + given(mockSubscriptionRepository.getSubscriptions(applicationId)).willReturn(Seq(api)) + + val result = await(underTest.refreshSubscriptions()) + + result shouldBe 1 + verify(mockSubscriptionRepository).remove(applicationId, api) + } + + "process multiple applications" in new Setup { + val applicationId2 = UUID.randomUUID() + val applicationData2 = anApplicationData(applicationId2) + + given(mockApplicationRepository.findAll()).willReturn(List(applicationData, applicationData2)) + given(mockWSO2APIStore.getSubscriptions(any(), any(), any())(any[HeaderCarrier])).willReturn(Seq(api)) + given(mockSubscriptionRepository.getSubscriptions(any())).willReturn(Seq.empty) + + val result = await(underTest.refreshSubscriptions()) + + result shouldBe 2 + verify(mockSubscriptionRepository).add(applicationId, api) + verify(mockSubscriptionRepository).add(applicationId2, api) + } + + "not refresh the subscriptions when fetching the subscriptions from WSO2 fail" in new Setup { + + given(mockApplicationRepository.findAll()).willReturn(List(applicationData)) + given(mockWSO2APIStore.getSubscriptions( + refEq(applicationData.wso2Username), refEq(applicationData.wso2Password), refEq(applicationData.wso2ApplicationName))(any[HeaderCarrier])) + .willReturn(failed(new RuntimeException("Something went wrong"))) + given(mockSubscriptionRepository.getSubscriptions(applicationId)).willReturn(Seq(api)) + + intercept[RuntimeException] { + await(underTest.refreshSubscriptions()) + } + + verify(mockSubscriptionRepository, never()).remove(applicationId, api) + } + } + + private val requestedByEmail = "john.smith@example.com" + + private def anApplicationData(applicationId: UUID, state: ApplicationState = productionState(requestedByEmail), + collaborators: Set[Collaborator] = Set(Collaborator(loggedInUser, ADMINISTRATOR)), + rateLimitTier: Option[RateLimitTier] = Some(BRONZE)) = { + new ApplicationData( + applicationId, + "MyApp", + "myapp", + collaborators, + Some("description"), + "aaaaaaaaaa", + "aaaaaaaaaa", + "aaaaaaaaaa", + ApplicationTokens(productionToken, sandboxToken), state, + Standard(Seq(), None, None), + new DateTime(), + rateLimitTier + ) + } + + private def anAPIVersion(version: String) = APIVersion(version, APIStatus.STABLE, None) + + private def anAPIDefinition(context: String = "some-context", versions: Seq[APIVersion] = Seq(anAPIVersion("1.0"))) = + APIDefinition("service", "name", context, versions, Some(false)) + + private def anAPI(context: String = "some-context", version: String = "1.0") = { + new APIIdentifier(context, version) + } + +} diff --git a/test/unit/services/WSO2APIStoreSpec.scala b/test/unit/services/WSO2APIStoreSpec.scala new file mode 100644 index 000000000..e3a41ceff --- /dev/null +++ b/test/unit/services/WSO2APIStoreSpec.scala @@ -0,0 +1,315 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * 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 services + +import org.mockito.Matchers.{any, anyInt, anyString} +import org.mockito.Mockito._ +import org.mockito.invocation.InvocationOnMock +import org.mockito.stubbing.Answer +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.mockito.MockitoSugar +import uk.gov.hmrc.connector.WSO2APIStoreConnector +import uk.gov.hmrc.models.RateLimitTier._ +import uk.gov.hmrc.models._ +import uk.gov.hmrc.play.test.UnitSpec +import uk.gov.hmrc.services.{RealWSO2APIStore, WSO2APIStore} +import uk.gov.hmrc.util.http.HttpHeaders.X_REQUEST_ID_HEADER + +import scala.concurrent.Future +import uk.gov.hmrc.http.HeaderCarrier + +class WSO2APIStoreSpec extends UnitSpec with ScalaFutures with MockitoSugar { + + trait Setup { + implicit val hc: HeaderCarrier = HeaderCarrier().withExtraHeaders(X_REQUEST_ID_HEADER -> "requestId") + val mockWSO2APIStoreConnector = mock[WSO2APIStoreConnector] + + val underTest = new RealWSO2APIStore(mockWSO2APIStoreConnector) { + override val resubscribeMaxRetries = 0 + } + + } + + "createApplication" should { + + "create an application in WSO2 and generate production and sandbox tokens" in new Setup { + + val wso2Username = "myuser" + val wso2Password = "mypassword" + val wso2ApplicationName = "myapplication" + val cookie = "some-cookie-value" + val tokens = ApplicationTokens(EnvironmentToken("aaa", "bbb", "ccc"), EnvironmentToken("111", "222", "333")) + + when(mockWSO2APIStoreConnector.createUser(wso2Username, wso2Password)) + .thenReturn(Future.successful(HasSucceeded)) + when(mockWSO2APIStoreConnector.login(wso2Username, wso2Password)) + .thenReturn(Future.successful(cookie)) + when(mockWSO2APIStoreConnector.createApplication(cookie, wso2ApplicationName)) + .thenReturn(Future.successful(HasSucceeded)) + when(mockWSO2APIStoreConnector.generateApplicationKey(cookie, wso2ApplicationName, Environment.SANDBOX)) + .thenReturn(Future.successful(tokens.sandbox)) + when(mockWSO2APIStoreConnector.generateApplicationKey(cookie, wso2ApplicationName, Environment.PRODUCTION)) + .thenReturn(Future.successful(tokens.production)) + when(mockWSO2APIStoreConnector.logout(cookie)).thenReturn(Future.successful(HasSucceeded)) + + val result = await(underTest.createApplication(wso2Username, wso2Password, wso2ApplicationName)) + + result shouldBe tokens + + verify(mockWSO2APIStoreConnector).logout(cookie) + + } + + } + + "updateApplication" should { + + "update rate limiting tier in wso2" in new Setup { + val wso2Username = "myuser" + val wso2Password = "mypassword" + val wso2ApplicationName = "myapplication" + val cookie = "some-cookie-value" + + when(mockWSO2APIStoreConnector.login(wso2Username, wso2Password)).thenReturn(Future.successful(cookie)) + when(mockWSO2APIStoreConnector.updateApplication(cookie, wso2ApplicationName, SILVER)). + thenReturn(Future.successful(HasSucceeded)) + when(mockWSO2APIStoreConnector.logout(cookie)).thenReturn(Future.successful(HasSucceeded)) + + await(underTest updateApplication(wso2Username, wso2Password, wso2ApplicationName, SILVER)) + + verify(mockWSO2APIStoreConnector).updateApplication(cookie, wso2ApplicationName, SILVER) + verify(mockWSO2APIStoreConnector).logout(cookie) + } + + } + + "deleteApplication" should { + + "delete an application in WSO2" in new Setup { + + val wso2Username = "myuser" + val wso2Password = "mypassword" + val wso2ApplicationName = "myapplication" + val cookie = "some-cookie-value" + + when(mockWSO2APIStoreConnector.login(wso2Username, wso2Password)).thenReturn(Future.successful(cookie)) + when(mockWSO2APIStoreConnector.deleteApplication(cookie, wso2ApplicationName)) + .thenReturn(Future.successful(HasSucceeded)) + when(mockWSO2APIStoreConnector.logout(cookie)).thenReturn(Future.successful(HasSucceeded)) + + await(underTest.deleteApplication(wso2Username, wso2Password, wso2ApplicationName)) + + verify(mockWSO2APIStoreConnector).deleteApplication(cookie, wso2ApplicationName) + verify(mockWSO2APIStoreConnector).logout(cookie) + } + + } + + "addSubscription" should { + + val wso2Username = "myuser" + val wso2Password = "mypassword" + val wso2ApplicationName = "myapplication" + val cookie = "some-cookie-value" + val wso2API = WSO2API("some--context--1.0", "1.0") + val api = APIIdentifier("some/context", "1.0") + + "add a subscription to an application in WSO2" in new Setup { + + when(mockWSO2APIStoreConnector.login(wso2Username, wso2Password)).thenReturn(Future.successful(cookie)) + when(mockWSO2APIStoreConnector.addSubscription(cookie, wso2ApplicationName, wso2API, Some(GOLD), 0)) + .thenReturn(Future.successful(HasSucceeded)) + when(mockWSO2APIStoreConnector.logout(cookie)).thenReturn(Future.successful(HasSucceeded)) + + await(underTest.addSubscription(wso2Username, wso2Password, wso2ApplicationName, api, Some(GOLD))) + + verify(mockWSO2APIStoreConnector).addSubscription(cookie, wso2ApplicationName, wso2API, Some(GOLD), 0) + verify(mockWSO2APIStoreConnector).logout(cookie) + } + + "fail when add subscription fails" in new Setup { + + when(mockWSO2APIStoreConnector.login(wso2Username, wso2Password)).thenReturn(Future.successful(cookie)) + when(mockWSO2APIStoreConnector.addSubscription(cookie, wso2ApplicationName, wso2API, Some(SILVER), 0)) + .thenReturn(Future.failed(new RuntimeException)) + when(mockWSO2APIStoreConnector.logout(cookie)).thenReturn(Future.successful(HasSucceeded)) + + intercept[RuntimeException] { + await(underTest.addSubscription(wso2Username, wso2Password, wso2ApplicationName, api, Some(SILVER))) + } + } + } + + "removeSubscription" should { + + "remove a subscription from an application in WSO2" in new Setup { + + val wso2Username = "myuser" + val wso2Password = "mypassword" + val wso2ApplicationName = "myapplication" + val cookie = "some-cookie-value" + val wso2API = WSO2API("some--context--1.0", "1.0") + val api = APIIdentifier("some/context", "1.0") + + when(mockWSO2APIStoreConnector.login(wso2Username, wso2Password)).thenReturn(Future.successful(cookie)) + when(mockWSO2APIStoreConnector.removeSubscription(cookie, wso2ApplicationName, wso2API, 0)) + .thenReturn(Future.successful(HasSucceeded)) + when(mockWSO2APIStoreConnector.logout(cookie)).thenReturn(Future.successful(HasSucceeded)) + + await(underTest.removeSubscription(wso2Username, wso2Password, wso2ApplicationName, api)) + + verify(mockWSO2APIStoreConnector).removeSubscription(cookie, wso2ApplicationName, wso2API, 0) + verify(mockWSO2APIStoreConnector).logout(cookie) + } + + } + + "resubscribeApi" should { + + val wso2Username = "myuser" + val wso2Password = "mypassword" + val wso2ApplicationName = "myapplication" + val cookie = "some-cookie-value" + val wso2Api = WSO2API("some--context--1.0", "1.0") + val api = APIIdentifier("some/context", "1.0") + val anotherWso2Api = WSO2API("some--context_2--1.0", "1.0") + val anotherApi = APIIdentifier("some/context_2", "1.0") + + "remove and then add subscriptions" in new Setup { + + when(mockWSO2APIStoreConnector.login(wso2Username, wso2Password)).thenReturn(Future.successful(cookie)) + + when(mockWSO2APIStoreConnector.removeSubscription(cookie, wso2ApplicationName, wso2Api, 0)) + .thenReturn(Future.successful(HasSucceeded)) + when(mockWSO2APIStoreConnector.addSubscription(cookie, wso2ApplicationName, wso2Api, Some(SILVER), 0)) + .thenReturn(Future.successful(HasSucceeded)) + + when(mockWSO2APIStoreConnector.removeSubscription(cookie, wso2ApplicationName, anotherWso2Api, 0)) + .thenReturn(Future.successful(HasSucceeded)) + when(mockWSO2APIStoreConnector.addSubscription(cookie, wso2ApplicationName, anotherWso2Api, Some(SILVER), 0)) + .thenReturn(Future.successful(HasSucceeded)) + + when(mockWSO2APIStoreConnector.logout(cookie)).thenReturn(Future.successful(HasSucceeded)) + + when(mockWSO2APIStoreConnector.getSubscriptions(cookie, wso2ApplicationName)).thenAnswer( + new Answer[Future[Seq[WSO2API]]] { + var count = 0 + override def answer(invocation: InvocationOnMock): Future[Seq[WSO2API]] = { + count += 1 + count match { + case 1 => Future.successful(Seq(anotherWso2Api)) + case 2 => Future.successful(Seq(wso2Api, anotherWso2Api)) + case 3 => Future.successful(Seq(wso2Api)) + case 4 => Future.successful(Seq(wso2Api, anotherWso2Api)) + case x => throw new IllegalStateException("Invocation not expected: " + x) + } + } + } + ) + + await(underTest.resubscribeApi(Seq(api, anotherApi), wso2Username, wso2Password, wso2ApplicationName, api, SILVER)) + await(underTest.resubscribeApi(Seq(api, anotherApi), wso2Username, wso2Password, wso2ApplicationName, anotherApi, SILVER)) + + verify(mockWSO2APIStoreConnector, times(2)).login(wso2Username, wso2Password) + + verify(mockWSO2APIStoreConnector, times(4)).getSubscriptions(cookie, wso2ApplicationName) + + verify(mockWSO2APIStoreConnector).removeSubscription(cookie, wso2ApplicationName, wso2Api, 0) + verify(mockWSO2APIStoreConnector).addSubscription(cookie, wso2ApplicationName, wso2Api, Some(SILVER), 0) + + verify(mockWSO2APIStoreConnector).removeSubscription(cookie, wso2ApplicationName, anotherWso2Api, 0) + verify(mockWSO2APIStoreConnector).addSubscription(cookie, wso2ApplicationName, anotherWso2Api, Some(SILVER), 0) + + verify(mockWSO2APIStoreConnector, times(2)).logout(cookie) + } + + "fail when remove subscription fails" in new Setup { + + when(mockWSO2APIStoreConnector.login(wso2Username, wso2Password)).thenReturn(Future.successful(cookie)) + when(mockWSO2APIStoreConnector.removeSubscription(cookie, wso2ApplicationName, wso2Api, 0)) + .thenReturn(Future.failed(new RuntimeException)) + when(mockWSO2APIStoreConnector.logout(cookie)).thenReturn(Future.successful(HasSucceeded)) + + intercept[RuntimeException] { + await(underTest.resubscribeApi(Seq(api), wso2Username, wso2Password, wso2ApplicationName, api, SILVER)) + } + + verify(mockWSO2APIStoreConnector, never()).addSubscription(anyString(), anyString(), any[WSO2API], any[Option[RateLimitTier]], anyInt())(any[HeaderCarrier]) + } + + "fail when add subscription fails" in new Setup { + + when(mockWSO2APIStoreConnector.login(wso2Username, wso2Password)).thenReturn(Future.successful(cookie)) + when(mockWSO2APIStoreConnector.removeSubscription(cookie, wso2ApplicationName, wso2Api, 0)) + .thenReturn(Future.successful(HasSucceeded)) + when(mockWSO2APIStoreConnector.addSubscription(cookie, wso2ApplicationName, wso2Api, Some(SILVER), 0)) + .thenReturn(Future.failed(new RuntimeException)) + when(mockWSO2APIStoreConnector.logout(cookie)).thenReturn(Future.successful(HasSucceeded)) + + intercept[RuntimeException] { + await(underTest.resubscribeApi(Seq(api), wso2Username, wso2Password, wso2ApplicationName, api, SILVER)) + } + } + } + + "getSubscriptions" should { + + "get subscriptions for an application from WSO2" in new Setup { + + val wso2Username = "myuser" + val wso2Password = "mypassword" + val wso2ApplicationName = "myapplication" + val cookie = "some-cookie-value" + val wso2Subscriptions = Seq(WSO2API("some--context--1.0", "1.0"), WSO2API("some--other--context--1.0", "1.0")) + val subscriptions = Seq(APIIdentifier("some/context", "1.0"), APIIdentifier("some/other/context", "1.0")) + + when(mockWSO2APIStoreConnector.login(wso2Username, wso2Password)).thenReturn(Future.successful(cookie)) + when(mockWSO2APIStoreConnector.getSubscriptions(cookie, wso2ApplicationName)) + .thenReturn(Future.successful(wso2Subscriptions)) + when(mockWSO2APIStoreConnector.logout(cookie)).thenReturn(Future.successful(HasSucceeded)) + + val result = await(underTest.getSubscriptions(wso2Username, wso2Password, wso2ApplicationName)) + + result shouldBe subscriptions + verify(mockWSO2APIStoreConnector).logout(cookie) + } + + } + + "getAllSubscriptions" should { + + "retrieve all subscriptions for all applications in WSO2" in new Setup { + + val wso2Username = "myuser" + val wso2Password = "mypassword" + val wso2ApplicationName = "myapplication" + val cookie = "some-cookie-value" + val wso2Subscriptions = Seq(WSO2API("some--context--1.0", "1.0"), WSO2API("some--other--context--1.0", "1.0")) + val subscriptions = Seq(APIIdentifier("some/context", "1.0"), APIIdentifier("some/other/context", "1.0")) + + when(mockWSO2APIStoreConnector.login(wso2Username, wso2Password)).thenReturn(Future.successful(cookie)) + when(mockWSO2APIStoreConnector.getAllSubscriptions(cookie)) + .thenReturn(Future.successful(Map(wso2ApplicationName -> wso2Subscriptions))) + when(mockWSO2APIStoreConnector.logout(cookie)).thenReturn(Future.successful(HasSucceeded)) + + val result = await(underTest.getAllSubscriptions(wso2Username, wso2Password)) + + result shouldBe Map(wso2ApplicationName -> subscriptions) + verify(mockWSO2APIStoreConnector).logout(cookie) + } + } +}