Skip to content

Commit

Permalink
[OP,HB] APB-1664 Implement time based expiry of invitations. Invitati…
Browse files Browse the repository at this point in the history
…ons are marked as expired after first access after the point (in time) of expiry has passed. (#128)
  • Loading branch information
opetch authored and arturopala committed Dec 7, 2017
1 parent 52f291f commit de95460
Show file tree
Hide file tree
Showing 14 changed files with 213 additions and 49 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import javax.inject.{Inject, Singleton}

import play.api.mvc.Request
import uk.gov.hmrc.agentclientauthorisation.audit.AgentClientInvitationEvent.AgentClientInvitationEvent
import uk.gov.hmrc.agentclientauthorisation.model.InvitationStatus
import uk.gov.hmrc.agentclientauthorisation.model.{Invitation, InvitationStatus}
import uk.gov.hmrc.agentmtdidentifiers.model.{Arn, MtdItId}
import uk.gov.hmrc.http.HeaderCarrier
import uk.gov.hmrc.play.audit.AuditExtensions._
Expand Down Expand Up @@ -50,6 +50,16 @@ class AuditService @Inject()(val auditConnector: AuditConnector) {
))
}

def sendInvitationExpired(invitation: Invitation)(implicit hc: HeaderCarrier, request: Request[Any]): Unit = {
auditEvent(AgentClientInvitationEvent.AgentClientInvitationResponse, "Client responded to agent invitation",
Seq(
"invitationId" -> invitation.id.stringify,
"agentReferenceNumber" -> invitation.arn
// "regimeId" -> "TODO", populate with values once a regime/service has been modelled into core
// "regime" -> "HMRC-MTD-IT"
))
}

