diff --git a/app/config/FrontendAppConfig.scala b/app/config/FrontendAppConfig.scala index b916881fe..ace395093 100644 --- a/app/config/FrontendAppConfig.scala +++ b/app/config/FrontendAppConfig.scala @@ -45,6 +45,13 @@ class FrontendAppConfig @Inject()(val servicesConfig: ServicesConfig, runModeCon lazy val eligibilityQuestionUrl: String = loadConfig("microservice.services.vat-registration-eligibility-frontend.question") implicit val appConfig: FrontendAppConfig = this + lazy val ttlLockSeconds:Int = 86400 + lazy val knownFactsLockAttemptLimit:Int = 3 + lazy val isKnownFactsCheckEnabled:Boolean = true + + + + private lazy val thresholdString: String = runModeConfiguration.get[ConfigList]("vat-threshold").render(ConfigRenderOptions.concise()) lazy val thresholds: Seq[VatThreshold] = Json.parse(thresholdString).as[List[VatThreshold]] @@ -288,6 +295,8 @@ class FrontendAppConfig @Inject()(val servicesConfig: ServicesConfig, runModeCon lazy val govukHowToRegister: String = "https://www.gov.uk/register-for-vat/how-register-for-vat" + lazy val vatTaskList: String = s"$host/register-for-vat/application-progress" + lazy val govukTogcVatNotice: String = "https://www.gov.uk/guidance/transfer-a-business-as-a-going-concern-and-vat-notice-7009" lazy val businessDescriptionMaxLength: Int = servicesConfig.getInt("constants.businessDescriptionMaxLength") diff --git a/app/controllers/bankdetails/AccountDetailsNotVerified.scala b/app/controllers/bankdetails/AccountDetailsNotVerified.scala new file mode 100644 index 000000000..6a6884afb --- /dev/null +++ b/app/controllers/bankdetails/AccountDetailsNotVerified.scala @@ -0,0 +1,54 @@ +/* + * Copyright 2026 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.bankdetails + +import config.{AuthClientConnector, BaseControllerComponents, FrontendAppConfig} +import controllers.BaseController +import play.api.data.Form +import play.api.data.Forms.{boolean, single} +import play.api.mvc.{Action, AnyContent} +import services.{LockService, SessionService} +import views.html.bankdetails.AccountDetailsNotVerifiedView + +import javax.inject.Inject +import scala.concurrent.ExecutionContext + +class AccountDetailsNotVerified @Inject()(val authConnector: AuthClientConnector, + val sessionService: SessionService, + lockService: LockService, + view: AccountDetailsNotVerifiedView) + (implicit appConfig: FrontendAppConfig, + val executionContext: ExecutionContext, + baseControllerComponents: BaseControllerComponents) extends BaseController { + + private val AttemptForm: Form[Boolean] = Form(single("value" -> boolean)) + + def show: Action[AnyContent] = isAuthenticatedWithProfile { + implicit request => implicit profile => + lockService.getBarsAttemptsUsed(profile.registrationId).map { attemptsUsed => + if (attemptsUsed >= appConfig.knownFactsLockAttemptLimit) { + Redirect(controllers.errors.routes.ThirdAttemptLockoutController.show) + } else { + val formWithAttempts = AttemptForm.bind(Map( + "value" -> "true", + "attempts" -> attemptsUsed.toString + )) + Ok(view(formWithAttempts)) + } + } + } +} diff --git a/app/controllers/bankdetails/UkBankAccountDetailsController.scala b/app/controllers/bankdetails/UkBankAccountDetailsController.scala index 4a7b2c48e..ca56fb7d4 100644 --- a/app/controllers/bankdetails/UkBankAccountDetailsController.scala +++ b/app/controllers/bankdetails/UkBankAccountDetailsController.scala @@ -18,10 +18,9 @@ package controllers.bankdetails import config.{AuthClientConnector, BaseControllerComponents, FrontendAppConfig} import controllers.BaseController -import forms.EnterBankAccountDetailsForm import forms.EnterBankAccountDetailsForm.{form => enterBankAccountDetailsForm} import play.api.mvc.{Action, AnyContent} -import services.{BankAccountDetailsService, SessionService} +import services.{BankAccountDetailsService, LockService, SessionService} import views.html.bankdetails.EnterCompanyBankAccountDetails import javax.inject.Inject @@ -30,6 +29,7 @@ import scala.concurrent.{ExecutionContext, Future} class UkBankAccountDetailsController @Inject()(val authConnector: AuthClientConnector, val bankAccountDetailsService: BankAccountDetailsService, val sessionService: SessionService, + val lockService: LockService, view: EnterCompanyBankAccountDetails) (implicit appConfig: FrontendAppConfig, val executionContext: ExecutionContext, @@ -38,10 +38,15 @@ class UkBankAccountDetailsController @Inject()(val authConnector: AuthClientConn def show: Action[AnyContent] = isAuthenticatedWithProfile { implicit request => implicit profile => - for { - bankDetails <- bankAccountDetailsService.fetchBankAccountDetails - filledForm = bankDetails.flatMap(_.details).fold(enterBankAccountDetailsForm)(enterBankAccountDetailsForm.fill) - } yield Ok(view(filledForm)) + lockService.getBarsAttemptsUsed(profile.registrationId).map(_ >= appConfig.knownFactsLockAttemptLimit).flatMap { + case true => + Future.successful(Redirect(controllers.errors.routes.ThirdAttemptLockoutController.show)) + case false => + for { + bankDetails <- bankAccountDetailsService.fetchBankAccountDetails + filledForm = bankDetails.flatMap(_.details).fold(enterBankAccountDetailsForm)(enterBankAccountDetailsForm.fill) + } yield Ok(view(filledForm)) + } } def submit: Action[AnyContent] = isAuthenticatedWithProfile { @@ -51,13 +56,17 @@ class UkBankAccountDetailsController @Inject()(val authConnector: AuthClientConn formWithErrors => Future.successful(BadRequest(view(formWithErrors))), accountDetails => - bankAccountDetailsService.saveEnteredBankAccountDetails(accountDetails).map { accountDetailsValid => + bankAccountDetailsService.saveEnteredBankAccountDetails(accountDetails).flatMap { accountDetailsValid => if (accountDetailsValid) { - Redirect(controllers.routes.TaskListController.show.url) - } - else { - val invalidDetails = EnterBankAccountDetailsForm.formWithInvalidAccountReputation.fill(accountDetails) - BadRequest(view(invalidDetails)) + Future.successful(Redirect(controllers.routes.TaskListController.show.url)) + } else { + lockService.incrementBarsAttempts(profile.registrationId).map { attempts => + if (attempts >= appConfig.knownFactsLockAttemptLimit) { + Redirect(controllers.errors.routes.ThirdAttemptLockoutController.show) + } else { + Redirect(controllers.bankdetails.routes.AccountDetailsNotVerified.show) + } + } } } ) diff --git a/app/controllers/errors/ThirdAttemptLockoutController.scala b/app/controllers/errors/ThirdAttemptLockoutController.scala new file mode 100644 index 000000000..2255ae189 --- /dev/null +++ b/app/controllers/errors/ThirdAttemptLockoutController.scala @@ -0,0 +1,42 @@ +/* + * Copyright 2026 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.errors + + +import play.api.mvc.{Action, AnyContent, MessagesControllerComponents} +import uk.gov.hmrc.auth.core.{AuthConnector, AuthorisedFunctions} +import config.FrontendAppConfig +import uk.gov.hmrc.play.bootstrap.frontend.controller.FrontendController +import services.SessionService +import views.html.errors.ThirdAttemptLockoutPage + +import javax.inject.{Inject, Singleton} +import scala.concurrent.{ExecutionContext, Future} + +@Singleton +class ThirdAttemptLockoutController @Inject()(mcc: MessagesControllerComponents, + view: ThirdAttemptLockoutPage, + val authConnector: AuthConnector + )(implicit appConfig: FrontendAppConfig, ec: ExecutionContext) extends FrontendController(mcc) with AuthorisedFunctions { + + def show(): Action[AnyContent] = Action.async { + implicit request => + authorised() { + Future.successful(Ok(view())) + } + } +} \ No newline at end of file diff --git a/app/models/Lock.scala b/app/models/Lock.scala new file mode 100644 index 000000000..e6993794c --- /dev/null +++ b/app/models/Lock.scala @@ -0,0 +1,31 @@ +/* + * Copyright 2026 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 play.api.libs.json.{Format, Json, OFormat} +import uk.gov.hmrc.mongo.play.json.formats.MongoJavatimeFormats + +import java.time.Instant + +case class Lock(identifier: String, + failedAttempts: Int, + lastAttemptedAt: Instant) + +object Lock { + implicit val instantFormat: Format[Instant] = MongoJavatimeFormats.instantFormat + implicit lazy val format: OFormat[Lock] = Json.format +} \ No newline at end of file diff --git a/app/repositories/UserLockRepository.scala b/app/repositories/UserLockRepository.scala new file mode 100644 index 000000000..f5942abc2 --- /dev/null +++ b/app/repositories/UserLockRepository.scala @@ -0,0 +1,108 @@ +/* + * Copyright 2026 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 repositories + +import org.mongodb.scala.model.Indexes.ascending +import org.mongodb.scala.model._ +import play.api.libs.json._ +import config.FrontendAppConfig +import models.Lock +import models.Lock._ +import uk.gov.hmrc.mongo.MongoComponent +import uk.gov.hmrc.mongo.play.json.PlayMongoRepository + +import java.time.Instant +import java.util.concurrent.TimeUnit +import javax.inject.{Inject, Singleton} +import scala.concurrent.{ExecutionContext, Future} + +@Singleton +class UserLockRepository @Inject()( + mongoComponent: MongoComponent, + appConfig: FrontendAppConfig + )(implicit ec: ExecutionContext) extends PlayMongoRepository[Lock]( + collectionName = "user-lock", + mongoComponent = mongoComponent, + domainFormat = implicitly[Format[Lock]], + indexes = Seq( + IndexModel( + keys = ascending("lastAttemptedAt"), + indexOptions = IndexOptions() + .name("CVEInvalidDataLockExpires") + .expireAfter(appConfig.ttlLockSeconds, TimeUnit.SECONDS) + ), + IndexModel( + keys = ascending("identifier"), + indexOptions = IndexOptions() + .name("IdentifierIdx") + .sparse(true) + .unique(true) + ) + ), + replaceIndexes = true +) { + + def getFailedAttempts(identifier: String): Future[Int] = + collection + .find(Filters.eq("identifier", identifier)) + .headOption() + .map(_.map(_.failedAttempts).getOrElse(0)) + + def isUserLocked(userId: String): Future[Boolean] = { + collection + .find(Filters.in("identifier", userId)) + .toFuture() + .map { _.exists { _.failedAttempts >= appConfig.knownFactsLockAttemptLimit }} + } + + def updateAttempts(userId: String): Future[Map[String, Int]] = { + def updateAttemptsForLockWith(identifier: String): Future[Lock] = { + collection + .find(Filters.eq("identifier", identifier)) + .headOption() + .flatMap { + case Some(existingLock) => + val newLock = existingLock.copy( + failedAttempts = existingLock.failedAttempts + 1, + lastAttemptedAt = Instant.now() + ) + collection.replaceOne( + Filters.and( + Filters.eq("identifier", identifier) + ), + newLock + ) + .toFuture() + .map(_ => newLock) + case _ => + val newLock = Lock(identifier, 1, Instant.now) + collection.insertOne(newLock) + .toFuture() + .map(_ => newLock) + } + } + val updateUserLock = updateAttemptsForLockWith(userId) + + for { + userLock <- updateUserLock + } yield { + Map( + "user" -> userLock.failedAttempts + ) + } + } +} \ No newline at end of file diff --git a/app/services/LockService.scala b/app/services/LockService.scala new file mode 100644 index 000000000..49f67ad24 --- /dev/null +++ b/app/services/LockService.scala @@ -0,0 +1,62 @@ +/* + * Copyright 2026 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 play.api.mvc.Result +import play.api.mvc.Results.Redirect +import config.FrontendAppConfig +import controllers.errors +import repositories.UserLockRepository +import utils.LoggingUtil + +import javax.inject.{Inject, Singleton} +import scala.concurrent.{ExecutionContext, Future} + +@Singleton +class LockService @Inject()(userLockRepository: UserLockRepository, + config: FrontendAppConfig)(implicit ec: ExecutionContext) extends LoggingUtil{ + + + + def updateAttempts(userId: String): Future[Map[String, Int]] = { + if (config.isKnownFactsCheckEnabled) { + userLockRepository.updateAttempts(userId) + } else { + Future.successful(Map.empty) + } + } + + def isJourneyLocked(userId: String): Future[Boolean] = { + if (config.isKnownFactsCheckEnabled) { + userLockRepository.isUserLocked(userId) + } else { + Future.successful(false) + } + } + + // ---- BARs bank account lock methods ---- + + def getBarsAttemptsUsed(registrationId: String): Future[Int] = + userLockRepository.getFailedAttempts(registrationId) + + def incrementBarsAttempts(registrationId: String): Future[Int] = + userLockRepository.updateAttempts(registrationId).map(_.getOrElse("user", 0)) + + def isBarsLocked(registrationId: String): Future[Boolean] = + userLockRepository.isUserLocked(registrationId) +} diff --git a/app/views/bankdetails/AccountDetailsNotVerifiedView.scala.html b/app/views/bankdetails/AccountDetailsNotVerifiedView.scala.html new file mode 100644 index 000000000..82bebb867 --- /dev/null +++ b/app/views/bankdetails/AccountDetailsNotVerifiedView.scala.html @@ -0,0 +1,57 @@ +@* + * Copyright 2026 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 config.FrontendAppConfig +@import play.api.data.Form +@import play.api.i18n.Messages +@import play.api.mvc.Request +@import uk.gov.hmrc.govukfrontend.views.html.components._ + +@this( + layout: layouts.layout, + h1: components.h1, + p: components.p, + formWithCSRF: FormWithCSRF, + errorSummary: components.errorSummary, +) + +@(form: Form[Boolean])(implicit request: Request[_], messages: Messages, appConfig: FrontendAppConfig) + + +@layout(pageTitle = Some(title(form, messages("pages.accountDetailsCouldNotBeVerified.heading")))){ + + @errorSummary(errors = form.errors) + + @h1("pages.accountDetailsCouldNotBeVerified.heading") + + @p { + @messages("pages.accountDetailsCouldNotBeVerified.para1", 3 - form.data.get("attempts").map(_.toInt).getOrElse(0)) + } + + @p { + @Html(messages("pages.accountDetailsCouldNotBeVerified.para2", appConfig.vatTaskList)) + } +@* + * @p { + * @messages("pages.accountDetailsCouldNotBeVerified.para2") + * } +*@ + + @p { + @messages("pages.accountDetailsCouldNotBeVerified.para3.bold") @messages("pages.accountDetailsCouldNotBeVerified.para3") + } + +} \ No newline at end of file diff --git a/app/views/errors/ThirdAttemptLockoutPage.scala.html b/app/views/errors/ThirdAttemptLockoutPage.scala.html new file mode 100644 index 000000000..d348775a8 --- /dev/null +++ b/app/views/errors/ThirdAttemptLockoutPage.scala.html @@ -0,0 +1,49 @@ +@* + * Copyright 2026 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 config.FrontendAppConfig +@import play.api.i18n.Messages +@import play.api.mvc.Request +@import views.html.components._ +@import views.html.layouts.layout +@import uk.gov.hmrc.govukfrontend.views.html.components._ + + +@this(layout: layout, + h1: h1, + p: p, + govUkHeader: GovukHeader, + govukButton: GovukButton +) + +@()(implicit request: Request[_], messages: Messages, appConfig: FrontendAppConfig) + +@layout(Some(titleNoForm(messages("ThirdAttemptLockoutPage.heading"))), backLink = false) { + + @h1(messages("ThirdAttemptLockoutPage.heading")) + + @p { + @messages("pages.ThirdAttemptLockoutPage.para1") + } + + @p { + @messages("pages.ThirdAttemptLockoutPage.para2") + } + + @p { + @Html(messages("pages.ThirdAttemptLockoutPage.para3", appConfig.vatTaskList)) + } +} \ No newline at end of file diff --git a/conf/app.routes b/conf/app.routes index 7254020a3..4a8628d5f 100644 --- a/conf/app.routes +++ b/conf/app.routes @@ -631,3 +631,7 @@ POST /partner/:index/partner-telephone controller ## Partner Email Address Page GET /partner/:index/email-address controllers.partners.PartnerCaptureEmailAddressController.show(index: Int) POST /partner/:index/email-address controllers.partners.PartnerCaptureEmailAddressController.submit(index: Int) + +# Lockout screens +GET /failed-third-attempt controllers.errors.ThirdAttemptLockoutController.show +GET /failed-attempt controllers.bankdetails.AccountDetailsNotVerified.show \ No newline at end of file diff --git a/conf/messages b/conf/messages index dd8a986f1..81e17557a 100644 --- a/conf/messages +++ b/conf/messages @@ -2028,3 +2028,14 @@ partnerEmail.error.incorrect_format = Enter the email address in the partnerEmail.error.nothing_entered = Enter the email address partnerEmail.error.incorrect_length = The email address must be 132 characters or fewer +#Account Details could not be verified +pages.accountDetailsCouldNotBeVerified.heading = We could not verify the bank details you provided +pages.accountDetailsCouldNotBeVerified.para1 = You have {0} more attempts to provide your account details. +pages.accountDetailsCouldNotBeVerified.para2 = Enter your bank account or building society details again, making sure the name matches exactly as it appears on your account. +pages.accountDetailsCouldNotBeVerified.para3.bold = After 3 consecutive unsuccessful attempts, +pages.accountDetailsCouldNotBeVerified.para3 = you will need to complete your VAT registration before sending us your details. + +ThirdAttemptLockoutPage.heading = Account details could not be verified +pages.ThirdAttemptLockoutPage.para1 = We have been unable to verify the account details you supplied. +pages.ThirdAttemptLockoutPage.para2 = For your security, we have paused this part of the service. +pages.ThirdAttemptLockoutPage.para3 = You can return to the VAT registration task list now, and provide your account details later once your registration is confirmed. \ No newline at end of file diff --git a/test/controllers/bankdetails/AccountDetailsNotVerifiedSpec.scala b/test/controllers/bankdetails/AccountDetailsNotVerifiedSpec.scala new file mode 100644 index 000000000..e58c58d10 --- /dev/null +++ b/test/controllers/bankdetails/AccountDetailsNotVerifiedSpec.scala @@ -0,0 +1,87 @@ +/* + * Copyright 2026 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.bankdetails + +import fixtures.VatRegistrationFixture +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito._ +import play.api.test.FakeRequest +import services.LockService +import testHelpers.{ControllerSpec, FutureAssertions} +import views.html.bankdetails.AccountDetailsNotVerifiedView + +import scala.concurrent.Future + +class AccountDetailsNotVerifiedSpec extends ControllerSpec with VatRegistrationFixture with FutureAssertions { + + val mockLockService: LockService = mock[LockService] + val view: AccountDetailsNotVerifiedView = app.injector.instanceOf[AccountDetailsNotVerifiedView] + + trait Setup { + val testController = new AccountDetailsNotVerified( + mockAuthClientConnector, + mockSessionService, + mockLockService, + view + ) + + mockAuthenticated() + mockWithCurrentProfile(Some(currentProfile)) + } + + "show" should { + "return 200 when attempts used is below the lockout limit" in new Setup { + when(mockLockService.getBarsAttemptsUsed(any())) + .thenReturn(Future.successful(1)) + + callAuthorised(testController.show) { result => + status(result) mustBe OK + } + } + + "return 200 when attempts used is one below the lockout limit" in new Setup { + // knownFactsLockAttemptLimit is 3 in appConfig, so 2 attempts should still show the page + when(mockLockService.getBarsAttemptsUsed(any())) + .thenReturn(Future.successful(2)) + + callAuthorised(testController.show) { result => + status(result) mustBe OK + } + } + + "redirect to ThirdAttemptLockout when attempts used is at or above the lockout limit" in new Setup { + when(mockLockService.getBarsAttemptsUsed(any())) + .thenReturn(Future.successful(3)) // >= appConfig.knownFactsLockAttemptLimit (3) + + callAuthorised(testController.show) { result => + status(result) mustBe SEE_OTHER + redirectLocation(result) mustBe Some(controllers.errors.routes.ThirdAttemptLockoutController.show.url) + } + } + + "redirect to ThirdAttemptLockout when attempts used exceeds the lockout limit" in new Setup { + when(mockLockService.getBarsAttemptsUsed(any())) + .thenReturn(Future.successful(5)) + + callAuthorised(testController.show) { result => + status(result) mustBe SEE_OTHER + redirectLocation(result) mustBe Some(controllers.errors.routes.ThirdAttemptLockoutController.show.url) + } + } + } +} + diff --git a/test/controllers/errors/ThirdAttemptLockoutControllerSpec.scala b/test/controllers/errors/ThirdAttemptLockoutControllerSpec.scala new file mode 100644 index 000000000..1d5ac5965 --- /dev/null +++ b/test/controllers/errors/ThirdAttemptLockoutControllerSpec.scala @@ -0,0 +1,53 @@ +/* + * Copyright 2026 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.errors + +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito._ +import play.api.test.FakeRequest +import testHelpers.{ControllerSpec, FutureAssertions} +import views.html.errors.ThirdAttemptLockoutPage + +import scala.concurrent.Future + +class ThirdAttemptLockoutControllerSpec extends ControllerSpec with FutureAssertions { + + val view: ThirdAttemptLockoutPage = app.injector.instanceOf[ThirdAttemptLockoutPage] + + trait Setup { + val testController = new ThirdAttemptLockoutController( + messagesControllerComponents, + view, + mockAuthConnector + ) + + // ThirdAttemptLockoutController uses AuthorisedFunctions which calls authConnector.authorise + // directly, so we stub mockAuthConnector (uk.gov.hmrc.auth.core.AuthConnector) rather than + // mockAuthClientConnector. + when(mockAuthConnector.authorise[Unit](any(), any())(any(), any())) + .thenReturn(Future.successful(())) + } + + "show" should { + "return 200 and render the lockout page" in new Setup { + val result = testController.show()(FakeRequest()) + status(result) mustBe OK + contentType(result) mustBe Some("text/html") + } + } +} + diff --git a/test/services/LockServiceSpec.scala b/test/services/LockServiceSpec.scala new file mode 100644 index 000000000..bd354b5c9 --- /dev/null +++ b/test/services/LockServiceSpec.scala @@ -0,0 +1,102 @@ +/* + * Copyright 2026 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 config.FrontendAppConfig +import org.mockito.ArgumentMatchers.{any, eq => eqTo} +import org.mockito.Mockito._ +import org.scalatestplus.mockito.MockitoSugar +import org.scalatestplus.play.PlaySpec +import play.api.test.{DefaultAwaitTimeout, FutureAwaits} +import repositories.UserLockRepository + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +class LockServiceSpec extends PlaySpec with MockitoSugar with FutureAwaits with DefaultAwaitTimeout { + + val mockUserLockRepository: UserLockRepository = mock[UserLockRepository] + val mockAppConfig: FrontendAppConfig = mock[FrontendAppConfig] + + val registrationId = "reg-123" + + trait Setup { + val service: LockService = new LockService(mockUserLockRepository, mockAppConfig) + } + + // ---- getBarsAttemptsUsed ---- + + "getBarsAttemptsUsed" should { + "return the number of failed attempts from the repository" in new Setup { + when(mockUserLockRepository.getFailedAttempts(eqTo(registrationId))) + .thenReturn(Future.successful(2)) + + await(service.getBarsAttemptsUsed(registrationId)) mustBe 2 + } + + "return 0 when there are no recorded attempts" in new Setup { + when(mockUserLockRepository.getFailedAttempts(eqTo(registrationId))) + .thenReturn(Future.successful(0)) + + await(service.getBarsAttemptsUsed(registrationId)) mustBe 0 + } + } + + // ---- incrementBarsAttempts ---- + + "incrementBarsAttempts" should { + "return the new total number of failed attempts after incrementing" in new Setup { + when(mockUserLockRepository.updateAttempts(eqTo(registrationId))) + .thenReturn(Future.successful(Map("user" -> 1))) + + await(service.incrementBarsAttempts(registrationId)) mustBe 1 + } + + "return 0 if the repository map does not contain the 'user' key" in new Setup { + when(mockUserLockRepository.updateAttempts(eqTo(registrationId))) + .thenReturn(Future.successful(Map.empty[String, Int])) + + await(service.incrementBarsAttempts(registrationId)) mustBe 0 + } + + "return 3 on the third failed attempt" in new Setup { + when(mockUserLockRepository.updateAttempts(eqTo(registrationId))) + .thenReturn(Future.successful(Map("user" -> 3))) + + await(service.incrementBarsAttempts(registrationId)) mustBe 3 + } + } + + // ---- isBarsLocked ---- + + "isBarsLocked" should { + "return true when the user is locked in the repository" in new Setup { + when(mockUserLockRepository.isUserLocked(eqTo(registrationId))) + .thenReturn(Future.successful(true)) + + await(service.isBarsLocked(registrationId)) mustBe true + } + + "return false when the user is not locked in the repository" in new Setup { + when(mockUserLockRepository.isUserLocked(eqTo(registrationId))) + .thenReturn(Future.successful(false)) + + await(service.isBarsLocked(registrationId)) mustBe false + } + } +} +