private[audit] def auditEvent(event: AgentClientInvitationEvent, transactionName: String, details: Seq[(String, Any)] = Seq.empty)(implicit hc: HeaderCarrier, request: Request[Any]): Future[Unit] = {
send(createEvent(event, transactionName, details: _*))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import uk.gov.hmrc.agentclientauthorisation.connectors.AuthConnector
import uk.gov.hmrc.agentclientauthorisation.controllers.ErrorResults.{InvitationNotFound, NoPermissionOnAgency, invalidInvitationStatus}
import uk.gov.hmrc.agentclientauthorisation.controllers.actions.AgentInvitationValidation
import uk.gov.hmrc.agentclientauthorisation.model._
import uk.gov.hmrc.agentclientauthorisation.service.{InvitationsService, PostcodeService}
import uk.gov.hmrc.agentclientauthorisation.service.{InvitationsService, PostcodeService, StatusUpdateFailure}
import uk.gov.hmrc.agentmtdidentifiers.model.{Arn, InvitationId}
import uk.gov.hmrc.play.http.logging.MdcLoggingExecutionContext._

Expand Down Expand Up @@ -125,7 +125,7 @@ class AgencyInvitationsController @Inject()(override val postcodeService: Postco
invitationsService.findInvitation(invitationId) flatMap {
case Some(i) if i.arn == givenArn => invitationsService.cancelInvitation(i) map {
case Right(_) => NoContent
case Left(message) => invalidInvitationStatus(message)
case Left(StatusUpdateFailure(_, msg)) => invalidInvitationStatus(msg)
}
case None => Future successful InvitationNotFound
case _ => Future successful NoPermissionOnAgency
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import uk.gov.hmrc.agentclientauthorisation.audit.AuditService
import uk.gov.hmrc.agentclientauthorisation.connectors.AuthConnector
import uk.gov.hmrc.agentclientauthorisation.controllers.ErrorResults._
import uk.gov.hmrc.agentclientauthorisation.model.{Invitation, InvitationStatus}
import uk.gov.hmrc.agentclientauthorisation.service.InvitationsService
import uk.gov.hmrc.agentclientauthorisation.service.{InvitationsService, StatusUpdateFailure}
import uk.gov.hmrc.agentmtdidentifiers.model.{InvitationId, MtdItId}
import uk.gov.hmrc.http.HeaderCarrier
import uk.gov.hmrc.play.http.logging.MdcLoggingExecutionContext._
Expand Down Expand Up @@ -71,14 +71,14 @@ class ClientInvitationsController @Inject()(invitationsService: InvitationsServi
}
}

private def actionInvitation(mtdItId: MtdItId, invitationId: InvitationId, action: Invitation => Future[Either[String, Invitation]])
private def actionInvitation(mtdItId: MtdItId, invitationId: InvitationId, action: Invitation => Future[Either[StatusUpdateFailure, Invitation]])
(implicit hc: HeaderCarrier, request: Request[AnyContent]) = {
invitationsService.findInvitation(invitationId) flatMap {
case Some(invitation)
if invitation.clientId == mtdItId.value =>
action(invitation) map {
case Right(_) => NoContent
case Left(message) => invalidInvitationStatus(message)
case Left(StatusUpdateFailure(_, msg)) => invalidInvitationStatus(msg)
}
case None => Future successful InvitationNotFound
case _ => Future successful NoPermissionOnClient
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ object ErrorResults {
val PostcodeDoesNotMatch = Forbidden(toJson(ErrorBody("POSTCODE_DOES_NOT_MATCH", "The submitted postcode did not match the client's postcode as held by HMRC.")))
val InvitationNotFound = NotFound(toJson(ErrorBody("INVITATION_NOT_FOUND", "The specified invitation was not found.")))
val InvalidNino = BadRequest(toJson(ErrorBody("INVALID_NINO", "The NINO specified is not in a valid format")))
val InvitationExpired = Gone(toJson(ErrorBody("INVITATION_EXPIRED", "Invitation has expired")))
def nonUkAddress(countryCode: String) = NotImplemented(toJson(ErrorBody("NON_UK_ADDRESS", s"This API does not currently support non-UK addresses. The client's country code should be 'GB' but it was '$countryCode'.")))
def invalidInvitationStatus(message: String) = Forbidden(toJson(ErrorBody("INVALID_INVITATION_STATUS", message)))
def unsupportedService(message: String) = NotImplemented(toJson(ErrorBody("UNSUPPORTED_SERVICE", message)))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class GuiceModule() extends AbstractModule with ServicesConfig {
bindBaseUrl("des")
bindProperty("des.environment", "des.environment")
bindProperty("des.authorizationToken", "des.authorization-token")
bindProperty("invitation.expiryDuration", "invitation.expiryDuration")
}

private def bindBaseUrl(serviceName: String) =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ sealed trait InvitationStatus {

case object Pending extends InvitationStatus

case object Expired extends InvitationStatus

case object Rejected extends InvitationStatus

case object Accepted extends InvitationStatus
Expand All @@ -51,6 +53,7 @@ object InvitationStatus {
case Rejected => Some("Rejected")
case Accepted => Some("Accepted")
case Cancelled => Some("Cancelled")
case Expired => Some("Expired")
case _ => None
}

Expand All @@ -59,6 +62,7 @@ object InvitationStatus {
case "rejected" => Rejected
case "accepted" => Accepted
case "cancelled" => Cancelled
case "expired" => Expired
case _ => Unknown(status)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,38 @@

package uk.gov.hmrc.agentclientauthorisation.service

import java.util.concurrent.TimeUnit.DAYS
import javax.inject._

import org.joda.time.DateTime
import play.api.mvc.Request
import uk.gov.hmrc.agentclientauthorisation._
import uk.gov.hmrc.agentclientauthorisation.audit.AuditService
import uk.gov.hmrc.agentclientauthorisation.connectors.{DesConnector, RelationshipsConnector}
import uk.gov.hmrc.agentclientauthorisation.model._
import uk.gov.hmrc.agentclientauthorisation.repository.InvitationsRepository
import uk.gov.hmrc.agentmtdidentifiers.model.{Arn, InvitationId, MtdItId}
import uk.gov.hmrc.agentmtdidentifiers.model._
import uk.gov.hmrc.domain.Nino

import scala.concurrent.duration
import scala.concurrent.duration.Duration
import uk.gov.hmrc.http.HeaderCarrier


case class StatusUpdateFailure(currentStatus: InvitationStatus, failureReason: String)

import scala.concurrent.{ExecutionContext, Future}

@Singleton
class InvitationsService @Inject()(invitationsRepository: InvitationsRepository,
relationshipsConnector: RelationshipsConnector, desConnector: DesConnector) {
relationshipsConnector: RelationshipsConnector,
desConnector: DesConnector,
auditService: AuditService,
@Named("invitation.expiryDuration") invitationExpiryDurationValue: String) {

private val invitationExpiryDuration = Duration(invitationExpiryDurationValue)
private val invitationExpiryUnits = invitationExpiryDuration.unit

def translateToMtdItId(clientId: String, clientIdType: String)
(implicit hc: HeaderCarrier, ec: ExecutionContext): Future[Option[MtdItId]] = {
clientIdType match {
Expand All @@ -48,25 +65,49 @@ class InvitationsService @Inject()(invitationsRepository: InvitationsRepository,
(implicit ec: ExecutionContext): Future[Invitation] =
invitationsRepository.create(arn, service, clientId, postcode, suppliedClientId, suppliedClientIdType)

def acceptInvitation(invitation: Invitation)(implicit hc: HeaderCarrier, ec: ExecutionContext): Future[Either[StatusUpdateFailure, Invitation]] = {
invitation.status match {
case Pending => {
relationshipsConnector.createRelationship(invitation.arn, MtdItId(invitation.clientId))
.flatMap(_ => changeInvitationStatus(invitation, model.Accepted))
}
case _ => Future successful cannotTransitionBecauseNotPending(invitation, Accepted)
}
}

def acceptInvitation(invitation: Invitation)(implicit hc: HeaderCarrier, ec: ExecutionContext): Future[Either[String, Invitation]] = {
if (invitation.status == Pending) {
relationshipsConnector.createRelationship(invitation.arn, MtdItId(invitation.clientId))
.flatMap(_ => changeInvitationStatus(invitation, model.Accepted))
} else {
Future successful cannotTransitionBecauseNotPending(invitation, Accepted)
private[service] def isInvitationExpired(invitation: Invitation, currentDateTime: () => DateTime = DateTime.now) = {
val createTime = invitation.firstEvent.time
val fromTime = if (invitationExpiryUnits == DAYS) createTime.millisOfDay().withMinimumValue() else createTime
val elapsedTime = Duration.create(currentDateTime().getMillis - fromTime.getMillis, duration.MILLISECONDS)
elapsedTime gt invitationExpiryDuration
}

private def updateStatusToExpired(invitation: Invitation)(implicit ec: ExecutionContext,
hc: HeaderCarrier, request: Request[Any]): Future[Invitation] = {
changeInvitationStatus(invitation, Expired).map { a =>
if (a.isLeft) throw new Exception("Failed to transition invitation state to Expired")
auditService.sendInvitationExpired(invitation)
a.right.get
}
}

def cancelInvitation(invitation: Invitation)(implicit ec: ExecutionContext): Future[Either[String, Invitation]] =
def cancelInvitation(invitation: Invitation)(implicit ec: ExecutionContext): Future[Either[StatusUpdateFailure, Invitation]] =
changeInvitationStatus(invitation, model.Cancelled)

def rejectInvitation(invitation: Invitation)(implicit ec: ExecutionContext): Future[Either[String, Invitation]] =
def rejectInvitation(invitation: Invitation)(implicit ec: ExecutionContext): Future[Either[StatusUpdateFailure, Invitation]] =
changeInvitationStatus(invitation, model.Rejected)

def findInvitation(invitationId: InvitationId)(implicit ec: ExecutionContext): Future[Option[Invitation]] = {
def findInvitation(invitationId: InvitationId)(implicit ec: ExecutionContext, hc: HeaderCarrier,
request: Request[Any]): Future[Option[Invitation]] = {
invitationsRepository.find("invitationId" -> invitationId)
.map(_.headOption)
.flatMap { invitationResult =>
if (invitationResult.isEmpty) Future successful None else {
val invitation = invitationResult.get
if (isInvitationExpired(invitation)) updateStatusToExpired(invitation).map(Some(_))
else Future successful invitationResult
}
}
}

def clientsReceived(service: String, clientId: MtdItId, status: Option[InvitationStatus])
Expand All @@ -80,14 +121,14 @@ class InvitationsService @Inject()(invitationsRepository: InvitationsRepository,
else Future successful List.empty

private def changeInvitationStatus(invitation: Invitation, status: InvitationStatus)
(implicit ec: ExecutionContext): Future[Either[String, Invitation]] = {
(implicit ec: ExecutionContext): Future[Either[StatusUpdateFailure, Invitation]] = {
invitation.status match {
case Pending => invitationsRepository.update(invitation.id, status) map (invitation => Right(invitation))
case _ => Future successful cannotTransitionBecauseNotPending(invitation, status)
}
}

private def cannotTransitionBecauseNotPending(invitation: Invitation, toStatus: InvitationStatus) = {
Left(s"The invitation cannot be transitioned to $toStatus because its current status is ${invitation.status}. Only Pending invitations may be transitioned to $toStatus.")
Left(StatusUpdateFailure(invitation.status, s"The invitation cannot be transitioned to $toStatus because its current status is ${invitation.status}. Only Pending invitations may be transitioned to $toStatus."))
}
}
Empty file.
3 changes: 3 additions & 0 deletions conf/application.conf
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@ microservice {
service-locator {
enabled = false
}
invitation {
expiryDuration = 10 days
}
}

whitelist {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import uk.gov.hmrc.agentclientauthorisation.UriPathEncoding.encodePathSegments
import uk.gov.hmrc.agentclientauthorisation.connectors.AuthConnector
import uk.gov.hmrc.agentclientauthorisation.controllers.ErrorResults._
import uk.gov.hmrc.agentclientauthorisation.model._
import uk.gov.hmrc.agentclientauthorisation.service.{InvitationsService, PostcodeService}
import uk.gov.hmrc.agentclientauthorisation.service.{InvitationsService, PostcodeService, StatusUpdateFailure}
import uk.gov.hmrc.agentclientauthorisation.support.TestConstants._
import uk.gov.hmrc.agentclientauthorisation.support.{AkkaMaterializerSpec, ResettingMockitoSugar, TestData, TransitionInvitation}
import uk.gov.hmrc.agentmtdidentifiers.model.{Arn, InvitationId, MtdItId}
Expand Down Expand Up @@ -193,7 +193,7 @@ class AgencyInvitationsControllerSpec extends AkkaMaterializerSpec with Resettin
val cancelledInvitation = transitionInvitation(invitation, Cancelled)

whenAnInvitationIsCancelled(any()) thenReturn (Future successful Right(cancelledInvitation))
whenFindingAnInvitation()(any()) thenReturn (Future successful Some(invitation))
whenFindingAnInvitation() thenReturn (Future successful Some(invitation))

val response = await(controller.cancelInvitation(arn, mtdSaPendingInvitationId)(FakeRequest()))

Expand All @@ -204,8 +204,8 @@ class AgencyInvitationsControllerSpec extends AkkaMaterializerSpec with Resettin

agentAuthStub(agentAffinityAndEnrolments)

whenAnInvitationIsCancelled(any()) thenReturn (Future successful Left("message"))
whenFindingAnInvitation()(any()) thenReturn aFutureOptionInvitation()
whenAnInvitationIsCancelled(any()) thenReturn (Future successful Left(StatusUpdateFailure(Cancelled,"message")))
whenFindingAnInvitation() thenReturn aFutureOptionInvitation()

val response = await(controller.cancelInvitation(arn, mtdSaPendingInvitationId)(FakeRequest()))
response shouldBe invalidInvitationStatus("message")
Expand All @@ -215,7 +215,7 @@ class AgencyInvitationsControllerSpec extends AkkaMaterializerSpec with Resettin

agentAuthStub(agentAffinityAndEnrolments)

whenFindingAnInvitation()(any()) thenReturn aFutureOptionInvitation(new Arn("1234"))
whenFindingAnInvitation() thenReturn aFutureOptionInvitation(new Arn("1234"))

val response = await(controller.cancelInvitation(new Arn("1234"), mtdSaPendingInvitationId)(FakeRequest()))

Expand All @@ -226,7 +226,7 @@ class AgencyInvitationsControllerSpec extends AkkaMaterializerSpec with Resettin

agentAuthStub(agentAffinityAndEnrolments)

whenFindingAnInvitation()(any()) thenReturn aFutureOptionInvitation(Arn("a-different-arn"))
whenFindingAnInvitation() thenReturn aFutureOptionInvitation(Arn("a-different-arn"))

val response = await(controller.cancelInvitation(arn, mtdSaPendingInvitationId)(FakeRequest()))
response shouldBe NoPermissionOnAgency
Expand All @@ -236,7 +236,7 @@ class AgencyInvitationsControllerSpec extends AkkaMaterializerSpec with Resettin

agentAuthStub(agentAffinityAndEnrolments)

whenFindingAnInvitation()(any()) thenReturn (Future successful None)
whenFindingAnInvitation() thenReturn (Future successful None)

val response = await(controller.cancelInvitation(arn, mtdSaPendingInvitationId)(FakeRequest()))
response shouldBe InvitationNotFound
Expand Down Expand Up @@ -270,7 +270,7 @@ class AgencyInvitationsControllerSpec extends AkkaMaterializerSpec with Resettin
private def aFutureOptionInvitation(arn: Arn = arn) =
Future successful Some(anInvitation(arn))

private def whenFindingAnInvitation()(implicit ec: ExecutionContext) = when(invitationsService.findInvitation(any[InvitationId]))
private def whenFindingAnInvitation() = when(invitationsService.findInvitation(any[InvitationId])(any(), any(), any()))

private def whenAnInvitationIsCancelled(implicit ec: ExecutionContext) = when(invitationsService.cancelInvitation(any[Invitation]))
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import reactivemongo.bson.BSONObjectID
import uk.gov.hmrc.agentclientauthorisation.MicroserviceAuthConnector
import uk.gov.hmrc.agentclientauthorisation.controllers.ErrorResults._
import uk.gov.hmrc.agentclientauthorisation.model._
import uk.gov.hmrc.agentclientauthorisation.service.StatusUpdateFailure
import uk.gov.hmrc.agentclientauthorisation.support.TestConstants.{mtdItId1, nino1}
import uk.gov.hmrc.agentclientauthorisation.support.{AkkaMaterializerSpec, ClientEndpointBehaviours, ResettingMockitoSugar, TestData}
import uk.gov.hmrc.agentmtdidentifiers.model.{InvitationId, MtdItId}
Expand Down Expand Up @@ -90,7 +91,7 @@ class ClientInvitationsControllerSpec extends AkkaMaterializerSpec with Resettin
clientAuthStub(clientEnrolments)

whenFindingAnInvitation thenReturn aFutureOptionInvitation()
whenInvitationIsAccepted thenReturn (Future successful Left("failure message"))
whenInvitationIsAccepted thenReturn (Future successful Left(StatusUpdateFailure(Accepted,"failure message")))

val response = await(controller.acceptInvitation(mtdItId1, invitationId)(FakeRequest()))
response shouldBe invalidInvitationStatus("failure message")
Expand Down Expand Up @@ -147,7 +148,7 @@ class ClientInvitationsControllerSpec extends AkkaMaterializerSpec with Resettin
clientAuthStub(clientEnrolments)

whenFindingAnInvitation thenReturn aFutureOptionInvitation()
whenInvitationIsRejected thenReturn (Future successful Left("failure message"))
whenInvitationIsRejected thenReturn (Future successful Left(StatusUpdateFailure(Rejected, "failure message")))

val response = await(controller.rejectInvitation(mtdItId1, invitationId)(FakeRequest()))

Expand Down
Loading

0 comments on commit de95460

Please sign in to comment.